//! Shared harness for the HTTP integration tests. //! //! Boots the real Axum app (via [`shoplist_backend::build_router`]) against a //! Postgres database named by the `TEST_DATABASE_URL` env var, on an ephemeral //! port, with a cookie-aware client so session auth works end to end. //! //! When `TEST_DATABASE_URL` is unset, [`spawn`] returns `None` and each test //! skips — so `cargo test` stays green without a database, and the pre-push //! hook runs the full suite only when one is available. use std::sync::Arc; use shoplist_backend::config::{Config, SmtpConfig, SmtpSecurity}; use shoplist_backend::mail::Mailer; use shoplist_backend::state::AppState; use shoplist_backend::{build_router, db, fetch}; pub struct TestApp { pub base: String, pub client: reqwest::Client, } impl TestApp { /// Absolute URL for an `/api`-relative path. pub fn url(&self, path: &str) -> String { format!("{}/api{}", self.base, path) } /// A fresh, never-before-used email (tests share one DB; avoid collisions). pub fn unique_email(&self) -> String { format!("u{}@example.test", uuid::Uuid::new_v4().simple()) } } /// Boot the app, or `None` if no test database is configured. pub async fn spawn() -> Option { let database_url = std::env::var("TEST_DATABASE_URL").ok()?; let pool = db::connect(&database_url) .await .expect("connect + migrate test database"); let config = Config { database_url, host: "127.0.0.1".into(), port: 0, session_secret: "test-session-secret-at-least-32-chars!!".into(), public_app_url: "http://localhost:5173".into(), cors_origins: vec!["http://localhost:5173".into()], smtp: SmtpConfig { // Points nowhere; register() swallows send failures, other tests // never trigger mail. host: "localhost".into(), port: 1, username: String::new(), password: String::new(), from: "Test ".into(), security: SmtpSecurity::None, }, refetch_interval_secs: 0, // no background worker in tests refetch_min_age_secs: 21_600, default_currency: "EUR".into(), cookie_secure: false, // plain HTTP in tests }; let mailer = Mailer::from_config(&config.smtp).expect("build test mailer"); let state = AppState { pool, config: Arc::new(config), mailer, http: fetch::http_client(), }; let app = build_router(state).await.expect("build router"); let listener = tokio::net::TcpListener::bind("127.0.0.1:0") .await .expect("bind ephemeral port"); let addr = listener.local_addr().unwrap(); tokio::spawn(async move { axum::serve(listener, app).await.unwrap(); }); let client = reqwest::Client::builder() .cookie_store(true) .build() .expect("build cookie client"); Some(TestApp { base: format!("http://{addr}"), client, }) } /// Skip-or-run boilerplate: `let app = test_app!();`. #[macro_export] macro_rules! test_app { () => { match common::spawn().await { Some(app) => app, None => { eprintln!("skipping: TEST_DATABASE_URL not set"); return; } } }; }