mod common; use axum::http::StatusCode; use serde_json::json; use sqlx::PgPool; use tower::ServiceExt; #[sqlx::test(migrations = "./migrations")] async fn get_returns_defaults_for_new_user(pool: PgPool) { let h = common::harness(pool); let (_, cookie) = common::register_user(&h.app).await; let resp = h .app .oneshot(common::get_with_cookie("/api/v1/auth/me/preferences", &cookie)) .await .unwrap(); assert_eq!(resp.status(), StatusCode::OK); let body = common::body_json(resp).await; assert_eq!(body["reader_mode"], "single"); assert_eq!(body["reader_page_gap"], "none"); assert!(body.get("updated_at").is_some()); // user_id is server-internal — must not leak. assert!(body.get("user_id").is_none()); } #[sqlx::test(migrations = "./migrations")] async fn patch_persists_mode_and_gap(pool: PgPool) { let h = common::harness(pool); let (_, cookie) = common::register_user(&h.app).await; let resp = h .app .clone() .oneshot(common::patch_json_with_cookie( "/api/v1/auth/me/preferences", json!({ "reader_mode": "continuous", "reader_page_gap": "large" }), &cookie, )) .await .unwrap(); assert_eq!(resp.status(), StatusCode::OK); let body = common::body_json(resp).await; assert_eq!(body["reader_mode"], "continuous"); assert_eq!(body["reader_page_gap"], "large"); // A fresh GET reads the persisted values. let resp = h .app .oneshot(common::get_with_cookie("/api/v1/auth/me/preferences", &cookie)) .await .unwrap(); assert_eq!(resp.status(), StatusCode::OK); let body = common::body_json(resp).await; assert_eq!(body["reader_mode"], "continuous"); assert_eq!(body["reader_page_gap"], "large"); } #[sqlx::test(migrations = "./migrations")] async fn patch_supports_partial_update(pool: PgPool) { let h = common::harness(pool); let (_, cookie) = common::register_user(&h.app).await; // Seed both fields away from their defaults. let _ = h .app .clone() .oneshot(common::patch_json_with_cookie( "/api/v1/auth/me/preferences", json!({ "reader_mode": "continuous", "reader_page_gap": "medium" }), &cookie, )) .await .unwrap(); // PATCH only the mode — the gap must not regress to default. let resp = h .app .clone() .oneshot(common::patch_json_with_cookie( "/api/v1/auth/me/preferences", json!({ "reader_mode": "single" }), &cookie, )) .await .unwrap(); assert_eq!(resp.status(), StatusCode::OK); let body = common::body_json(resp).await; assert_eq!(body["reader_mode"], "single"); assert_eq!(body["reader_page_gap"], "medium"); } #[sqlx::test(migrations = "./migrations")] async fn patch_rejects_invalid_mode(pool: PgPool) { let h = common::harness(pool); let (_, cookie) = common::register_user(&h.app).await; let resp = h .app .oneshot(common::patch_json_with_cookie( "/api/v1/auth/me/preferences", json!({ "reader_mode": "tachiyomi" }), &cookie, )) .await .unwrap(); assert_eq!(resp.status(), StatusCode::BAD_REQUEST); let body = common::body_json(resp).await; assert_eq!(body["error"]["code"], "invalid_input"); } #[sqlx::test(migrations = "./migrations")] async fn patch_rejects_invalid_gap(pool: PgPool) { let h = common::harness(pool); let (_, cookie) = common::register_user(&h.app).await; let resp = h .app .oneshot(common::patch_json_with_cookie( "/api/v1/auth/me/preferences", json!({ "reader_page_gap": "huge" }), &cookie, )) .await .unwrap(); assert_eq!(resp.status(), StatusCode::BAD_REQUEST); let body = common::body_json(resp).await; assert_eq!(body["error"]["code"], "invalid_input"); } #[sqlx::test(migrations = "./migrations")] async fn requires_authentication(pool: PgPool) { let h = common::harness(pool); let resp = h .app .clone() .oneshot(common::get("/api/v1/auth/me/preferences")) .await .unwrap(); assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); let resp = h .app .oneshot(common::patch_json( "/api/v1/auth/me/preferences", json!({ "reader_mode": "continuous" }), )) .await .unwrap(); assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); } #[sqlx::test(migrations = "./migrations")] async fn preferences_are_per_user(pool: PgPool) { let h = common::harness(pool); let (_, cookie_a) = common::register_user(&h.app).await; let (_, cookie_b) = common::register_user(&h.app).await; // A switches to continuous + large; B is untouched and must keep defaults. let _ = h .app .clone() .oneshot(common::patch_json_with_cookie( "/api/v1/auth/me/preferences", json!({ "reader_mode": "continuous", "reader_page_gap": "large" }), &cookie_a, )) .await .unwrap(); let resp_b = h .app .clone() .oneshot(common::get_with_cookie("/api/v1/auth/me/preferences", &cookie_b)) .await .unwrap(); let body_b = common::body_json(resp_b).await; assert_eq!(body_b["reader_mode"], "single"); assert_eq!(body_b["reader_page_gap"], "none"); // A still sees their own update. let resp_a = h .app .oneshot(common::get_with_cookie("/api/v1/auth/me/preferences", &cookie_a)) .await .unwrap(); let body_a = common::body_json(resp_a).await; assert_eq!(body_a["reader_mode"], "continuous"); assert_eq!(body_a["reader_page_gap"], "large"); } /// Direct repo + SQL: a deleted user must cascade-delete their preferences /// row. Guards against accidental loss of `ON DELETE CASCADE` in future /// migrations. #[sqlx::test(migrations = "./migrations")] async fn preferences_cascade_on_user_delete(pool: PgPool) { use mangalord::repo; // Insert a user directly so we don't need the API harness here. let user_id: uuid::Uuid = sqlx::query_scalar( "INSERT INTO users (username, password_hash) VALUES ($1, $2) RETURNING id", ) .bind("cascade-test") .bind("not-a-real-hash") .fetch_one(&pool) .await .unwrap(); repo::user_preferences::upsert(&pool, user_id, "continuous", "small") .await .unwrap(); assert!(repo::user_preferences::find(&pool, user_id) .await .unwrap() .is_some()); sqlx::query("DELETE FROM users WHERE id = $1") .bind(user_id) .execute(&pool) .await .unwrap(); assert!(repo::user_preferences::find(&pool, user_id) .await .unwrap() .is_none()); }