test: add schedule CRUD integration tests and E2E

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
master
oabrivard 3 months ago
parent f989f592a9
commit e97b01c819

@ -0,0 +1,402 @@
//! Integration tests for the schedule CRUD endpoints.
//!
//! Tests:
//! - GET /api/v1/themes/:id/schedule — get schedule (null when none)
//! - PUT /api/v1/themes/:id/schedule — create or update schedule
//! - DELETE /api/v1/themes/:id/schedule — delete schedule
//!
//! Covers authentication, validation, ownership isolation, and CRUD operations.
//!
//! Requires a running Postgres instance. Set `TEST_DATABASE_URL` to run.
mod common;
use axum::http::StatusCode;
fn require_test_db() -> bool {
std::env::var("TEST_DATABASE_URL").is_ok()
}
/// Helper: create a theme and return its ID.
async fn create_theme(app: &common::TestApp, session: &str) -> String {
let body = serde_json::json!({
"name": "Schedule Test",
"theme": "Test Topic",
"categories": ["Cat"]
});
let (status, resp) = app.post_with_session("/api/v1/themes", &body, session).await;
assert_eq!(status.as_u16(), 201);
resp["id"].as_str().unwrap().to_string()
}
/// A valid schedule body used across multiple tests.
fn valid_schedule() -> serde_json::Value {
serde_json::json!({
"enabled": true,
"days": ["mon", "wed", "fri"],
"time_utc": "08:00",
"emails": ["test@example.com"]
})
}
// ═══════════════════════════════════════════════════════════════════════════
// Authentication
// ═══════════════════════════════════════════════════════════════════════════
#[tokio::test]
async fn schedule_requires_auth() {
if !require_test_db() {
eprintln!("SKIPPED: TEST_DATABASE_URL not set");
return;
}
let app = common::TestApp::new().await;
let fake_theme_id = uuid::Uuid::new_v4();
// GET without valid session
let (status, body) = app
.get_with_session(
&format!("/api/v1/themes/{}/schedule", fake_theme_id),
"invalid-session-token",
)
.await;
assert_eq!(
status,
StatusCode::UNAUTHORIZED,
"GET /themes/:id/schedule without auth should return 401"
);
assert_eq!(body["error"], "unauthorized");
}
// ═══════════════════════════════════════════════════════════════════════════
// GET — no schedule yet
// ═══════════════════════════════════════════════════════════════════════════
#[tokio::test]
async fn get_schedule_returns_null_when_none() {
if !require_test_db() {
eprintln!("SKIPPED: TEST_DATABASE_URL not set");
return;
}
let app = common::TestApp::new().await;
let (_user_id, session) = app
.create_authenticated_user("sched-get-null@example.com")
.await;
let theme_id = create_theme(&app, &session).await;
let (status, resp) = app
.get_with_session(&format!("/api/v1/themes/{}/schedule", theme_id), &session)
.await;
assert_eq!(
status,
StatusCode::OK,
"GET /themes/:id/schedule with no schedule should return 200"
);
assert!(
resp.is_null(),
"Body should be null when no schedule exists, got: {:?}",
resp
);
}
// ═══════════════════════════════════════════════════════════════════════════
// PUT — create
// ═══════════════════════════════════════════════════════════════════════════
#[tokio::test]
async fn upsert_schedule_creates_and_returns() {
if !require_test_db() {
eprintln!("SKIPPED: TEST_DATABASE_URL not set");
return;
}
let app = common::TestApp::new().await;
let (_user_id, session) = app
.create_authenticated_user("sched-create@example.com")
.await;
let theme_id = create_theme(&app, &session).await;
let (status, resp) = app
.put_with_session(
&format!("/api/v1/themes/{}/schedule", theme_id),
&valid_schedule(),
&session,
)
.await;
assert_eq!(
status,
StatusCode::OK,
"PUT /themes/:id/schedule with valid body should return 200"
);
assert!(resp["id"].as_str().is_some(), "Response should have an id");
assert_eq!(resp["theme_id"].as_str().unwrap(), theme_id);
assert_eq!(resp["enabled"], true);
let days = resp["days"].as_array().expect("days should be an array");
assert_eq!(days.len(), 3);
assert!(days.contains(&serde_json::json!("mon")));
assert!(days.contains(&serde_json::json!("wed")));
assert!(days.contains(&serde_json::json!("fri")));
assert_eq!(resp["time_utc"], "08:00");
let emails = resp["emails"].as_array().expect("emails should be an array");
assert_eq!(emails.len(), 1);
assert_eq!(emails[0], "test@example.com");
}
// ═══════════════════════════════════════════════════════════════════════════
// PUT — update existing
// ═══════════════════════════════════════════════════════════════════════════
#[tokio::test]
async fn upsert_schedule_updates_existing() {
if !require_test_db() {
eprintln!("SKIPPED: TEST_DATABASE_URL not set");
return;
}
let app = common::TestApp::new().await;
let (_user_id, session) = app
.create_authenticated_user("sched-update@example.com")
.await;
let theme_id = create_theme(&app, &session).await;
let url = format!("/api/v1/themes/{}/schedule", theme_id);
// Create initial schedule with mon/wed/fri
let (status, _) = app
.put_with_session(&url, &valid_schedule(), &session)
.await;
assert_eq!(status, StatusCode::OK);
// Update with different days and time
let updated_body = serde_json::json!({
"enabled": false,
"days": ["tue", "thu"],
"time_utc": "14:30",
"emails": ["updated@example.com", "second@example.com"]
});
let (status, resp) = app
.put_with_session(&url, &updated_body, &session)
.await;
assert_eq!(
status,
StatusCode::OK,
"Second PUT should also return 200"
);
assert_eq!(resp["enabled"], false);
let days = resp["days"].as_array().expect("days should be an array");
assert_eq!(days.len(), 2);
assert!(days.contains(&serde_json::json!("tue")));
assert!(days.contains(&serde_json::json!("thu")));
assert_eq!(resp["time_utc"], "14:30");
let emails = resp["emails"].as_array().expect("emails should be an array");
assert_eq!(emails.len(), 2);
}
// ═══════════════════════════════════════════════════════════════════════════
// Validation — 422 cases
// ═══════════════════════════════════════════════════════════════════════════
#[tokio::test]
async fn upsert_schedule_invalid_day_returns_422() {
if !require_test_db() {
eprintln!("SKIPPED: TEST_DATABASE_URL not set");
return;
}
let app = common::TestApp::new().await;
let (_user_id, session) = app
.create_authenticated_user("sched-invalid-day@example.com")
.await;
let theme_id = create_theme(&app, &session).await;
let body = serde_json::json!({
"enabled": true,
"days": ["invalid"],
"time_utc": "08:00",
"emails": []
});
let (status, _resp) = app
.put_with_session(
&format!("/api/v1/themes/{}/schedule", theme_id),
&body,
&session,
)
.await;
assert_eq!(
status,
StatusCode::UNPROCESSABLE_ENTITY,
"PUT with invalid day should return 422"
);
}
#[tokio::test]
async fn upsert_schedule_too_many_emails_returns_422() {
if !require_test_db() {
eprintln!("SKIPPED: TEST_DATABASE_URL not set");
return;
}
let app = common::TestApp::new().await;
let (_user_id, session) = app
.create_authenticated_user("sched-too-many-emails@example.com")
.await;
let theme_id = create_theme(&app, &session).await;
// 4 emails exceeds the 3-email limit
let body = serde_json::json!({
"enabled": true,
"days": ["mon"],
"time_utc": "08:00",
"emails": [
"one@example.com",
"two@example.com",
"three@example.com",
"four@example.com"
]
});
let (status, _resp) = app
.put_with_session(
&format!("/api/v1/themes/{}/schedule", theme_id),
&body,
&session,
)
.await;
assert_eq!(
status,
StatusCode::UNPROCESSABLE_ENTITY,
"PUT with 4 emails should return 422"
);
}
#[tokio::test]
async fn upsert_schedule_invalid_time_returns_422() {
if !require_test_db() {
eprintln!("SKIPPED: TEST_DATABASE_URL not set");
return;
}
let app = common::TestApp::new().await;
let (_user_id, session) = app
.create_authenticated_user("sched-invalid-time@example.com")
.await;
let theme_id = create_theme(&app, &session).await;
let body = serde_json::json!({
"enabled": true,
"days": ["mon"],
"time_utc": "25:00",
"emails": []
});
let (status, _resp) = app
.put_with_session(
&format!("/api/v1/themes/{}/schedule", theme_id),
&body,
&session,
)
.await;
assert_eq!(
status,
StatusCode::UNPROCESSABLE_ENTITY,
"PUT with time_utc '25:00' should return 422"
);
}
// ═══════════════════════════════════════════════════════════════════════════
// DELETE
// ═══════════════════════════════════════════════════════════════════════════
#[tokio::test]
async fn delete_schedule_returns_204() {
if !require_test_db() {
eprintln!("SKIPPED: TEST_DATABASE_URL not set");
return;
}
let app = common::TestApp::new().await;
let (_user_id, session) = app
.create_authenticated_user("sched-delete@example.com")
.await;
let theme_id = create_theme(&app, &session).await;
let url = format!("/api/v1/themes/{}/schedule", theme_id);
// Create a schedule first
let (status, _) = app
.put_with_session(&url, &valid_schedule(), &session)
.await;
assert_eq!(status, StatusCode::OK);
// Delete it
let (status, _) = app.delete_with_session(&url, &session).await;
assert_eq!(
status,
StatusCode::NO_CONTENT,
"DELETE /themes/:id/schedule should return 204"
);
// GET should now return null
let (status, resp) = app.get_with_session(&url, &session).await;
assert_eq!(status, StatusCode::OK);
assert!(
resp.is_null(),
"GET after delete should return null, got: {:?}",
resp
);
}
// ═══════════════════════════════════════════════════════════════════════════
// Ownership isolation
// ═══════════════════════════════════════════════════════════════════════════
#[tokio::test]
async fn schedule_requires_theme_ownership() {
if !require_test_db() {
eprintln!("SKIPPED: TEST_DATABASE_URL not set");
return;
}
let app = common::TestApp::new().await;
// User A creates a theme
let (_user_a_id, session_a) = app
.create_authenticated_user("sched-owner-a@example.com")
.await;
let theme_id = create_theme(&app, &session_a).await;
// User B tries to PUT a schedule on User A's theme
let (_user_b_id, session_b) = app
.create_authenticated_user("sched-owner-b@example.com")
.await;
let (status, _resp) = app
.put_with_session(
&format!("/api/v1/themes/{}/schedule", theme_id),
&valid_schedule(),
&session_b,
)
.await;
assert_eq!(
status,
StatusCode::NOT_FOUND,
"User B should get 404 when attempting to PUT schedule on User A's theme"
);
}

