//! Load test orchestrator: sets up the shared app, seeds data, spawns virtual //! users, and prints the final report. use axum::Router; use sqlx::postgres::PgPoolOptions; use sqlx::PgPool; use std::sync::Arc; use std::time::{Duration, Instant}; use tower_sessions::cookie::time::Duration as CookieDuration; use tower_sessions::cookie::SameSite; use tower_sessions::{Expiry, SessionManagerLayer}; use tower_sessions_sqlx_store::PostgresStore; use makenotwork::config::Config; use docengine::DocLoader; use makenotwork::email::{EmailClient, EmailConfig}; use makenotwork::{build_app, AppState}; use crate::harness::client::TestClient; use crate::harness::db::TestDb; use super::config::{LoadConfig, ScenarioType}; use super::metrics::MetricsCollector; use super::scenarios::{self, SeedData}; /// Run the full load test. pub async fn run(config: LoadConfig) { // 1. Database setup: TestDb for creation/migration/cleanup let test_db = TestDb::new().await; // Production-sized pool against the same test database let pool = PgPoolOptions::new() .max_connections(config.db_max_connections) .acquire_timeout(config.db_acquire_timeout) .connect(test_db.url()) .await .expect("Failed to create load test pool"); // 2. Session store 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(CookieDuration::days(1))); // 3. App let app_config = Config { host: "127.0.0.1".parse().unwrap(), port: 0, database_url: String::new(), host_url: std::sync::Arc::from("http://localhost:3000"), signing_secret: "load-test-signing-secret".to_string(), storage: None, synckit_storage: None, stripe: None, admin_user_id: None, synckit_jwt_secret: None, scan: None, git_repos_path: None, postmark_webhook_token: None, postmark_broadcast_webhook_token: None, git_ssh_host: None, mt_base_url: None, fan_plus_price_id: None, creator_tier_prices: std::collections::HashMap::new(), creator_tier_annual_prices: std::collections::HashMap::new(), creator_tier_founder_prices: std::collections::HashMap::new(), creator_tier_founder_annual_prices: std::collections::HashMap::new(), creator_founder_window_open: false, build_trigger_token: None, build_host_linux: None, build_host_darwin: None, cdn_base_url: None, postmark_inbound_webhook_token: None, internal_shared_secret: None, cli_service_token: None, wam_url: None, access_gate: makenotwork::config::AccessGate::Open, sso: None, }; let email = EmailClient::new(EmailConfig { postmark_token: None, from_address: "loadtest@makenot.work".to_string(), from_name: "LoadTest".to_string(), }, Some(pool.clone())); let rp_origin = url::Url::parse(&app_config.host_url).expect("test HOST_URL"); let rp_id = rp_origin.host_str().expect("test HOST_URL host").to_string(); let webauthn = Arc::new( webauthn_rs::WebauthnBuilder::new(&rp_id, &rp_origin) .expect("WebauthnBuilder") .rp_name("LoadTest") .build() .expect("Webauthn"), ); let state = AppState { db: pool.clone(), config: app_config, tier_prices: makenotwork::tier_prices::TierPrices::default(), cost_allocation: makenotwork::tier_prices::CostAllocation::default(), runway_config: makenotwork::tier_prices::RunwayConfig::default(), s3: None, synckit_s3: None, stripe: None, email, docs: Arc::new(DocLoader::load(std::path::Path::new("."), &docengine::DocLoaderConfig { sections: vec![], link_prefix: "/docs".to_string(), unpublished_pattern: None, examples_path: None, pre_process: None, })), scanner: None, webauthn, syntax: None, started_at: chrono::Utc::now(), start_instant: Instant::now(), session_cache: Arc::new(dashmap::DashMap::new()), mt_client: None, wam: None, domain_cache: Arc::new(dashmap::DashMap::new()), restart_at: Arc::new(std::sync::atomic::AtomicI64::new(0)), sync_notify: Arc::new(dashmap::DashMap::new()), sse_connections: Arc::new(dashmap::DashMap::new()), metrics_handle: None, scan_semaphore: Arc::new(tokio::sync::Semaphore::new(4)), caddy_ask_semaphore: Arc::new(tokio::sync::Semaphore::new(8)), page_view_tx: makenotwork::db::page_views::spawn_batcher(pool.clone()), bg: makenotwork::background::spawn_pool(), }; let app = build_app(state, session_layer); // 4. Seed data println!("Seeding test data..."); let seed = Arc::new(seed_data(&app, &pool).await); println!( " Seeded {} creators, {} projects, {} items", seed.usernames.len(), seed.project_slugs.len(), seed.item_ids.len() ); // 5. Spawn VUs let metrics = MetricsCollector::new(); let ramp_delay = if config.virtual_users > 1 { config.ramp_up / config.virtual_users } else { Duration::ZERO }; let test_duration = config.duration; let test_start = Instant::now(); let mut handles = Vec::new(); println!("Spawning {} virtual users...", config.virtual_users); for vu in 0..config.virtual_users { // Stagger VU start times if vu > 0 { tokio::time::sleep(ramp_delay).await; } let scenario = config.scenario_mix.assign_scenario(vu, config.virtual_users); let ip = format!("10.0.{}.{}", vu / 256, vu % 256); let deadline = test_start + test_duration; let think_time = config.think_time; let m = metrics.clone(); let a = app.clone(); let s = Arc::clone(&seed); let p = pool.clone(); let handle = tokio::spawn(async move { match scenario { ScenarioType::AnonymousBrowse => { scenarios::anonymous_browse(a, ip, deadline, think_time, m, &s).await; } ScenarioType::BuyerFlow => { scenarios::buyer_flow(a, ip, deadline, think_time, m, &s).await; } ScenarioType::CreatorFlow => { scenarios::creator_flow(a, ip, deadline, think_time, m, p).await; } ScenarioType::DashboardSession => { scenarios::dashboard_session(a, ip, deadline, think_time, m).await; } } }); handles.push((vu, scenario, handle)); } // 6. Join all println!("Running for {:?}...\n", test_duration); for (vu, scenario, handle) in handles { if let Err(e) = handle.await { eprintln!("VU {} ({}) panicked: {:?}", vu, scenario, e); } } // 7. Report metrics.report().print(); // 8. Cleanup (TestDb dropped here) drop(pool); drop(test_db); } /// Seed the database with creators, projects, and items via HTTP endpoints. /// Returns shared seed data for all VU scenarios. async fn seed_data(app: &Router, pool: &PgPool) -> SeedData { let mut usernames = Vec::new(); let mut project_slugs = Vec::new(); let mut item_ids = Vec::new(); for i in 0..5 { let username = format!("seed_creator_{}", i); let slug = format!("seed-project-{}", i); let mut client = TestClient::new(app.clone()); // Unique IP per seed client to avoid rate limiting client.set_forwarded_ip(&format!("192.168.{}.{}", i / 256, i % 256 + 1)); // Sign up client.fetch_csrf_token().await; let body = format!( "username={}&email={}%40seed.local&password=seedpass123", username, username ); let resp = client.post_form("/join/step/account", &body).await; assert!( resp.status.is_success() || resp.status.is_redirection(), "Seed signup failed for {}: {} {}", username, resp.status, resp.text ); // Grant creator via SQL let user_id: uuid::Uuid = sqlx::query_scalar("SELECT id FROM users WHERE username = $1") .bind(&username) .fetch_one(pool) .await .expect("Seed user not found"); sqlx::query("UPDATE users SET can_create_projects = true WHERE id = $1") .bind(user_id) .execute(pool) .await .expect("Failed to grant creator to seed user"); // Re-login client.post_form("/logout", "").await; client.fetch_csrf_token().await; let body = format!("login={}&password=seedpass123", username); let resp = client.post_form("/login", &body).await; assert!( resp.status.is_success() || resp.status.is_redirection(), "Seed login failed for {}: {} {}", username, resp.status, resp.text ); // Create project let body = format!( "slug={}&title=Seed+Project+{}", urlencoding::encode(&slug), i ); let resp = client.post_form("/api/projects", &body).await; assert!( resp.status.is_success(), "Seed create project failed: {} {}", resp.status, resp.text ); let project: serde_json::Value = resp.json(); let project_id = project["id"].as_str().expect("project should have id"); // Make project public client .put_json( &format!("/api/projects/{}", project_id), r#"{"is_public": true}"#, ) .await; // Create 3 items per project for j in 0..3 { let item_body = format!( "title=Seed+Item+{}+{}&price_cents=0&item_type=digital", i, j ); let resp = client .post_form( &format!("/api/projects/{}/items", project_id), &item_body, ) .await; assert!( resp.status.is_success(), "Seed create item failed: {} {}", resp.status, resp.text ); let item: serde_json::Value = resp.json(); let item_id = item["id"].as_str().expect("item should have id"); // Publish item client .put_form(&format!("/api/items/{}", item_id), "is_public=true") .await; item_ids.push(item_id.to_string()); } usernames.push(username); project_slugs.push(slug); } SeedData { usernames, project_slugs, item_ids, } }