//! Test harness for in-process integration tests. pub mod client; pub mod db; use multithreaded::{csrf, routes, AppState, config::Config}; use sqlx::PgPool; use tower_sessions::cookie::SameSite; use tower_sessions::{Expiry, SessionManagerLayer}; use tower_sessions_sqlx_store::PostgresStore; use uuid::Uuid; use self::client::TestClient; use self::db::TestDb; pub struct TestHarness { pub client: TestClient, pub db: PgPool, _test_db: TestDb, } impl TestHarness { pub async fn new() -> Self { let test_db = TestDb::new().await; let pool = test_db.pool.clone(); let session_store = PostgresStore::new(pool.clone()); session_store .migrate() .await .expect("Failed to migrate session store"); let session_layer = SessionManagerLayer::new(session_store) .with_secure(false) .with_same_site(SameSite::Lax) .with_expiry(Expiry::OnInactivity( tower_sessions::cookie::time::Duration::days(1), )); let config = Config { mnw_base_url: "http://127.0.0.1:9999".into(), oauth_client_id: "test-client-id".to_string(), oauth_redirect_uri: "http://127.0.0.1:3400/auth/callback".to_string(), platform_admin_id: None, cookie_secure: false, s3: None, internal_shared_secret: None, }; let state = AppState { db: pool.clone(), config, http: reqwest::Client::new(), preview_http: multithreaded::link_preview::build_preview_client(), s3: None, }; // Build the app with a /_test/login route for setting sessions without OAuth let test_login = axum::Router::new() .route("/_test/login", axum::routing::post(test_login_handler)) .with_state(state.clone()); let app = routes::forum_routes(state) .merge(test_login) .layer(axum::middleware::from_fn(csrf::csrf_middleware)) .layer(session_layer); let client = TestClient::new(app); TestHarness { client, db: pool, _test_db: test_db, } } /// Log in as a user by username. Creates the user if needed. Returns the user's UUID. pub async fn login_as(&mut self, username: &str) -> Uuid { let user_id = Uuid::new_v4(); // Insert user into the database sqlx::query( "INSERT INTO users (mnw_account_id, username, display_name) VALUES ($1, $2, $3) ON CONFLICT (mnw_account_id) DO NOTHING", ) .bind(user_id) .bind(username) .bind(username) .execute(&self.db) .await .expect("Failed to insert test user"); // GET a page to establish session + CSRF token self.client.get("/").await; // POST to /_test/login to set session (exempt from CSRF) let body = serde_json::json!({ "user_id": user_id.to_string(), "username": username, }); self.client .post_json("/_test/login", &body.to_string()) .await; user_id } /// Create a community via direct SQL. Returns the community ID. pub async fn create_community(&self, name: &str, slug: &str) -> Uuid { sqlx::query_scalar( "INSERT INTO communities (name, slug) VALUES ($1, $2) ON CONFLICT (slug) DO UPDATE SET name = EXCLUDED.name RETURNING id", ) .bind(name) .bind(slug) .fetch_one(&self.db) .await .expect("Failed to create community") } /// Create a category via direct SQL. Returns the category ID. pub async fn create_category( &self, community_id: Uuid, name: &str, slug: &str, ) -> Uuid { sqlx::query_scalar( "INSERT INTO categories (community_id, name, slug, sort_order) VALUES ($1, $2, $3, 0) ON CONFLICT (community_id, slug) DO UPDATE SET name = EXCLUDED.name RETURNING id", ) .bind(community_id) .bind(name) .bind(slug) .fetch_one(&self.db) .await .expect("Failed to create category") } /// Add a membership via direct SQL. pub async fn add_membership(&self, user_id: Uuid, community_id: Uuid, role: &str) { sqlx::query( "INSERT INTO memberships (user_id, community_id, role) VALUES ($1, $2, $3) ON CONFLICT (user_id, community_id) DO UPDATE SET role = $3", ) .bind(user_id) .bind(community_id) .bind(role) .execute(&self.db) .await .expect("Failed to add membership"); } /// Create a harness with a specific platform admin user ID. pub async fn new_with_admin(admin_id: Uuid) -> Self { let harness = Self::new().await; // Rebuild with admin config — we need to reconstruct the app // because Config is cloned into AppState. drop(harness); let test_db = TestDb::new().await; let pool = test_db.pool.clone(); let session_store = PostgresStore::new(pool.clone()); session_store .migrate() .await .expect("Failed to migrate session store"); let session_layer = SessionManagerLayer::new(session_store) .with_secure(false) .with_same_site(SameSite::Lax) .with_expiry(Expiry::OnInactivity( tower_sessions::cookie::time::Duration::days(1), )); let config = Config { mnw_base_url: "http://127.0.0.1:9999".into(), oauth_client_id: "test-client-id".to_string(), oauth_redirect_uri: "http://127.0.0.1:3400/auth/callback".to_string(), platform_admin_id: Some(admin_id), cookie_secure: false, s3: None, internal_shared_secret: None, }; let state = AppState { db: pool.clone(), config, http: reqwest::Client::new(), preview_http: multithreaded::link_preview::build_preview_client(), s3: None, }; let test_login = axum::Router::new() .route("/_test/login", axum::routing::post(test_login_handler)) .with_state(state.clone()); let app = routes::forum_routes(state) .merge(test_login) .layer(axum::middleware::from_fn(csrf::csrf_middleware)) .layer(session_layer); let client = TestClient::new(app); TestHarness { client, db: pool, _test_db: test_db, } } /// Ban a user in a community via direct SQL. pub async fn ban_user(&self, community_id: Uuid, user_id: Uuid, banned_by: Uuid, ban_type: &str) { sqlx::query( "INSERT INTO community_bans (community_id, user_id, banned_by, ban_type) VALUES ($1, $2, $3, $4) ON CONFLICT (community_id, user_id, ban_type) DO NOTHING", ) .bind(community_id) .bind(user_id) .bind(banned_by) .bind(ban_type) .execute(&self.db) .await .expect("Failed to ban user"); } /// Create a thread with an initial post via direct SQL. Returns thread ID. pub async fn create_thread_with_post( &self, category_id: Uuid, author_id: Uuid, title: &str, body: &str, ) -> Uuid { let thread_id = mt_db::mutations::create_thread(&self.db, category_id, author_id, title) .await .expect("Failed to create thread"); mt_db::mutations::create_post( &self.db, thread_id, author_id, body, &format!("
{}
", body), ) .await .expect("Failed to create post"); thread_id } } /// Handler for `POST /_test/login` — sets session keys without OAuth. async fn test_login_handler( session: tower_sessions::Session, axum::Json(payload): axum::Json