Skip to main content

max / makenotwork

11.6 KB · 356 lines History Blame Raw
1 //! Load test orchestrator: sets up the shared app, seeds data, spawns virtual
2 //! users, and prints the final report.
3
4 use axum::Router;
5 use sqlx::PgPool;
6 use sqlx::postgres::PgPoolOptions;
7 use std::sync::Arc;
8 use std::time::{Duration, Instant};
9 use tower_sessions::cookie::SameSite;
10 use tower_sessions::cookie::time::Duration as CookieDuration;
11 use tower_sessions::{Expiry, SessionManagerLayer};
12 use tower_sessions_sqlx_store::PostgresStore;
13
14 use docengine::DocLoader;
15 use makenotwork::config::{
16 BuildConfig, Config, CreatorTierPricing, EmailWebhookConfig, IntegrationsConfig,
17 };
18 use makenotwork::email::{EmailClient, EmailConfig};
19 use makenotwork::{AppState, AppStateParts, AppStorage, build_app};
20
21 use crate::harness::client::TestClient;
22 use crate::harness::db::TestDb;
23
24 use super::config::{LoadConfig, ScenarioType};
25 use super::metrics::MetricsCollector;
26 use super::scenarios::{self, SeedData};
27
28 /// Run the full load test.
29 pub(super) async fn run(config: LoadConfig) {
30 // 1. Database setup: TestDb for creation/migration/cleanup
31 let test_db = TestDb::new().await;
32
33 // Production-sized pool against the same test database
34 let pool = PgPoolOptions::new()
35 .max_connections(config.db_max_connections)
36 .acquire_timeout(config.db_acquire_timeout)
37 .connect(test_db.url())
38 .await
39 .expect("Failed to create load test pool");
40
41 // 2. Session store
42 let session_store = PostgresStore::new(pool.clone());
43 session_store
44 .migrate()
45 .await
46 .expect("Failed to migrate session store");
47
48 let session_layer = SessionManagerLayer::new(session_store)
49 .with_secure(false)
50 .with_same_site(SameSite::Lax)
51 .with_expiry(Expiry::OnInactivity(CookieDuration::days(1)));
52
53 // 3. App
54 let app_config = Config {
55 host: "127.0.0.1".parse().unwrap(),
56 port: 0,
57 database_url: String::new(),
58 host_url: std::sync::Arc::from("http://localhost:3000"),
59 signing_secret: "load-test-signing-secret".to_string(),
60 storage: None,
61 synckit_storage: None,
62 public_storage: None,
63 stripe: None,
64 admin_user_id: None,
65 synckit_jwt_secret: None,
66 scan: None,
67 cdn_base_url: "https://cdn.localhost".to_string(),
68 user_pages_host: std::sync::Arc::from("u.localhost"),
69 access_gate: makenotwork::config::AccessGate::Open,
70 sso: None,
71 build: BuildConfig {
72 trigger_token: None,
73 host_linux: None,
74 host_darwin: None,
75 git_repos_path: None,
76 git_ssh_host: None,
77 },
78 email_webhooks: EmailWebhookConfig {
79 webhook_token: None,
80 broadcast_webhook_token: None,
81 inbound_webhook_token: None,
82 enforce_sender_auth: true,
83 },
84 creator_pricing: CreatorTierPricing {
85 fan_plus_price_id: None,
86 tier_prices: std::collections::HashMap::new(),
87 tier_annual_prices: std::collections::HashMap::new(),
88 tier_founder_prices: std::collections::HashMap::new(),
89 tier_founder_annual_prices: std::collections::HashMap::new(),
90 founder_window_open: false,
91 },
92 integrations: IntegrationsConfig {
93 mt_base_url: None,
94 wam_url: None,
95 internal_shared_secret: None,
96 cli_service_token: None,
97 alerts_ingest_token: None,
98 },
99 };
100
101 let email = EmailClient::new(
102 EmailConfig {
103 postmark_token: None,
104 from_address: "loadtest@makenot.work".to_string(),
105 from_name: "LoadTest".to_string(),
106 },
107 Some(pool.clone()),
108 );
109
110 let rp_origin = url::Url::parse(&app_config.host_url).expect("test HOST_URL");
111 let rp_id = rp_origin
112 .host_str()
113 .expect("test HOST_URL host")
114 .to_string();
115 let webauthn = Arc::new(
116 webauthn_rs::WebauthnBuilder::new(&rp_id, &rp_origin)
117 .expect("WebauthnBuilder")
118 .rp_name("LoadTest")
119 .build()
120 .expect("Webauthn"),
121 );
122
123 // Route through the shared `AppState::build` constructor (same as main.rs
124 // and the integration harness), derived in-memory state has one source.
125 let state = AppState::build(AppStateParts {
126 db: pool.clone(),
127 config: app_config,
128 storage: AppStorage {
129 s3: None,
130 synckit_s3: None,
131 public_s3: None,
132 },
133 stripe: None,
134 email,
135 docs: Arc::new(DocLoader::load(
136 std::path::Path::new("."),
137 &docengine::DocLoaderConfig {
138 sections: vec![],
139 link_prefix: "/docs".to_string(),
140 unpublished_pattern: None,
141 examples_path: None,
142 pre_process: None,
143 },
144 )),
145 tier_prices: {
146 // Install the process-global TierPrices so CreatorTier accessors
147 // work under the load harness (they read the global). Idempotent.
148 makenotwork::tier_prices::TierPrices::install_test_default();
149 makenotwork::tier_prices::TierPrices::global().clone()
150 },
151 runway_config: makenotwork::tier_prices::RunwayConfig::default(),
152 pricing_comparison: makenotwork::pricing_comparison::PricingComparison::load(
153 "docs/business/assumptions.toml",
154 ),
155 scanner: None,
156 webauthn,
157 syntax: None,
158 mt_client: None,
159 wam: None,
160 domain_cache: Arc::new(dashmap::DashMap::new()),
161 metrics_handle: None,
162 page_view_tx: makenotwork::db::page_views::spawn_batcher(pool.clone()),
163 bg: makenotwork::background::spawn_pool_detached(),
164 });
165
166 let app = build_app(&state, session_layer);
167
168 // 4. Seed data
169 println!("Seeding test data...");
170 let seed = Arc::new(seed_data(&app, &pool).await);
171 println!(
172 " Seeded {} creators, {} projects, {} items",
173 seed.usernames.len(),
174 seed.project_slugs.len(),
175 seed.item_ids.len()
176 );
177
178 // 5. Spawn VUs
179 let metrics = MetricsCollector::new();
180 let ramp_delay = if config.virtual_users > 1 {
181 config.ramp_up / config.virtual_users
182 } else {
183 Duration::ZERO
184 };
185
186 let test_duration = config.duration;
187 let test_start = Instant::now();
188 let mut handles = Vec::new();
189
190 println!("Spawning {} virtual users...", config.virtual_users);
191
192 for vu in 0..config.virtual_users {
193 // Stagger VU start times
194 if vu > 0 {
195 tokio::time::sleep(ramp_delay).await;
196 }
197
198 let scenario = config
199 .scenario_mix
200 .assign_scenario(vu, config.virtual_users);
201 let ip = format!("10.0.{}.{}", vu / 256, vu % 256);
202 let deadline = test_start + test_duration;
203 let think_time = config.think_time;
204 let m = metrics.clone();
205 let a = app.clone();
206 let s = Arc::clone(&seed);
207 let p = pool.clone();
208
209 let handle = tokio::spawn(async move {
210 match scenario {
211 ScenarioType::AnonymousBrowse => {
212 scenarios::anonymous_browse(a, ip, deadline, think_time, m, &s).await;
213 }
214 ScenarioType::BuyerFlow => {
215 scenarios::buyer_flow(a, ip, deadline, think_time, m, &s).await;
216 }
217 ScenarioType::CreatorFlow => {
218 scenarios::creator_flow(a, ip, deadline, think_time, m, p).await;
219 }
220 ScenarioType::DashboardSession => {
221 scenarios::dashboard_session(a, ip, deadline, think_time, m).await;
222 }
223 }
224 });
225
226 handles.push((vu, scenario, handle));
227 }
228
229 // 6. Join all
230 println!("Running for {test_duration:?}...\n");
231 for (vu, scenario, handle) in handles {
232 if let Err(e) = handle.await {
233 eprintln!("VU {vu} ({scenario}) panicked: {e:?}");
234 }
235 }
236
237 // 7. Report
238 metrics.report().print();
239
240 // 8. Cleanup (TestDb dropped here)
241 drop(pool);
242 drop(test_db);
243 }
244
245 /// Seed the database with creators, projects, and items via HTTP endpoints.
246 /// Returns shared seed data for all VU scenarios.
247 async fn seed_data(app: &Router, pool: &PgPool) -> SeedData {
248 let mut usernames = Vec::new();
249 let mut project_slugs = Vec::new();
250 let mut item_ids = Vec::new();
251
252 for i in 0..5 {
253 let username = format!("seed_creator_{i}");
254 let slug = format!("seed-project-{i}");
255
256 let mut client = TestClient::new(app.clone());
257 // Unique IP per seed client to avoid rate limiting
258 client.set_forwarded_ip(&format!("192.168.{}.{}", i / 256, i % 256 + 1));
259
260 // Sign up
261 client.fetch_csrf_token().await;
262 let body =
263 format!("username={username}&email={username}%40seed.local&password=seedpass123");
264 let resp = client.post_form("/join/step/account", &body).await;
265 assert!(
266 resp.status.is_success() || resp.status.is_redirection(),
267 "Seed signup failed for {}: {} {}",
268 username,
269 resp.status,
270 resp.text
271 );
272
273 // Grant creator via SQL
274 let user_id: uuid::Uuid = sqlx::query_scalar("SELECT id FROM users WHERE username = $1")
275 .bind(&username)
276 .fetch_one(pool)
277 .await
278 .expect("Seed user not found");
279
280 sqlx::query("UPDATE users SET can_create_projects = true WHERE id = $1")
281 .bind(user_id)
282 .execute(pool)
283 .await
284 .expect("Failed to grant creator to seed user");
285
286 // Re-login
287 client.post_form("/logout", "").await;
288 client.fetch_csrf_token().await;
289 let body = format!("login={username}&password=seedpass123");
290 let resp = client.post_form("/login", &body).await;
291 assert!(
292 resp.status.is_success() || resp.status.is_redirection(),
293 "Seed login failed for {}: {} {}",
294 username,
295 resp.status,
296 resp.text
297 );
298
299 // Create project
300 let body = format!(
301 "slug={}&title=Seed+Project+{}",
302 urlencoding::encode(&slug),
303 i
304 );
305 let resp = client.post_form("/api/projects", &body).await;
306 assert!(
307 resp.status.is_success(),
308 "Seed create project failed: {} {}",
309 resp.status,
310 resp.text
311 );
312 let project: serde_json::Value = resp.json();
313 let project_id = project["id"].as_str().expect("project should have id");
314
315 // Make project public
316 client
317 .put_json(
318 &format!("/api/projects/{project_id}"),
319 r#"{"is_public": true}"#,
320 )
321 .await;
322
323 // Create 3 items per project
324 for j in 0..3 {
325 let item_body = format!("title=Seed+Item+{i}+{j}&price_cents=0&item_type=digital");
326 let resp = client
327 .post_form(&format!("/api/projects/{project_id}/items"), &item_body)
328 .await;
329 assert!(
330 resp.status.is_success(),
331 "Seed create item failed: {} {}",
332 resp.status,
333 resp.text
334 );
335 let item: serde_json::Value = resp.json();
336 let item_id = item["id"].as_str().expect("item should have id");
337
338 // Publish item
339 client
340 .put_form(&format!("/api/items/{item_id}"), "is_public=true")
341 .await;
342
343 item_ids.push(item_id.to_string());
344 }
345
346 usernames.push(username);
347 project_slugs.push(slug);
348 }
349
350 SeedData {
351 usernames,
352 project_slugs,
353 item_ids,
354 }
355 }
356