You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
618 lines
20 KiB
Rust
618 lines
20 KiB
Rust
//! 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::<Vec<_>>()
|
|
);
|
|
}
|
|
|
|
#[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"
|
|
);
|
|
}
|