//! Characterization of the classic HTML routes, feeds and the OpenAPI surface, //! recorded before the HTTP layer moves. Statuses are what the app returns today. use axum::{ Router, body::Body, http::{Method, Request, header}, }; use tower::ServiceExt; const PASSWORD: &str = "correct-horse-battery"; const EMAIL: &str = "owner@example.com"; const USERNAME: &str = "owner"; /// Public classic pages. `{id}`, `{username}`, `{movie}` are replaced by seeded values. const CLASSIC_VISITOR_PAGES: &[(&str, u16)] = &[ ("/", 200), ("/users", 200), ("/u/{username}", 308), ("/users/{id}", 200), ("/users/{id}?view=ratings", 200), ("/users/{id}?view=history", 200), ("/movies/{movie}", 200), ("/login", 200), ("/register", 200), ("/logout", 303), ("/tags/drama", 307), ("/users/{id}/watchlist", 200), ("/wrapups/{id}/2025", 200), ("/wrapups/global/2025", 200), ("/users/{id}/following-list", 303), ("/users/{id}/followers-list", 303), ("/posters/abc.jpg", 308), ("/images/missing.jpg", 404), ("/health", 200), ]; /// Pages that need the signed-in owner. const CLASSIC_OWNER_PAGES: &[(&str, u16)] = &[ ("/reviews/new", 200), ("/diary/export", 200), ("/import", 200), ("/import/done", 200), ("/import/00000000-0000-0000-0000-000000000000/mapping", 303), ("/import/00000000-0000-0000-0000-000000000000/preview", 303), ("/settings/profile", 200), ("/settings/integrations", 200), ("/watch-queue", 200), ("/users/{id}/watchlist", 200), ("/users/{id}/following-list", 200), ("/users/{id}/followers-list", 200), ("/movies/{movie}", 200), ("/users/{id}?embed=true", 200), ]; #[cfg(feature = "federation")] const FEDERATION_VISITOR_PAGES: &[(&str, u16)] = &[ ("/users/{id}/followers", 303), ("/users/{id}/following", 303), ]; #[cfg(feature = "federation")] const FEDERATION_OWNER_PAGES: &[(&str, u16)] = &[ ("/users/{id}/followers", 303), ("/users/{id}/following", 303), // The registered owner is not an admin. ("/admin/blocked-domains", 403), ("/social/blocked", 200), ]; /// Without federation the collection URLs permanently redirect to the HTML lists. #[cfg(not(feature = "federation"))] const FEDERATION_OFF_PAGES: &[(&str, u16)] = &[ ("/users/{id}/followers", 308), ("/users/{id}/following", 308), ]; struct Seed { router: Router, user_id: String, movie_id: String, /// Cookie header for the signed-in owner (csrf + token). owner_cookie: String, } fn test_config() -> config::AppConfig { config::AppConfig { server: config::ServerConfig { allow_registration: true, base_url: "http://localhost:3000".into(), ..Default::default() }, database: config::DatabaseConfig { url: "sqlite::memory:".into(), }, auth: config::AuthConfig { jwt_secret: Some("route-smoke-test-secret-0123456789abcdef".into()), ..Default::default() }, events: Default::default(), nats: Default::default(), storage: Default::default(), image_conversion: Default::default(), // Required to build; never called (seeding logs reviews with manual titles). metadata: config::MetadataConfig { tmdb_api_key: Some("test-key".into()), ..Default::default() }, federation: Default::default(), wrapup: Default::default(), } } async fn send(router: &Router, mut req: Request) -> (u16, axum::http::HeaderMap, String) { let addr: std::net::SocketAddr = "127.0.0.1:12345".parse().unwrap(); req.extensions_mut() .insert(axum::extract::ConnectInfo::(addr)); let resp = router.clone().oneshot(req).await.unwrap(); let status = resp.status().as_u16(); let headers = resp.headers().clone(); let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX) .await .unwrap(); ( status, headers, String::from_utf8_lossy(&bytes).into_owned(), ) } fn get(uri: &str, cookie: Option<&str>) -> Request { let mut b = Request::builder().method(Method::GET).uri(uri); if let Some(c) = cookie { b = b.header(header::COOKIE, c); } b.body(Body::empty()).unwrap() } fn cookie_value(headers: &axum::http::HeaderMap, name: &str) -> Option { headers .get_all(header::SET_COOKIE) .iter() .filter_map(|v| v.to_str().ok()) .find_map(|c| { c.split(';') .next()? .trim() .strip_prefix(&format!("{name}=")) .map(str::to_string) }) } fn json_request( method: Method, uri: &str, token: Option<&str>, body: serde_json::Value, ) -> Request { let mut b = Request::builder() .method(method) .uri(uri) .header(header::CONTENT_TYPE, "application/json"); if let Some(t) = token { b = b.header(header::AUTHORIZATION, format!("Bearer {t}")); } b.body(Body::from(body.to_string())).unwrap() } /// One user (registered through the classic form), one movie with a spoiler review, one goal. async fn seed() -> Seed { let pool = sqlx::sqlite::SqlitePoolOptions::new() .max_connections(1) .connect("sqlite::memory:") .await .unwrap(); let router = bootstrap::build_server_on(&test_config(), pool.clone()) .await .unwrap(); // Register through the form: GET for the csrf cookie, then POST with the token. let (status, headers, _) = send(&router, get("/register", None)).await; assert_eq!(status, 200, "GET /register"); let csrf = cookie_value(&headers, "csrf").expect("csrf cookie"); let form = format!( "email={}&username={USERNAME}&password={PASSWORD}&_csrf={csrf}", EMAIL.replace('@', "%40") ); let req = Request::builder() .method(Method::POST) .uri("/register") .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded") .header(header::COOKIE, format!("csrf={csrf}")) .body(Body::from(form)) .unwrap(); let (status, headers, _) = send(&router, req).await; assert_eq!(status, 303, "POST /register"); let session = cookie_value(&headers, "token").expect("session cookie after register"); let owner_cookie = format!("csrf={csrf}; token={session}"); // Bearer token for the JSON API. let (status, _, body) = send( &router, json_request( Method::POST, "/api/v1/auth/login", None, serde_json::json!({ "email": EMAIL, "password": PASSWORD }), ), ) .await; assert_eq!(status, 200, "login: {body}"); let login: serde_json::Value = serde_json::from_str(&body).unwrap(); let token = login["token"].as_str().unwrap().to_string(); let user_id = login["user_id"].as_str().unwrap().to_string(); let (status, _, body) = send( &router, json_request( Method::POST, "/api/v1/reviews", Some(&token), serde_json::json!({ "manual_title": "Seeded Movie", "manual_release_year": 1999, "manual_director": "Some Director", "rating": 4, "comment": "the butler did it", "watched_at": "2025-03-01", "spoilers": true, }), ), ) .await; assert_eq!(status, 201, "log review: {body}"); let (status, _, body) = send( &router, json_request( Method::POST, "/api/v1/goals", Some(&token), serde_json::json!({ "year": 2025, "target_count": 12 }), ), ) .await; assert_eq!(status, 200, "create goal: {body}"); let (status, _, body) = send( &router, Request::builder() .uri("/api/v1/movies") .header(header::AUTHORIZATION, format!("Bearer {token}")) .body(Body::empty()) .unwrap(), ) .await; assert_eq!(status, 200, "list movies: {body}"); let movies: serde_json::Value = serde_json::from_str(&body).unwrap(); let movie_id = movies["items"][0]["id"] .as_str() .unwrap_or_else(|| panic!("no movie id in {body}")) .to_string(); seed_ready_wrapup(&pool, Some(&user_id)).await; seed_ready_wrapup(&pool, None).await; Seed { router, user_id, movie_id, owner_cookie, } } /// Inserts a READY 2025 wrap-up record (user scope, or global when `user_id` is None), /// the state the worker leaves behind after generating a report. async fn seed_ready_wrapup(pool: &sqlx::SqlitePool, user_id: Option<&str>) { use chrono::NaiveDate; use domain::models::wrapup::{DateRange, WrapUpScope}; let start = NaiveDate::from_ymd_opt(2025, 1, 1).unwrap(); let end = NaiveDate::from_ymd_opt(2026, 1, 1).unwrap(); let scope = match user_id { Some(id) => WrapUpScope::User(id.parse().unwrap()), None => WrapUpScope::Global, }; let report = domain::services::wrapup_analyzer::build_report( scope, DateRange::new(start, end).unwrap(), &[], ); sqlx::query( "INSERT INTO wrap_up_records (id, user_id, start_date, end_date, status, report_json) \ VALUES (?, ?, '2025-01-01', '2026-01-01', 'ready', ?)", ) .bind(uuid::Uuid::new_v4().to_string()) .bind(user_id) .bind(serde_json::to_string(&report).unwrap()) .execute(pool) .await .unwrap(); } fn expand(seed: &Seed, template: &str) -> String { template .replace("{id}", &seed.user_id) .replace("{username}", USERNAME) .replace("{movie}", &seed.movie_id) } async fn check_table(seed: &Seed, table: &[(&str, u16)], cookie: Option<&str>) { let mut failures = Vec::new(); for (template, expected) in table { let uri = expand(seed, template); let (status, _, _) = send(&seed.router, get(&uri, cookie)).await; if status != *expected { failures.push(format!("{uri}: expected {expected}, got {status}")); } } assert!( failures.is_empty(), "route status drift:\n{}", failures.join("\n") ); } #[tokio::test] async fn every_classic_page_renders_for_a_visitor() { let seed = seed().await; check_table(&seed, CLASSIC_VISITOR_PAGES, None).await; #[cfg(feature = "federation")] check_table(&seed, FEDERATION_VISITOR_PAGES, None).await; #[cfg(not(feature = "federation"))] check_table(&seed, FEDERATION_OFF_PAGES, None).await; // A dropped wrapup route would also 404, so require the template itself. for template in ["/wrapups/{id}/2025", "/wrapups/global/2025"] { let (status, _, body) = send(&seed.router, get(&expand(&seed, template), None)).await; assert_eq!(status, 200, "{template}"); assert!( body.contains("class=\"wu-container\""), "wrapup template not rendered for {template}" ); } // The owner embeds this widget in an iframe on their personal website. let (status, _, body) = send( &seed.router, get(&expand(&seed, "/users/{id}?embed=true"), None), ) .await; assert_eq!(status, 200); assert!( body.contains("class=\"embed-profile\""), "embed template not rendered: {body}" ); } #[tokio::test] async fn every_classic_page_renders_for_the_signed_in_owner() { let seed = seed().await; let cookie = seed.owner_cookie.clone(); check_table(&seed, CLASSIC_OWNER_PAGES, Some(&cookie)).await; #[cfg(feature = "federation")] check_table(&seed, FEDERATION_OWNER_PAGES, Some(&cookie)).await; } #[tokio::test] async fn feeds_and_openapi_are_served() { let seed = seed().await; // `/docs` (what users type) redirects to the Swagger UI at `/docs/`. for (template, expected) in [ ("/feed.rss", 200), ("/users/{id}/feed.rss", 200), ("/openapi.json", 200), ("/docs", 303), ("/docs/", 200), ("/scalar", 200), ] { let uri = expand(&seed, template); let (status, _, _) = send(&seed.router, get(&uri, None)).await; assert_eq!(status, expected, "{uri}"); } } #[tokio::test] async fn openapi_paths_match_the_recorded_list() { let seed = seed().await; let (status, _, body) = send(&seed.router, get("/openapi.json", None)).await; assert_eq!(status, 200); let spec: serde_json::Value = serde_json::from_str(&body).unwrap(); let mut lines = Vec::new(); for (path, item) in spec["paths"].as_object().unwrap() { for method in item.as_object().unwrap().keys() { if ["get", "put", "post", "delete", "patch", "head", "options"] .contains(&method.as_str()) { lines.push(format!("{} {path}", method.to_uppercase())); } } } lines.sort(); let actual = lines.join("\n") + "\n"; let fixture = if cfg!(feature = "federation") { "tests/fixtures/openapi_paths.txt" } else { "tests/fixtures/openapi_paths_federation_off.txt" }; let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join(fixture); if std::env::var("UPDATE_OPENAPI_FIXTURE").is_ok() { std::fs::write(&path, &actual).unwrap(); } let expected = std::fs::read_to_string(&path).unwrap(); assert_eq!(actual, expected, "OpenAPI paths drifted from {fixture}"); } #[cfg(feature = "federation")] #[tokio::test] async fn profile_page_serves_the_actor_document_to_activitypub_clients() { let seed = seed().await; let req = Request::builder() .method(Method::GET) .uri(format!("/users/{}", seed.user_id)) .header(header::ACCEPT, "application/activity+json") .body(Body::empty()) .unwrap(); let (status, headers, body) = send(&seed.router, req).await; assert_eq!(status, 200, "{body}"); assert_eq!( headers.get(header::CONTENT_TYPE).unwrap(), "application/activity+json" ); let actor: serde_json::Value = serde_json::from_str(&body).expect("actor JSON"); assert_eq!(actor["type"], "Person"); }