mod support; use axum::{ Router, body::Body, http::{Request, StatusCode}, }; use sqlx::SqlitePool; use tower::ServiceExt; use uuid::Uuid; async fn test_app() -> Router { support::server().await.0 } async fn test_app_with_user() -> (Router, SqlitePool, Uuid, String) { let (app, pool) = support::server().await; let session = support::register(&app, "insights", "insights@example.com").await; let user_id = session.user_id.parse().unwrap(); (app, pool, user_id, session.token) } /// Inject a fake peer IP so the GovernorLayer can extract ConnectInfo. fn with_ip(req: Request) -> Request { let addr: std::net::SocketAddr = "127.0.0.1:12345".parse().unwrap(); let mut req = req; req.extensions_mut() .insert(axum::extract::ConnectInfo::(addr)); req } #[tokio::test] async fn get_api_diary_returns_empty_list() { let app = test_app().await; let response = app .oneshot(with_ip( Request::builder() .uri("/api/v1/diary") .body(Body::empty()) .unwrap(), )) .await .unwrap(); assert_eq!(response.status(), StatusCode::OK); let bytes = axum::body::to_bytes(response.into_body(), usize::MAX) .await .unwrap(); let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap(); assert_eq!(json["total_count"], 0); assert_eq!(json["items"], serde_json::json!([])); assert_eq!(json["limit"], 5); assert_eq!(json["offset"], 0); } #[tokio::test] async fn post_api_reviews_without_auth_returns_401() { let app = test_app().await; let response = app .oneshot(with_ip( Request::builder() .method("POST") .uri("/api/v1/reviews") .header("content-type", "application/json") .body(Body::from( r#"{"rating":4,"watched_at":"2026-01-01T20:00:00","manual_title":"Dune","manual_release_year":2021}"#, )) .unwrap(), )) .await .unwrap(); assert_eq!(response.status(), StatusCode::UNAUTHORIZED); } #[tokio::test] async fn post_api_auth_login_unknown_user_returns_401() { let app = test_app().await; let response = app .oneshot(with_ip( Request::builder() .method("POST") .uri("/api/v1/auth/login") .header("content-type", "application/json") .body(Body::from(r#"{"email":"a@b.com","password":"x"}"#)) .unwrap(), )) .await .unwrap(); assert_eq!(response.status(), StatusCode::UNAUTHORIZED); } #[tokio::test] async fn get_api_movie_detail_returns_404_for_unknown_id() { let app = test_app().await; let response = app .oneshot(with_ip( Request::builder() .uri("/api/v1/movies/00000000-0000-0000-0000-000000000000") .body(Body::empty()) .unwrap(), )) .await .unwrap(); assert_eq!(response.status(), StatusCode::NOT_FOUND); } #[tokio::test] async fn tags_moviesdiary_redirects_to_home() { let app = test_app().await; let response = app .oneshot(with_ip( Request::builder() .uri("/tags/moviesdiary") .body(Body::empty()) .unwrap(), )) .await .unwrap(); assert_eq!(response.status(), StatusCode::TEMPORARY_REDIRECT); assert_eq!(response.headers().get("location").unwrap(), "/"); } #[tokio::test] async fn tags_other_redirects_to_search() { let app = test_app().await; let response = app .oneshot(with_ip( Request::builder() .uri("/tags/batman") .body(Body::empty()) .unwrap(), )) .await .unwrap(); assert_eq!(response.status(), StatusCode::TEMPORARY_REDIRECT); assert_eq!( response.headers().get("location").unwrap(), "/?search=batman" ); } #[tokio::test] async fn get_movie_detail_html_returns_404_for_unknown_id() { let app = test_app().await; let response = app .oneshot(with_ip( Request::builder() .uri("/movies/00000000-0000-0000-0000-000000000000") .body(Body::empty()) .unwrap(), )) .await .unwrap(); assert_eq!(response.status(), StatusCode::NOT_FOUND); } // ── Insights ───────────────────────────────────────────────────────────────── async fn seed_insights_diary(pool: &SqlitePool, user_id: Uuid) { // 12 thrillers rated 5 late at night, 12 comedies rated 2 — enough for a // rating lift once genres come from movie_genres. for i in 0..12u32 { let movie_id = uuid::Uuid::new_v4().to_string(); sqlx::query("INSERT INTO movies (id, title, release_year) VALUES (?, ?, ?)") .bind(&movie_id) .bind(format!("Thriller {i}")) .bind(2020) .execute(pool) .await .unwrap(); sqlx::query("INSERT INTO movie_genres (movie_id, tmdb_id, name) VALUES (?, ?, ?)") .bind(&movie_id) .bind(44400 + i as i64) .bind("Thriller") .execute(pool) .await .unwrap(); sqlx::query( "INSERT INTO reviews (id, movie_id, user_id, rating, watched_at, created_at) \ VALUES (?, ?, ?, ?, ?, ?)", ) .bind(uuid::Uuid::new_v4().to_string()) .bind(&movie_id) .bind(user_id.to_string()) .bind(5) .bind("2024-01-05 22:30:00") .bind("2024-01-05 22:30:00") .execute(pool) .await .unwrap(); let movie_id = uuid::Uuid::new_v4().to_string(); sqlx::query("INSERT INTO movies (id, title, release_year) VALUES (?, ?, ?)") .bind(&movie_id) .bind(format!("Comedy {i}")) .bind(2015) .execute(pool) .await .unwrap(); sqlx::query("INSERT INTO movie_genres (movie_id, tmdb_id, name) VALUES (?, ?, ?)") .bind(&movie_id) .bind(55300 + i as i64) .bind("Comedy") .execute(pool) .await .unwrap(); sqlx::query( "INSERT INTO reviews (id, movie_id, user_id, rating, watched_at, created_at) \ VALUES (?, ?, ?, ?, ?, ?)", ) .bind(uuid::Uuid::new_v4().to_string()) .bind(&movie_id) .bind(user_id.to_string()) .bind(2) .bind("2024-03-10 13:00:00") .bind("2024-03-10 13:00:00") .execute(pool) .await .unwrap(); } } #[tokio::test] async fn get_api_insights_returns_rating_lift_for_own_user() { let (app, pool, user_id, token) = test_app_with_user().await; seed_insights_diary(&pool, user_id).await; let response = app .oneshot(with_ip( Request::builder() .uri("/api/v1/insights") .header("authorization", format!("Bearer {token}")) .body(Body::empty()) .unwrap(), )) .await .unwrap(); let (status, bytes) = ( response.status(), axum::body::to_bytes(response.into_body(), usize::MAX) .await .unwrap(), ); assert_eq!( status, StatusCode::OK, "body: {}", String::from_utf8(bytes.to_vec()).unwrap() ); let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap(); let rating_lifts: Vec<&serde_json::Value> = json["insights"] .as_array() .unwrap() .iter() .filter(|i| i["kind"] == "rating_lift") .collect(); assert_eq!(rating_lifts.len(), 2, "thriller up, comedy down: {json}"); let thriller = rating_lifts .iter() .find(|i| i["genre"] == "Thriller") .unwrap(); assert_eq!(thriller["delta"], 1.5); assert_eq!(thriller["sample_count"], 12); // Timestamps are real → rhythm fires. Hours split evenly between 22 // (thrillers) and 13 (comedies); every window covering either bucket ties, // so the earliest one, 11:00–14:00, wins. Days tie too (Fri/Sun), sorted // ascending. let rhythm = json["insights"] .as_array() .unwrap() .iter() .find(|i| i["kind"] == "rhythm") .expect("rhythm expected for real timestamps"); assert_eq!(rhythm["peak_hour_start"], 11); assert_eq!(rhythm["peak_hour_end"], 14); assert_eq!(rhythm["top_days"], serde_json::json!([5, 7])); } async fn get_insights_json( app: Router, token: &str, query: &str, ) -> (StatusCode, serde_json::Value) { let response = app .oneshot(with_ip( Request::builder() .uri(format!("/api/v1/insights{query}")) .header("authorization", format!("Bearer {token}")) .body(Body::empty()) .unwrap(), )) .await .unwrap(); let status = response.status(); let bytes = axum::body::to_bytes(response.into_body(), usize::MAX) .await .unwrap(); let json = serde_json::from_slice(&bytes).unwrap_or(serde_json::Value::Null); (status, json) } #[tokio::test] async fn get_api_insights_ignores_remote_reviews() { let (app, pool, user_id, token) = test_app_with_user().await; seed_insights_diary(&pool, user_id).await; // Federated rows stored under the same user_id are someone else's watches. for i in 0..12u32 { let movie_id = uuid::Uuid::new_v4().to_string(); sqlx::query("INSERT INTO movies (id, title, release_year) VALUES (?, ?, ?)") .bind(&movie_id) .bind(format!("Horror {i}")) .bind(2019) .execute(&pool) .await .unwrap(); sqlx::query("INSERT INTO movie_genres (movie_id, tmdb_id, name) VALUES (?, ?, ?)") .bind(&movie_id) .bind(66600 + i as i64) .bind("Horror") .execute(&pool) .await .unwrap(); sqlx::query( "INSERT INTO reviews (id, movie_id, user_id, rating, watched_at, created_at, remote_actor_url) \ VALUES (?, ?, ?, ?, ?, ?, ?)", ) .bind(uuid::Uuid::new_v4().to_string()) .bind(&movie_id) .bind(user_id.to_string()) .bind(5) .bind("2024-05-05 09:00:00") .bind("2024-05-05 09:00:00") .bind("https://remote.example/users/someone") .execute(&pool) .await .unwrap(); } let (status, json) = get_insights_json(app, &token, "").await; assert_eq!(status, StatusCode::OK); let insights = json["insights"].as_array().unwrap(); assert!( !insights.iter().any(|i| i["genre"] == "Horror"), "remote reviews must not feed insights: {json}" ); let thriller = insights .iter() .find(|i| i["kind"] == "rating_lift" && i["genre"] == "Thriller") .unwrap(); assert_eq!(thriller["delta"], 1.5); } #[tokio::test] async fn get_api_insights_buckets_rhythm_in_requested_time_zone() { let (app, pool, user_id, token) = test_app_with_user().await; seed_insights_diary(&pool, user_id).await; // January in Warsaw is UTC+1: 22:30 → 23:30 and 13:00 → 14:00, so the // earliest tied window moves from 11–14 to 12–15. let (status, json) = get_insights_json(app, &token, "?tz=Europe/Warsaw").await; assert_eq!(status, StatusCode::OK); let rhythm = json["insights"] .as_array() .unwrap() .iter() .find(|i| i["kind"] == "rhythm") .expect("rhythm expected"); assert_eq!(rhythm["peak_hour_start"], 12); assert_eq!(rhythm["peak_hour_end"], 15); } #[tokio::test] async fn get_api_insights_rejects_unknown_time_zone() { let (app, _pool, _user_id, token) = test_app_with_user().await; let (status, _) = get_insights_json(app, &token, "?tz=Mars/Olympus").await; assert_eq!(status, StatusCode::BAD_REQUEST); }