//! 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; use ai_synth_backend::db; 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" ); } // ═══════════════════════════════════════════════════════════════════════════ // find_due_schedules — DB query logic // ═══════════════════════════════════════════════════════════════════════════ /// Return (day_code, time_utc) for the current UTC moment. fn today_day_and_time() -> (String, String) { use chrono::Datelike; let now = chrono::Utc::now(); let day_code = match now.weekday() { chrono::Weekday::Mon => "mon", chrono::Weekday::Tue => "tue", chrono::Weekday::Wed => "wed", chrono::Weekday::Thu => "thu", chrono::Weekday::Fri => "fri", chrono::Weekday::Sat => "sat", chrono::Weekday::Sun => "sun", }; let time = now.format("%H:%M").to_string(); (day_code.to_string(), time) } /// Return the day code for the day *after* the current UTC day (wraps around). fn tomorrow_day_code() -> String { use chrono::Datelike; let tomorrow = chrono::Utc::now() + chrono::Duration::days(1); match tomorrow.weekday() { chrono::Weekday::Mon => "mon".to_string(), chrono::Weekday::Tue => "tue".to_string(), chrono::Weekday::Wed => "wed".to_string(), chrono::Weekday::Thu => "thu".to_string(), chrono::Weekday::Fri => "fri".to_string(), chrono::Weekday::Sat => "sat".to_string(), chrono::Weekday::Sun => "sun".to_string(), } } #[tokio::test] async fn find_due_schedules_returns_matching_schedule() { 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("find-due-match@example.com") .await; let theme_id = create_theme(&app, &session).await; // Use today's day code and "00:00" so the schedule is always already due. let (day_code, _) = today_day_and_time(); let body = serde_json::json!({ "enabled": true, "days": [day_code], "time_utc": "00:00", "emails": [] }); let (status, _) = app .put_with_session( &format!("/api/v1/themes/{}/schedule", theme_id), &body, &session, ) .await; assert_eq!(status, StatusCode::OK); let (day_code, current_time) = today_day_and_time(); let due = db::schedules::find_due_schedules(&app.pool, &day_code, ¤t_time) .await .expect("find_due_schedules should not fail"); let theme_uuid: uuid::Uuid = theme_id.parse().unwrap(); assert!( due.iter().any(|s| s.theme_id == theme_uuid), "Expected the schedule for theme {} to be returned as due, got: {:?}", theme_id, due.iter().map(|s| s.theme_id).collect::>() ); } #[tokio::test] async fn find_due_schedules_skips_disabled_schedule() { 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("find-due-disabled@example.com") .await; let theme_id = create_theme(&app, &session).await; let (day_code, _) = today_day_and_time(); let body = serde_json::json!({ "enabled": false, "days": [day_code], "time_utc": "00:00", "emails": [] }); let (status, _) = app .put_with_session( &format!("/api/v1/themes/{}/schedule", theme_id), &body, &session, ) .await; assert_eq!(status, StatusCode::OK); let (day_code, current_time) = today_day_and_time(); let due = db::schedules::find_due_schedules(&app.pool, &day_code, ¤t_time) .await .expect("find_due_schedules should not fail"); let theme_uuid: uuid::Uuid = theme_id.parse().unwrap(); assert!( !due.iter().any(|s| s.theme_id == theme_uuid), "Disabled schedule should not appear in due list" ); } #[tokio::test] async fn find_due_schedules_skips_already_run_today() { 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("find-due-already-run@example.com") .await; let theme_id = create_theme(&app, &session).await; let (day_code, _) = today_day_and_time(); let body = serde_json::json!({ "enabled": true, "days": [day_code], "time_utc": "00:00", "emails": [] }); let (status, resp) = app .put_with_session( &format!("/api/v1/themes/{}/schedule", theme_id), &body, &session, ) .await; assert_eq!(status, StatusCode::OK); // Mark the schedule as already run let schedule_id: uuid::Uuid = resp["id"].as_str().unwrap().parse().unwrap(); db::schedules::mark_run(&app.pool, schedule_id) .await .expect("mark_run should not fail"); let (day_code, current_time) = today_day_and_time(); let due = db::schedules::find_due_schedules(&app.pool, &day_code, ¤t_time) .await .expect("find_due_schedules should not fail"); let theme_uuid: uuid::Uuid = theme_id.parse().unwrap(); assert!( !due.iter().any(|s| s.theme_id == theme_uuid), "Already-run schedule should not appear in due list" ); } #[tokio::test] async fn find_due_schedules_skips_wrong_day() { 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("find-due-wrong-day@example.com") .await; let theme_id = create_theme(&app, &session).await; // Schedule for tomorrow only, so it must not be due today. let wrong_day = tomorrow_day_code(); let body = serde_json::json!({ "enabled": true, "days": [wrong_day], "time_utc": "00:00", "emails": [] }); let (status, _) = app .put_with_session( &format!("/api/v1/themes/{}/schedule", theme_id), &body, &session, ) .await; assert_eq!(status, StatusCode::OK); let (day_code, current_time) = today_day_and_time(); let due = db::schedules::find_due_schedules(&app.pool, &day_code, ¤t_time) .await .expect("find_due_schedules should not fail"); let theme_uuid: uuid::Uuid = theme_id.parse().unwrap(); assert!( !due.iter().any(|s| s.theme_id == theme_uuid), "Schedule for a different day should not appear in due list" ); }