diff --git a/backend/tests/api_schedules_test.rs b/backend/tests/api_schedules_test.rs new file mode 100644 index 0000000..44aa9a2 --- /dev/null +++ b/backend/tests/api_schedules_test.rs @@ -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" + ); +} diff --git a/e2e/tests/themes.spec.ts b/e2e/tests/themes.spec.ts index fbb7e38..a7b599c 100644 --- a/e2e/tests/themes.spec.ts +++ b/e2e/tests/themes.spec.ts @@ -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' },