//! Load test orchestrator: sets up the shared app, seeds data, spawns virtual //! users, and prints the final report. use axum::Router; use sqlx::PgPool; use sqlx::postgres::PgPoolOptions; use std::sync::Arc; use std::time::{Duration, Instant}; use tower_sessions::cookie::SameSite; use tower_sessions::cookie::time::Duration as CookieDuration; use tower_sessions::{Expiry, SessionManagerLayer}; use tower_sessions_sqlx_store::PostgresStore; use docengine::DocLoader; use makenotwork::config::{ BuildConfig, Config, CreatorTierPricing, EmailWebhookConfig, IntegrationsConfig, }; use makenotwork::email::{EmailClient, EmailConfig}; use makenotwork::{AppState, AppStateParts, AppStorage, build_app}; use crate::harness::client::TestClient; use crate::harness::db::TestDb; use super::blocking_probe::BlockingProbe; use super::config::{LoadConfig, ScenarioType}; use super::metrics::MetricsCollector; use super::mt_stub::MtStub; use super::scenarios::{self, SeedData}; /// Run the full load test. pub(super) async fn run(config: LoadConfig) { // 1. Database setup: TestDb for creation/migration/cleanup let test_db = TestDb::new().await; // A stand-in Multithreaded, up before the app so its address can be // configured. Always started, not only when latency is asked for: the two // forum screens refuse or empty out when `mt_base_url` is unset, and a run // that measures a refusal reads as a very fast route. let mt = MtStub::start(config.mt_latency, config.mt_memberships).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, public_storage: None, rpm_storage: None, rpm_base_url: None, stripe: None, admin_user_id: None, synckit_jwt_secret: None, scan: None, cdn_base_url: "https://cdn.localhost".to_string(), user_pages_host: std::sync::Arc::from("u.localhost"), access_gate: makenotwork::config::AccessGate::Open, sso: None, // The load runner drives thousands of requests from one IP, so the // production limiter would measure the limiter rather than the server. rate_limits: makenotwork::constants::RateLimits::relaxed(), build: BuildConfig { trigger_token: None, host_linux: None, host_darwin: None, git_repos_path: None, git_ssh_host: None, }, email_webhooks: EmailWebhookConfig { webhook_token: None, broadcast_webhook_token: None, inbound_webhook_token: None, enforce_sender_auth: true, }, creator_pricing: CreatorTierPricing { fan_plus_price_id: None, tier_prices: std::collections::HashMap::new(), tier_annual_prices: std::collections::HashMap::new(), tier_founder_prices: std::collections::HashMap::new(), tier_founder_annual_prices: std::collections::HashMap::new(), founder_window_open: false, }, integrations: IntegrationsConfig { // Both halves, or the forum screens are not exercised. This was // hardcoded `None`, which is why the two Multithreaded-backed // described screens had no load measurement at all: the described // library tab answered an empty list without making the call, and // the described settings section 404'd, so either would have // reported as the fastest route in the run. mt_base_url: Some(mt.base_url()), wam_url: None, internal_shared_secret: Some("load-test-mt-secret".to_string()), cli_service_token: None, alerts_ingest_token: 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"), ); // Route through the shared `AppState::build` constructor (same as main.rs // and the integration harness), derived in-memory state has one source. let state = AppState::build(AppStateParts { db: pool.clone(), config: app_config, storage: AppStorage { s3: None, synckit_s3: None, public_s3: None, rpm_s3: None, }, payments: None, payment_caps: makenotwork::payments::PaymentCapabilities::default(), 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, }, )), tier_prices: { // Install the process-global TierPrices so CreatorTier accessors // work under the load harness (they read the global). Idempotent. makenotwork::tier_prices::TierPrices::install_test_default(); makenotwork::tier_prices::TierPrices::global().clone() }, runway_config: makenotwork::tier_prices::RunwayConfig::default(), fee_calculator: makenotwork::fee_calculator::FeeCalculator::load( "docs/business/assumptions.toml", ), scanner: None, webauthn, syntax: None, mt_client: None, wam: None, domain_cache: Arc::new(dashmap::DashMap::new()), metrics_handle: None, page_view_tx: makenotwork::db::page_views::spawn_batcher(pool.clone()), bg: makenotwork::background::spawn_pool_detached(), }); 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(); // Started after seeding, which is itself heavy enough to show as a stall and // is not part of what the run is measuring. let probe = BlockingProbe::start(config.probe_interval); 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, p).await; } } }); handles.push((vu, scenario, handle)); } // 6. Join all println!("Running for {test_duration:?}...\n"); for (vu, scenario, handle) in handles { if let Err(e) = handle.await { eprintln!("VU {vu} ({scenario}) panicked: {e:?}"); } } // 7. Report metrics.report().print(); probe.finish().print(); // 8. Cleanup (TestDb dropped here) drop(mt); 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={username}&email={username}%40seed.local&password=seedpass123"); let resp = client.post_form("/join/step/account", &body).await; assert_eq!( resp.status, 200, "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 on a FRESH client rather than logging this one out. The // signup left a live session whose SessionUser predates the grant // above, and every way of reusing it depends on the auth path noticing // the change: `authenticate` skips its touch query for // SESSION_TOUCH_CACHE_SECS (5s) and this dance runs well inside that. // A new cookie jar cannot carry a stale user. let mut client = TestClient::new(app.clone()); client.set_forwarded_ip(&format!("192.168.{}.{}", i / 256, i % 256 + 1)); client.fetch_csrf_token().await; let body = format!("login={username}&password=seedpass123"); let resp = client.post_form("/login", &body).await; assert_eq!( resp.status, 303, "Seed login failed for {}: {} {}", username, resp.status, resp.text ); // Re-fetch the token: logging in rotates the session, and the token // fetched before it is bound to the session that no longer exists. A // rejected CSRF check answers 403 Forbidden with the same generic // message an authorization failure uses, which is why this read for a // long time as "the seed user is not a creator" — the flags were right // the whole time. client.fetch_csrf_token().await; // Create project let body = format!( "slug={}&title=Seed+Project+{}", urlencoding::encode(&slug), i ); let resp = client.post_form("/api/projects", &body).await; assert_eq!( resp.status, 200, "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+{i}+{j}&price_cents=0&item_type=digital"); let resp = client .post_form(&format!("/api/projects/{project_id}/items"), &item_body) .await; assert_eq!( resp.status, 200, "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, } }