use std::sync::Arc; use std::time::Duration; use ab_glyph::FontArc; use dashmap::DashMap; use sea_orm::{ConnectionTrait, DatabaseConnection, SqlxSqliteConnector}; use zeroize::Zeroizing; use openposterdb_api::config::Config; use openposterdb_api::services::fanart::FanartClient; use openposterdb_api::services::tmdb::TmdbClient; use openposterdb_api::{build_app, AppState, FONT_BYTES, MIGRATIONS, SCHEMA_SQL}; pub struct TestAppOptions { pub cors_origin: Option, pub secure_cookies: bool, pub enable_cdn_redirects: bool, pub external_cache_only: bool, pub cache_dir_override: Option, pub free_key_enabled: Option, pub disable_fanart: bool, } impl Default for TestAppOptions { fn default() -> Self { Self { cors_origin: None, secure_cookies: false, enable_cdn_redirects: false, external_cache_only: false, cache_dir_override: None, free_key_enabled: None, disable_fanart: false, } } } pub async fn setup_test_app_with_options(opts: TestAppOptions) -> (axum::Router, Arc) { let cors_origin = opts.cors_origin; let secure_cookies = opts.secure_cookies; let enable_cdn_redirects = opts.enable_cdn_redirects; let external_cache_only = opts.external_cache_only; let cache_dir_override = opts.cache_dir_override; let free_key_enabled = opts.free_key_enabled; let disable_fanart = opts.disable_fanart; _setup_test_app(cors_origin, secure_cookies, enable_cdn_redirects, external_cache_only, cache_dir_override, free_key_enabled, disable_fanart).await } pub async fn setup_test_app_with_cors(cors_origin: Option) -> (axum::Router, Arc) { _setup_test_app(cors_origin, false, false, false, None, None, false).await } async fn _setup_test_app(cors_origin: Option, secure_cookies: bool, enable_cdn_redirects: bool, external_cache_only: bool, cache_dir_override: Option, free_key_enabled: Option, disable_fanart: bool) -> (axum::Router, Arc) { let sqlite_opts = sqlx::sqlite::SqliteConnectOptions::new() .filename(":memory:") .create_if_missing(true) .pragma("foreign_keys", "ON"); let pool = sqlx::sqlite::SqlitePoolOptions::new() .max_connections(1) .connect_with(sqlite_opts) .await .expect("failed to connect to test database"); let db: DatabaseConnection = SqlxSqliteConnector::from_sqlx_sqlite_pool(pool); for sql in SCHEMA_SQL { db.execute_unprepared(sql) .await .expect("failed to create table"); } for (sql, expected_err) in MIGRATIONS { match db.execute_unprepared(sql).await { Ok(_) => {} Err(e) if e.to_string().to_lowercase().contains(expected_err) => {} Err(e) => panic!("migration failed: {e}\n SQL: {sql}"), } } let jwt_secret = Zeroizing::new(vec![0xAB; 32]); let http = reqwest::Client::new(); let font = FontArc::try_from_slice(FONT_BYTES).expect("failed to load font"); let api_key_cache = moka::future::Cache::builder() .max_capacity(100) .time_to_live(Duration::from_secs(300)) .build(); let image_inflight = moka::future::Cache::builder() .max_capacity(100) .time_to_live(Duration::from_secs(30)) .build(); let id_cache = moka::future::Cache::builder() .max_capacity(100) .time_to_live(Duration::from_secs(3600)) .build(); let ratings_cache = moka::future::Cache::builder() .max_capacity(100) .time_to_live(Duration::from_secs(1800)) .build(); let image_mem_cache = moka::future::Cache::builder() .max_capacity(1024 * 1024) .time_to_live(Duration::from_secs(3600)) .build(); let refresh_locks = moka::sync::Cache::builder() .max_capacity(100) .time_to_live(Duration::from_secs(300)) .build(); let state = Arc::new(AppState { config: Config { tmdb_api_key: "test".into(), omdb_api_key: None, cache_dir: cache_dir_override.unwrap_or_else(|| "/tmp/openposterdb-test".into()), db_dir: "/tmp/openposterdb-test".into(), listen_addr: "127.0.0.1:0".into(), ratings_min_stale_secs: 86400, ratings_max_age_secs: 31_536_000, image_stale_secs: 0, image_quality: 85, mdblist_api_key: None, image_mem_cache_mb: 1, static_dir: None, cors_origin, fanart_api_key: if disable_fanart { None } else { Some("test".into()) }, trakt_client_id: None, enable_cdn_redirects, external_cache_only, free_key_enabled, disable_public_pages: false, }, tmdb: TmdbClient::new("test".into(), http.clone()), omdb: None, mdblist: None, trakt: None, font, refresh_locks, db, jwt_secret, secure_cookies, api_key_cache, image_inflight, id_cache, ratings_cache, image_mem_cache, pending_last_used: Arc::new(DashMap::new()), fanart: if disable_fanart { None } else { Some(FanartClient::new("test".into(), http)) }, fanart_cache: moka::future::Cache::builder() .max_capacity(100) .time_to_live(Duration::from_secs(3600)) .build(), fanart_negative: moka::future::Cache::builder() .max_capacity(100) .time_to_live(Duration::from_secs(3600)) .build(), tmdb_images_cache: moka::future::Cache::builder() .max_capacity(100) .time_to_live(Duration::from_secs(1800)) .build(), settings_cache: moka::future::Cache::builder() .max_capacity(100) .time_to_live(Duration::from_secs(300)) .build(), global_settings_cache: moka::future::Cache::builder() .max_capacity(1) .time_to_live(Duration::from_secs(300)) .build(), preview_cache: moka::future::Cache::builder() .max_capacity(500) .time_to_live(Duration::from_secs(300)) .build(), free_api_key_cache: moka::future::Cache::builder() .max_capacity(1) .time_to_live(Duration::from_secs(60)) .build(), render_semaphore: Arc::new(tokio::sync::Semaphore::new(4)), cross_id_semaphore: Arc::new(tokio::sync::Semaphore::new(8)), settings_hash_registry: moka::future::Cache::builder() .max_capacity(100) .time_to_live(Duration::from_secs(300)) .build(), available_ratings_cache: moka::future::Cache::builder() .max_capacity(1000) .time_to_live(Duration::from_secs(300)) .build(), }); let app = build_app(state.clone()); (app, state) } pub async fn setup_test_app() -> (axum::Router, Arc) { setup_test_app_with_cors(None).await } /// Helper to perform setup and get back an access token. pub async fn setup_admin(app: &axum::Router) -> String { use axum::body::Body; use axum::http::{Request, StatusCode}; use http_body_util::BodyExt; use tower::ServiceExt; let req = Request::builder() .method("POST") .uri("/api/auth/setup") .header("content-type", "application/json") .body(Body::from( serde_json::json!({"username": "admin", "password": "testpassword123"}).to_string(), )) .unwrap(); let res = app.clone().oneshot(req).await.unwrap(); assert_eq!(res.status(), StatusCode::OK); let body = res.into_body().collect().await.unwrap().to_bytes(); let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); json["token"].as_str().unwrap().to_string() } /// Helper: set up admin, create an API key, log in with it, and return the session JWT. pub async fn setup_api_key_session(app: &axum::Router) -> String { use axum::body::Body; use axum::http::{Request, StatusCode}; use http_body_util::BodyExt; use tower::ServiceExt; let admin_token = setup_admin(app).await; // Create an API key let req = Request::builder() .method("POST") .uri("/api/keys") .header("content-type", "application/json") .header("authorization", format!("Bearer {admin_token}")) .body(Body::from( serde_json::json!({"name": "preview-test-key"}).to_string(), )) .unwrap(); let res = app.clone().oneshot(req).await.unwrap(); assert_eq!(res.status(), StatusCode::OK); let body = res.into_body().collect().await.unwrap().to_bytes(); let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); let raw_key = json["key"].as_str().unwrap().to_string(); // Log in with the API key let req = Request::builder() .method("POST") .uri("/api/auth/key-login") .header("content-type", "application/json") .body(Body::from( serde_json::json!({"api_key": raw_key}).to_string(), )) .unwrap(); let res = app.clone().oneshot(req).await.unwrap(); assert_eq!(res.status(), StatusCode::OK); let body = res.into_body().collect().await.unwrap().to_bytes(); let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); json["token"].as_str().unwrap().to_string() } /// Extract Set-Cookie header value from a response. pub fn extract_set_cookie(headers: &axum::http::HeaderMap) -> Option { headers .get(axum::http::header::SET_COOKIE) .and_then(|v| v.to_str().ok()) .map(|s| s.to_string()) } /// Extract the refresh_token value from a Set-Cookie header. pub fn extract_refresh_token(set_cookie: &str) -> Option { set_cookie .split(';') .next() .and_then(|s| s.strip_prefix("refresh_token=")) .map(|s| s.to_string()) }