// Shared test helpers. Each integration test binary picks the subset it needs, // so dead-code lints on the unused helpers fire per-binary; suppress at the // module level. #![allow(dead_code)] use std::sync::Arc; use axum::body::Body; use axum::http::{header, Request}; use axum::Router; use http_body_util::BodyExt; use serde_json::json; use sqlx::PgPool; use tempfile::TempDir; use tower::ServiceExt; use mangalord::app::{router, AppState}; use mangalord::config::{AuthConfig, UploadConfig}; use mangalord::storage::LocalStorage; pub struct Harness { pub app: Router, // Kept alive for the lifetime of the test so the temp dir is not dropped. pub _storage_dir: TempDir, } pub fn harness(pool: PgPool) -> Harness { let storage_dir = tempfile::tempdir().expect("tempdir"); let state = AppState { db: pool, storage: Arc::new(LocalStorage::new(storage_dir.path())), auth: AuthConfig { cookie_secure: false, ..AuthConfig::default() }, upload: UploadConfig { // Keep file caps small in tests so the size-cap path is cheap to // exercise without producing tens of MBs of bytes. max_request_bytes: 4 * 1024 * 1024, max_file_bytes: 256 * 1024, }, }; Harness { app: router(state), _storage_dir: storage_dir } } pub async fn body_json(response: axum::response::Response) -> serde_json::Value { let bytes = response.into_body().collect().await.unwrap().to_bytes(); serde_json::from_slice(&bytes).expect("body is JSON") } pub fn get(uri: &str) -> Request { Request::builder().uri(uri).body(Body::empty()).unwrap() } pub fn get_with_cookie(uri: &str, cookie: &str) -> Request { Request::builder() .uri(uri) .header(header::COOKIE, cookie) .body(Body::empty()) .unwrap() } pub fn get_with_bearer(uri: &str, token: &str) -> Request { Request::builder() .uri(uri) .header(header::AUTHORIZATION, format!("Bearer {token}")) .body(Body::empty()) .unwrap() } pub fn post_json(uri: &str, body: serde_json::Value) -> Request { Request::builder() .method("POST") .uri(uri) .header(header::CONTENT_TYPE, "application/json") .body(Body::from(body.to_string())) .unwrap() } pub fn post_json_with_cookie( uri: &str, body: serde_json::Value, cookie: &str, ) -> Request { Request::builder() .method("POST") .uri(uri) .header(header::CONTENT_TYPE, "application/json") .header(header::COOKIE, cookie) .body(Body::from(body.to_string())) .unwrap() } pub fn post_json_with_bearer( uri: &str, body: serde_json::Value, token: &str, ) -> Request { Request::builder() .method("POST") .uri(uri) .header(header::CONTENT_TYPE, "application/json") .header(header::AUTHORIZATION, format!("Bearer {token}")) .body(Body::from(body.to_string())) .unwrap() } pub fn delete_with_cookie(uri: &str, cookie: &str) -> Request { Request::builder() .method("DELETE") .uri(uri) .header(header::COOKIE, cookie) .body(Body::empty()) .unwrap() } /// Extracts the `mangalord_session` cookie from a response's Set-Cookie /// headers as a `name=value` pair suitable for use in a follow-up `Cookie` /// request header. Returns `None` if no such cookie was set. pub fn extract_session_cookie(response: &axum::response::Response) -> Option { response .headers() .get_all(header::SET_COOKIE) .iter() .find_map(|v| { let s = v.to_str().ok()?; if s.starts_with("mangalord_session=") { let end = s.find(';').unwrap_or(s.len()); Some(s[..end].to_string()) } else { None } }) } /// Minimal multipart builder for tests. Real clients would use a real /// library; we hand-roll a small one so the test crate stays free of /// http-client dependencies. pub struct MultipartBuilder { boundary: String, body: Vec, } impl Default for MultipartBuilder { fn default() -> Self { Self::new() } } impl MultipartBuilder { pub fn new() -> Self { Self { boundary: format!("----mangalord-test-{}", uuid::Uuid::new_v4().simple()), body: Vec::new(), } } pub fn add_json(mut self, name: &str, value: serde_json::Value) -> Self { self.write_part_header(name, None, Some("application/json")); self.body.extend(value.to_string().as_bytes()); self.body.extend(b"\r\n"); self } pub fn add_file( mut self, name: &str, filename: &str, content_type: &str, bytes: &[u8], ) -> Self { self.write_part_header(name, Some(filename), Some(content_type)); self.body.extend(bytes); self.body.extend(b"\r\n"); self } fn write_part_header( &mut self, name: &str, filename: Option<&str>, ct: Option<&str>, ) { self.body .extend(format!("--{}\r\n", self.boundary).as_bytes()); let disposition = if let Some(fname) = filename { format!( "Content-Disposition: form-data; name=\"{name}\"; filename=\"{fname}\"\r\n" ) } else { format!("Content-Disposition: form-data; name=\"{name}\"\r\n") }; self.body.extend(disposition.as_bytes()); if let Some(ct) = ct { self.body.extend(format!("Content-Type: {ct}\r\n").as_bytes()); } self.body.extend(b"\r\n"); } fn finalize(self) -> (String, Vec) { let mut body = self.body; body.extend(format!("--{}--\r\n", self.boundary).as_bytes()); (self.boundary, body) } } pub fn post_multipart(uri: &str, builder: MultipartBuilder) -> Request { let (boundary, body) = builder.finalize(); Request::builder() .method("POST") .uri(uri) .header( header::CONTENT_TYPE, format!("multipart/form-data; boundary={boundary}"), ) .body(Body::from(body)) .unwrap() } pub fn post_multipart_with_cookie( uri: &str, builder: MultipartBuilder, cookie: &str, ) -> Request { let (boundary, body) = builder.finalize(); Request::builder() .method("POST") .uri(uri) .header( header::CONTENT_TYPE, format!("multipart/form-data; boundary={boundary}"), ) .header(header::COOKIE, cookie) .body(Body::from(body)) .unwrap() } /// Realistic PNG file header bytes — enough for `infer` to identify. pub fn fake_png_bytes() -> Vec { vec![0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 0] } /// Realistic JPEG file header bytes — enough for `infer` to identify. pub fn fake_jpeg_bytes() -> Vec { vec![ 0xff, 0xd8, 0xff, 0xe0, 0, 0x10, b'J', b'F', b'I', b'F', 0, 0, ] } /// Create a manga via the upload API and return its id. Used by tests /// that need a manga to exist before they exercise chapters / etc. pub async fn seed_manga_via_api(app: &Router, cookie: &str, title: &str) -> uuid::Uuid { let resp = app .clone() .oneshot(post_multipart_with_cookie( "/api/v1/mangas", MultipartBuilder::new().add_json("metadata", serde_json::json!({ "title": title })), cookie, )) .await .unwrap(); assert_eq!( resp.status(), axum::http::StatusCode::CREATED, "seed_manga_via_api failed" ); let body = body_json(resp).await; uuid::Uuid::parse_str(body["id"].as_str().unwrap()).unwrap() } /// Register a brand-new user and return (username, session cookie value). /// The username is unique per call so tests can run in parallel against a /// single DB without colliding. pub async fn register_user(app: &Router) -> (String, String) { // 12-hex-digit suffix keeps the username under the 32-char cap. let suffix: String = uuid::Uuid::new_v4().simple().to_string().chars().take(12).collect(); let username = format!("u-{suffix}"); let resp = app .clone() .oneshot(post_json( "/api/v1/auth/register", json!({ "username": username, "password": "hunter2hunter2" }), )) .await .unwrap(); assert_eq!( resp.status(), axum::http::StatusCode::CREATED, "register failed in test harness" ); let cookie = extract_session_cookie(&resp).expect("session cookie on register"); (username, cookie) }