From d7bac4aec347f9fe3b24bf47662d78db4e43505c Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Sun, 25 Oct 2026 21:27:13 +0100 Subject: [PATCH] test: classic route and openapi characterization --- Cargo.lock | 2 + crates/bootstrap/Cargo.toml | 2 + .../tests/fixtures/openapi_paths.txt | 69 ++++ .../fixtures/openapi_paths_federation_off.txt | 52 +++ crates/bootstrap/tests/route_smoke_test.rs | 385 ++++++++++++++++++ 5 files changed, 510 insertions(+) create mode 100644 crates/bootstrap/tests/fixtures/openapi_paths.txt create mode 100644 crates/bootstrap/tests/fixtures/openapi_paths_federation_off.txt create mode 100644 crates/bootstrap/tests/route_smoke_test.rs diff --git a/Cargo.lock b/Cargo.lock index 8010cb58..d4d6721a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -928,11 +928,13 @@ dependencies = [ "poster-fetcher", "presentation", "rss 0.1.0", + "serde_json", "sqlite", "sqlite-federation", "sqlx", "tmdb-enrichment", "tokio", + "tower", "tracing", "tracing-subscriber", "uuid", diff --git a/crates/bootstrap/Cargo.toml b/crates/bootstrap/Cargo.toml index 4690586d..95458630 100644 --- a/crates/bootstrap/Cargo.toml +++ b/crates/bootstrap/Cargo.toml @@ -45,3 +45,5 @@ sqlite-federation = { workspace = true, optional = true } chrono = { workspace = true } domain = { workspace = true, features = ["test-helpers"] } uuid = { workspace = true } +serde_json = { workspace = true } +tower = { version = "0.5", features = ["util"] } diff --git a/crates/bootstrap/tests/fixtures/openapi_paths.txt b/crates/bootstrap/tests/fixtures/openapi_paths.txt new file mode 100644 index 00000000..851c39a0 --- /dev/null +++ b/crates/bootstrap/tests/fixtures/openapi_paths.txt @@ -0,0 +1,69 @@ +DELETE /api/v1/admin/blocked-domains/{domain} +DELETE /api/v1/goals/{year} +DELETE /api/v1/import/profiles/{id} +DELETE /api/v1/reviews/{id} +DELETE /api/v1/settings/webhook-tokens/{id} +DELETE /api/v1/watchlist/{movie_id} +DELETE /api/v1/wrapups/{id} +GET /api/v1/activity-feed +GET /api/v1/admin/blocked-domains +GET /api/v1/diary +GET /api/v1/diary/export +GET /api/v1/goals +GET /api/v1/import/profiles +GET /api/v1/import/sessions/{id} +GET /api/v1/insights +GET /api/v1/movies +GET /api/v1/movies/{id}/history +GET /api/v1/movies/{id}/profile +GET /api/v1/movies/{movie_id} +GET /api/v1/people/{id} +GET /api/v1/people/{id}/credits +GET /api/v1/profile +GET /api/v1/search +GET /api/v1/settings +GET /api/v1/settings/webhook-tokens +GET /api/v1/social/blocked +GET /api/v1/social/followers +GET /api/v1/social/followers/pending +GET /api/v1/social/followers/pending/count +GET /api/v1/social/following +GET /api/v1/social/following/pending +GET /api/v1/social/relationship +GET /api/v1/users +GET /api/v1/users/{id} +GET /api/v1/users/{id}/goals +GET /api/v1/watch-queue +GET /api/v1/watchlist +GET /api/v1/watchlist/{movie_id} +GET /api/v1/wrapups +GET /api/v1/wrapups/{id} +GET /api/v1/wrapups/{id}/report +POST /api/v1/admin/blocked-domains +POST /api/v1/auth/login +POST /api/v1/auth/register +POST /api/v1/goals +POST /api/v1/import/profiles +POST /api/v1/import/sessions +POST /api/v1/import/sessions/{id}/confirm +POST /api/v1/movies/{id}/sync-poster +POST /api/v1/reviews +POST /api/v1/settings/webhook-tokens +POST /api/v1/social/block +POST /api/v1/social/follow +POST /api/v1/social/followers/accept +POST /api/v1/social/followers/reject +POST /api/v1/social/followers/remove +POST /api/v1/social/unblock +POST /api/v1/social/unfollow +POST /api/v1/watch-queue/confirm +POST /api/v1/watch-queue/dismiss +POST /api/v1/watchlist +POST /api/v1/webhooks/jellyfin +POST /api/v1/webhooks/plex +POST /api/v1/wrapups/generate +PUT /api/v1/goals/{year} +PUT /api/v1/import/sessions/{id}/mapping +PUT /api/v1/profile +PUT /api/v1/profile/fields +PUT /api/v1/settings diff --git a/crates/bootstrap/tests/fixtures/openapi_paths_federation_off.txt b/crates/bootstrap/tests/fixtures/openapi_paths_federation_off.txt new file mode 100644 index 00000000..ea1b1b54 --- /dev/null +++ b/crates/bootstrap/tests/fixtures/openapi_paths_federation_off.txt @@ -0,0 +1,52 @@ +DELETE /api/v1/goals/{year} +DELETE /api/v1/import/profiles/{id} +DELETE /api/v1/reviews/{id} +DELETE /api/v1/settings/webhook-tokens/{id} +DELETE /api/v1/watchlist/{movie_id} +DELETE /api/v1/wrapups/{id} +GET /api/v1/activity-feed +GET /api/v1/diary +GET /api/v1/diary/export +GET /api/v1/goals +GET /api/v1/import/profiles +GET /api/v1/import/sessions/{id} +GET /api/v1/insights +GET /api/v1/movies +GET /api/v1/movies/{id}/history +GET /api/v1/movies/{id}/profile +GET /api/v1/movies/{movie_id} +GET /api/v1/people/{id} +GET /api/v1/people/{id}/credits +GET /api/v1/profile +GET /api/v1/search +GET /api/v1/settings +GET /api/v1/settings/webhook-tokens +GET /api/v1/users +GET /api/v1/users/{id} +GET /api/v1/users/{id}/goals +GET /api/v1/watch-queue +GET /api/v1/watchlist +GET /api/v1/watchlist/{movie_id} +GET /api/v1/wrapups +GET /api/v1/wrapups/{id} +GET /api/v1/wrapups/{id}/report +POST /api/v1/auth/login +POST /api/v1/auth/register +POST /api/v1/goals +POST /api/v1/import/profiles +POST /api/v1/import/sessions +POST /api/v1/import/sessions/{id}/confirm +POST /api/v1/movies/{id}/sync-poster +POST /api/v1/reviews +POST /api/v1/settings/webhook-tokens +POST /api/v1/watch-queue/confirm +POST /api/v1/watch-queue/dismiss +POST /api/v1/watchlist +POST /api/v1/webhooks/jellyfin +POST /api/v1/webhooks/plex +POST /api/v1/wrapups/generate +PUT /api/v1/goals/{year} +PUT /api/v1/import/sessions/{id}/mapping +PUT /api/v1/profile +PUT /api/v1/profile/fields +PUT /api/v1/settings diff --git a/crates/bootstrap/tests/route_smoke_test.rs b/crates/bootstrap/tests/route_smoke_test.rs new file mode 100644 index 00000000..44d49609 --- /dev/null +++ b/crates/bootstrap/tests/route_smoke_test.rs @@ -0,0 +1,385 @@ +//! 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", 404), + ("/wrapups/global/2025", 404), + ("/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) + .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!( + status == 200 || status == 201, + "log review {status}: {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!( + status == 200 || status == 201, + "create goal {status}: {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 first = movies + .get("movies") + .and_then(|m| m.get(0)) + .or_else(|| movies.get(0)) + .or_else(|| movies.get("items").and_then(|m| m.get(0))) + .unwrap_or_else(|| panic!("no movie in {body}")); + let movie_id = first["id"] + .as_str() + .or_else(|| first["movie"]["id"].as_str()) + .unwrap_or_else(|| panic!("no movie id in {body}")) + .to_string(); + + Seed { + router, + user_id, + movie_id, + owner_cookie, + } +} + +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; + + // 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; + for template in [ + "/feed.rss", + "/users/{id}/feed.rss", + "/openapi.json", + "/docs/", + "/scalar", + ] { + let uri = expand(&seed, template); + let (status, _, _) = send(&seed.router, get(&uri, None)).await; + assert_eq!(status, 200, "{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}"); +}