//! Load test scenarios: realistic user journeys executed in a loop until deadline. use crate::harness::client::TestClient; use axum::http::StatusCode; use axum::Router; use std::sync::atomic::{AtomicU64, Ordering}; use std::time::{Duration, Instant}; use tokio::time::sleep; use super::metrics::MetricsCollector; /// Global counter for unique usernames across VUs. static USER_COUNTER: AtomicU64 = AtomicU64::new(0); /// Seed data created during setup, shared (read-only) across all VUs. pub struct SeedData { pub usernames: Vec, pub project_slugs: Vec, pub item_ids: Vec, } // ============================================================================= // Timed request helpers // ============================================================================= async fn timed_get( client: &mut TestClient, uri: &str, label: &str, metrics: &MetricsCollector, ) -> StatusCode { let start = Instant::now(); let resp = client.get(uri).await; metrics.record(label.to_string(), start.elapsed(), resp.status); resp.status } async fn timed_post_form( client: &mut TestClient, uri: &str, body: &str, label: &str, metrics: &MetricsCollector, ) -> (StatusCode, String) { let start = Instant::now(); let resp = client.post_form(uri, body).await; metrics.record(label.to_string(), start.elapsed(), resp.status); (resp.status, resp.text) } async fn timed_put_form( client: &mut TestClient, uri: &str, body: &str, label: &str, metrics: &MetricsCollector, ) -> (StatusCode, String) { let start = Instant::now(); let resp = client.put_form(uri, body).await; metrics.record(label.to_string(), start.elapsed(), resp.status); (resp.status, resp.text) } async fn timed_put_json( client: &mut TestClient, uri: &str, body: &str, label: &str, metrics: &MetricsCollector, ) -> (StatusCode, String) { let start = Instant::now(); let resp = client.put_json(uri, body).await; metrics.record(label.to_string(), start.elapsed(), resp.status); (resp.status, resp.text) } async fn timed_htmx_get( client: &mut TestClient, uri: &str, label: &str, metrics: &MetricsCollector, ) -> StatusCode { let start = Instant::now(); let resp = client.htmx_get(uri).await; metrics.record(label.to_string(), start.elapsed(), resp.status); resp.status } #[allow(dead_code)] async fn timed_delete( client: &mut TestClient, uri: &str, label: &str, metrics: &MetricsCollector, ) -> StatusCode { let start = Instant::now(); let resp = client.delete(uri).await; metrics.record(label.to_string(), start.elapsed(), resp.status); resp.status } // ============================================================================= // Helpers // ============================================================================= fn next_username(prefix: &str) -> String { let n = USER_COUNTER.fetch_add(1, Ordering::Relaxed); format!("{}_{}", prefix, n) } /// Sign up a new user via the app's /join endpoint. Returns true on success. async fn signup( client: &mut TestClient, username: &str, metrics: &MetricsCollector, ) -> bool { // Fetch CSRF token client.fetch_csrf_token().await; let body = format!( "username={}&email={}%40loadtest.local&password=loadtest123", urlencoding::encode(username), urlencoding::encode(username), ); let (status, _) = timed_post_form(client, "/join", &body, "POST /join", metrics).await; status.is_success() || status.is_redirection() } /// Log in as an existing user. async fn login( client: &mut TestClient, username: &str, metrics: &MetricsCollector, ) -> bool { client.fetch_csrf_token().await; let body = format!( "login={}&password=loadtest123", urlencoding::encode(username), ); let (status, _) = timed_post_form(client, "/login", &body, "POST /login", metrics).await; status.is_success() || status.is_redirection() } // ============================================================================= // Scenarios // ============================================================================= /// Anonymous browsing: no auth, cycles through public pages using seed data. pub async fn anonymous_browse( app: Router, ip: String, deadline: Instant, think_time: Duration, metrics: MetricsCollector, seed: &SeedData, ) { let mut client = TestClient::new(app); client.set_forwarded_ip(&ip); let mut cycle = 0usize; while Instant::now() < deadline { let u_idx = cycle % seed.usernames.len(); let p_idx = cycle % seed.project_slugs.len(); let i_idx = cycle % seed.item_ids.len(); timed_get(&mut client, "/", "GET /", &metrics).await; sleep(think_time).await; timed_get(&mut client, "/discover", "GET /discover", &metrics).await; sleep(think_time).await; timed_htmx_get(&mut client, "/discover/results", "HTMX /discover/results", &metrics).await; sleep(think_time).await; let user_url = format!("/u/{}", seed.usernames[u_idx]); timed_get(&mut client, &user_url, "GET /u/{username}", &metrics).await; sleep(think_time).await; let proj_url = format!("/p/{}", seed.project_slugs[p_idx]); timed_get(&mut client, &proj_url, "GET /p/{slug}", &metrics).await; sleep(think_time).await; let item_url = format!("/i/{}", seed.item_ids[i_idx]); timed_get(&mut client, &item_url, "GET /i/{item_id}", &metrics).await; sleep(think_time).await; cycle += 1; } } /// Buyer flow: signup, browse discover, add a free item to library. pub async fn buyer_flow( app: Router, ip: String, deadline: Instant, think_time: Duration, metrics: MetricsCollector, seed: &SeedData, ) { let mut cycle = 0usize; while Instant::now() < deadline { // Fresh client per cycle (new session) let mut client = TestClient::new(app.clone()); client.set_forwarded_ip(&ip); let username = next_username("buyer"); if !signup(&mut client, &username, &metrics).await { sleep(think_time).await; cycle += 1; continue; } sleep(think_time).await; timed_get(&mut client, "/discover", "GET /discover", &metrics).await; sleep(think_time).await; timed_htmx_get(&mut client, "/discover/results", "HTMX /discover/results", &metrics).await; sleep(think_time).await; let i_idx = cycle % seed.item_ids.len(); let item_url = format!("/i/{}", seed.item_ids[i_idx]); timed_get(&mut client, &item_url, "GET /i/{item_id}", &metrics).await; sleep(think_time).await; let add_url = format!("/api/library/add/{}", seed.item_ids[i_idx]); timed_post_form(&mut client, &add_url, "", "POST /api/library/add", &metrics).await; sleep(think_time).await; timed_get(&mut client, "/library", "GET /library", &metrics).await; sleep(think_time).await; cycle += 1; } } /// Creator flow: signup, grant creator via SQL, create project + items, publish. pub async fn creator_flow( app: Router, ip: String, deadline: Instant, think_time: Duration, metrics: MetricsCollector, pool: sqlx::PgPool, ) { let mut cycle = 0usize; while Instant::now() < deadline { let mut client = TestClient::new(app.clone()); client.set_forwarded_ip(&ip); let username = next_username("creator"); if !signup(&mut client, &username, &metrics).await { sleep(think_time).await; cycle += 1; continue; } // Grant creator via SQL let user_id: Option = sqlx::query_scalar("SELECT id FROM users WHERE username = $1") .bind(&username) .fetch_optional(&pool) .await .ok() .flatten(); let Some(user_id) = user_id else { sleep(think_time).await; cycle += 1; continue; }; let _ = sqlx::query("UPDATE users SET can_create_projects = true WHERE id = $1") .bind(user_id) .execute(&pool) .await; // Re-login to pick up creator permissions timed_post_form(&mut client, "/logout", "", "POST /logout", &metrics).await; sleep(think_time).await; if !login(&mut client, &username, &metrics).await { sleep(think_time).await; cycle += 1; continue; } sleep(think_time).await; // Create project let slug = format!("proj-{}", username); let body = format!("slug={}&title=Load+Test+Project", urlencoding::encode(&slug)); let (status, text) = timed_post_form(&mut client, "/api/projects", &body, "POST /api/projects", &metrics) .await; if !status.is_success() { sleep(think_time).await; cycle += 1; continue; } let project_id = serde_json::from_str::(&text) .ok() .and_then(|v| v["id"].as_str().map(String::from)); let Some(project_id) = project_id else { sleep(think_time).await; cycle += 1; continue; }; sleep(think_time).await; // Create 3 items let mut item_ids = Vec::new(); for i in 0..3 { let item_body = format!( "title=Item+{}+{}&price_cents=0&item_type=digital", cycle, i ); let (status, text) = timed_post_form( &mut client, &format!("/api/projects/{}/items", project_id), &item_body, "POST /api/projects/{id}/items", &metrics, ) .await; if status.is_success() && let Some(id) = serde_json::from_str::(&text) .ok() .and_then(|v| v["id"].as_str().map(String::from)) { item_ids.push(id); } sleep(think_time).await; } // Publish project timed_put_json( &mut client, &format!("/api/projects/{}", project_id), r#"{"is_public": true}"#, "PUT /api/projects/{id}", &metrics, ) .await; sleep(think_time).await; // Publish items for item_id in &item_ids { timed_put_form( &mut client, &format!("/api/items/{}", item_id), "is_public=true", "PUT /api/items/{id}", &metrics, ) .await; sleep(think_time).await; } // View dashboard timed_get(&mut client, "/dashboard", "GET /dashboard", &metrics).await; sleep(think_time).await; cycle += 1; } } /// Dashboard session: one-time signup, then loop through dashboard tabs. pub async fn dashboard_session( app: Router, ip: String, deadline: Instant, think_time: Duration, metrics: MetricsCollector, ) { let mut client = TestClient::new(app); client.set_forwarded_ip(&ip); let username = next_username("dash"); if !signup(&mut client, &username, &metrics).await { return; } sleep(think_time).await; let tabs = ["details", "payments", "projects", "creator", "promotions"]; while Instant::now() < deadline { timed_get(&mut client, "/dashboard", "GET /dashboard", &metrics).await; sleep(think_time).await; for tab in &tabs { let url = format!("/dashboard/tabs/{}", tab); timed_htmx_get(&mut client, &url, "HTMX /dashboard/tabs/{tab}", &metrics).await; sleep(think_time).await; } timed_get( &mut client, "/dashboard/transactions", "GET /dashboard/transactions", &metrics, ) .await; sleep(think_time).await; } }