@ -68,7 +68,41 @@ test.describe('Theme management', () => {
expect(updateResp.status).toBe(200);
expect(updateResp.data.name).toBe('E2E Updated Theme');
// Step 6: Delete the theme via API
// Step 6: Create and verify schedule via API
const schedResp = await page.evaluate(async (tid: string) => {
const resp = await fetch(`/api/v1/themes/${tid}/schedule`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'X-Requested-With': 'XMLHttpRequest',
},
credentials: 'same-origin',
body: JSON.stringify({
enabled: true,
days: ['mon', 'fri'],
time_utc: '09:00',
emails: ['test@example.com'],
}),
});
return { status: resp.status, data: await resp.json() };
}, themeId);
expect(schedResp.status).toBe(200);
expect(schedResp.data.days).toContain('mon');
expect(schedResp.data.days).toContain('fri');
expect(schedResp.data.emails.length).toBe(1);
// Step 7: Delete schedule
const delSchedResp = await page.evaluate(async (tid: string) => {
const resp = await fetch(`/api/v1/themes/${tid}/schedule`, {
method: 'DELETE',
headers: { 'X-Requested-With': 'XMLHttpRequest' },
credentials: 'same-origin',
});
return { status: resp.status };
}, themeId);
expect(delSchedResp.status).toBe(204);
// Step 8: Delete the theme via API
const deleteResp = await page.evaluate(async (id: string) => {
const resp = await fetch(`/api/v1/themes/${id}`, {
method: 'DELETE',
@ -79,7 +113,7 @@ test.describe('Theme management', () => {
}, themeId);
expect(deleteResp.status).toBe(204);
// Step 7: Verify theme is gone
// Step 9: Verify theme is gone
const listResp = await page.evaluate(async () => {
const resp = await fetch('/api/v1/themes', {
headers: { 'X-Requested-With': 'XMLHttpRequest' },

Loading…
Cancel
Save