Skip to main content

max / makenotwork

13.8 KB · 389 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::blocking_probe::BlockingProbe;
25 use super::config::{LoadConfig, ScenarioType};
26 use super::metrics::MetricsCollector;
27 use super::mt_stub::MtStub;
28 use super::scenarios::{self, SeedData};
29
30 /// Run the full load test.
31 pub(super) async fn run(config: LoadConfig) {
32 // 1. Database setup: TestDb for creation/migration/cleanup
33 let test_db = TestDb::new().await;
34
35 // A stand-in Multithreaded, up before the app so its address can be
36 // configured. Always started, not only when latency is asked for: the two
37 // forum screens refuse or empty out when `mt_base_url` is unset, and a run
38 // that measures a refusal reads as a very fast route.
39 let mt = MtStub::start(config.mt_latency, config.mt_memberships).await;
40
41 // Production-sized pool against the same test database
42 let pool = PgPoolOptions::new()
43 .max_connections(config.db_max_connections)
44 .acquire_timeout(config.db_acquire_timeout)
45 .connect(test_db.url())
46 .await
47 .expect("Failed to create load test pool");
48
49 // 2. Session store
50 let session_store = PostgresStore::new(pool.clone());
51 session_store
52 .migrate()
53 .await
54 .expect("Failed to migrate session store");
55
56 let session_layer = SessionManagerLayer::new(session_store)
57 .with_secure(false)
58 .with_same_site(SameSite::Lax)
59 .with_expiry(Expiry::OnInactivity(CookieDuration::days(1)));
60
61 // 3. App
62 let app_config = Config {
63 host: "127.0.0.1".parse().unwrap(),
64 port: 0,
65 database_url: String::new(),
66 host_url: std::sync::Arc::from("http://localhost:3000"),
67 signing_secret: "load-test-signing-secret".to_string(),
68 storage: None,
69 synckit_storage: None,
70 public_storage: None,
71 rpm_storage: None,
72 rpm_base_url: None,
73 stripe: None,
74 admin_user_id: None,
75 synckit_jwt_secret: None,
76 scan: None,
77 cdn_base_url: "https://cdn.localhost".to_string(),
78 user_pages_host: std::sync::Arc::from("u.localhost"),
79 access_gate: makenotwork::config::AccessGate::Open,
80 sso: None,
81 // The load runner drives thousands of requests from one IP, so the
82 // production limiter would measure the limiter rather than the server.
83 rate_limits: makenotwork::constants::RateLimits::relaxed(),
84 build: BuildConfig {
85 trigger_token: None,
86 host_linux: None,
87 host_darwin: None,
88 git_repos_path: None,
89 git_ssh_host: None,
90 },
91 email_webhooks: EmailWebhookConfig {
92 webhook_token: None,
93 broadcast_webhook_token: None,
94 inbound_webhook_token: None,
95 enforce_sender_auth: true,
96 },
97 creator_pricing: CreatorTierPricing {
98 fan_plus_price_id: None,
99 tier_prices: std::collections::HashMap::new(),
100 tier_annual_prices: std::collections::HashMap::new(),
101 tier_founder_prices: std::collections::HashMap::new(),
102 tier_founder_annual_prices: std::collections::HashMap::new(),
103 founder_window_open: false,
104 },
105 integrations: IntegrationsConfig {
106 // Both halves, or the forum screens are not exercised. This was
107 // hardcoded `None`, which is why the two Multithreaded-backed
108 // described screens had no load measurement at all: the described
109 // library tab answered an empty list without making the call, and
110 // the described settings section 404'd, so either would have
111 // reported as the fastest route in the run.
112 mt_base_url: Some(mt.base_url()),
113 wam_url: None,
114 internal_shared_secret: Some("load-test-mt-secret".to_string()),
115 cli_service_token: None,
116 alerts_ingest_token: None,
117 },
118 };
119
120 let email = EmailClient::new(
121 EmailConfig {
122 postmark_token: None,
123 from_address: "loadtest@makenot.work".to_string(),
124 from_name: "LoadTest".to_string(),
125 },
126 Some(pool.clone()),
127 );
128
129 let rp_origin = url::Url::parse(&app_config.host_url).expect("test HOST_URL");
130 let rp_id = rp_origin
131 .host_str()
132 .expect("test HOST_URL host")
133 .to_string();
134 let webauthn = Arc::new(
135 webauthn_rs::WebauthnBuilder::new(&rp_id, &rp_origin)
136 .expect("WebauthnBuilder")
137 .rp_name("LoadTest")
138 .build()
139 .expect("Webauthn"),
140 );
141
142 // Route through the shared `AppState::build` constructor (same as main.rs
143 // and the integration harness), derived in-memory state has one source.
144 let state = AppState::build(AppStateParts {
145 db: pool.clone(),
146 config: app_config,
147 storage: AppStorage {
148 s3: None,
149 synckit_s3: None,
150 public_s3: None,
151 rpm_s3: None,
152 },
153 stripe: None,
154 email,
155 docs: Arc::new(DocLoader::load(
156 std::path::Path::new("."),
157 &docengine::DocLoaderConfig {
158 sections: vec![],
159 link_prefix: "/docs".to_string(),
160 unpublished_pattern: None,
161 examples_path: None,
162 pre_process: None,
163 },
164 )),
165 tier_prices: {
166 // Install the process-global TierPrices so CreatorTier accessors
167 // work under the load harness (they read the global). Idempotent.
168 makenotwork::tier_prices::TierPrices::install_test_default();
169 makenotwork::tier_prices::TierPrices::global().clone()
170 },
171 runway_config: makenotwork::tier_prices::RunwayConfig::default(),
172 fee_calculator: makenotwork::fee_calculator::FeeCalculator::load(
173 "docs/business/assumptions.toml",
174 ),
175 scanner: None,
176 webauthn,
177 syntax: None,
178 mt_client: None,
179 wam: None,
180 domain_cache: Arc::new(dashmap::DashMap::new()),
181 metrics_handle: None,
182 page_view_tx: makenotwork::db::page_views::spawn_batcher(pool.clone()),
183 bg: makenotwork::background::spawn_pool_detached(),
184 });
185
186 let app = build_app(&state, session_layer);
187
188 // 4. Seed data
189 println!("Seeding test data...");
190 let seed = Arc::new(seed_data(&app, &pool).await);
191 println!(
192 " Seeded {} creators, {} projects, {} items",
193 seed.usernames.len(),
194 seed.project_slugs.len(),
195 seed.item_ids.len()
196 );
197
198 // 5. Spawn VUs
199 let metrics = MetricsCollector::new();
200 // Started after seeding, which is itself heavy enough to show as a stall and
201 // is not part of what the run is measuring.
202 let probe = BlockingProbe::start(config.probe_interval);
203 let ramp_delay = if config.virtual_users > 1 {
204 config.ramp_up / config.virtual_users
205 } else {
206 Duration::ZERO
207 };
208
209 let test_duration = config.duration;
210 let test_start = Instant::now();
211 let mut handles = Vec::new();
212
213 println!("Spawning {} virtual users...", config.virtual_users);
214
215 for vu in 0..config.virtual_users {
216 // Stagger VU start times
217 if vu > 0 {
218 tokio::time::sleep(ramp_delay).await;
219 }
220
221 let scenario = config
222 .scenario_mix
223 .assign_scenario(vu, config.virtual_users);
224 let ip = format!("10.0.{}.{}", vu / 256, vu % 256);
225 let deadline = test_start + test_duration;
226 let think_time = config.think_time;
227 let m = metrics.clone();
228 let a = app.clone();
229 let s = Arc::clone(&seed);
230 let p = pool.clone();
231
232 let handle = tokio::spawn(async move {
233 match scenario {
234 ScenarioType::AnonymousBrowse => {
235 scenarios::anonymous_browse(a, ip, deadline, think_time, m, &s).await;
236 }
237 ScenarioType::BuyerFlow => {
238 scenarios::buyer_flow(a, ip, deadline, think_time, m, &s).await;
239 }
240 ScenarioType::CreatorFlow => {
241 scenarios::creator_flow(a, ip, deadline, think_time, m, p).await;
242 }
243 ScenarioType::DashboardSession => {
244 scenarios::dashboard_session(a, ip, deadline, think_time, m, p).await;
245 }
246 }
247 });
248
249 handles.push((vu, scenario, handle));
250 }
251
252 // 6. Join all
253 println!("Running for {test_duration:?}...\n");
254 for (vu, scenario, handle) in handles {
255 if let Err(e) = handle.await {
256 eprintln!("VU {vu} ({scenario}) panicked: {e:?}");
257 }
258 }
259
260 // 7. Report
261 metrics.report().print();
262 probe.finish().print();
263
264 // 8. Cleanup (TestDb dropped here)
265 drop(mt);
266 drop(pool);
267 drop(test_db);
268 }
269
270 /// Seed the database with creators, projects, and items via HTTP endpoints.
271 /// Returns shared seed data for all VU scenarios.
272 async fn seed_data(app: &Router, pool: &PgPool) -> SeedData {
273 let mut usernames = Vec::new();
274 let mut project_slugs = Vec::new();
275 let mut item_ids = Vec::new();
276
277 for i in 0..5 {
278 let username = format!("seed_creator_{i}");
279 let slug = format!("seed-project-{i}");
280
281 let mut client = TestClient::new(app.clone());
282 // Unique IP per seed client to avoid rate limiting
283 client.set_forwarded_ip(&format!("192.168.{}.{}", i / 256, i % 256 + 1));
284
285 // Sign up
286 client.fetch_csrf_token().await;
287 let body =
288 format!("username={username}&email={username}%40seed.local&password=seedpass123");
289 let resp = client.post_form("/join/step/account", &body).await;
290 assert_eq!(
291 resp.status, 200,
292 "Seed signup failed for {}: {} {}",
293 username, resp.status, resp.text
294 );
295
296 // Grant creator via SQL
297 let user_id: uuid::Uuid = sqlx::query_scalar("SELECT id FROM users WHERE username = $1")
298 .bind(&username)
299 .fetch_one(pool)
300 .await
301 .expect("Seed user not found");
302
303 sqlx::query("UPDATE users SET can_create_projects = true WHERE id = $1")
304 .bind(user_id)
305 .execute(pool)
306 .await
307 .expect("Failed to grant creator to seed user");
308
309 // Re-login on a FRESH client rather than logging this one out. The
310 // signup left a live session whose SessionUser predates the grant
311 // above, and every way of reusing it depends on the auth path noticing
312 // the change: `authenticate` skips its touch query for
313 // SESSION_TOUCH_CACHE_SECS (5s) and this dance runs well inside that.
314 // A new cookie jar cannot carry a stale user.
315 let mut client = TestClient::new(app.clone());
316 client.set_forwarded_ip(&format!("192.168.{}.{}", i / 256, i % 256 + 1));
317 client.fetch_csrf_token().await;
318 let body = format!("login={username}&password=seedpass123");
319 let resp = client.post_form("/login", &body).await;
320 assert_eq!(
321 resp.status, 303,
322 "Seed login failed for {}: {} {}",
323 username, resp.status, resp.text
324 );
325
326 // Re-fetch the token: logging in rotates the session, and the token
327 // fetched before it is bound to the session that no longer exists. A
328 // rejected CSRF check answers 403 Forbidden with the same generic
329 // message an authorization failure uses, which is why this read for a
330 // long time as "the seed user is not a creator" — the flags were right
331 // the whole time.
332 client.fetch_csrf_token().await;
333
334 // Create project
335 let body = format!(
336 "slug={}&title=Seed+Project+{}",
337 urlencoding::encode(&slug),
338 i
339 );
340 let resp = client.post_form("/api/projects", &body).await;
341 assert_eq!(
342 resp.status, 200,
343 "Seed create project failed: {} {}",
344 resp.status, resp.text
345 );
346 let project: serde_json::Value = resp.json();
347 let project_id = project["id"].as_str().expect("project should have id");
348
349 // Make project public
350 client
351 .put_json(
352 &format!("/api/projects/{project_id}"),
353 r#"{"is_public": true}"#,
354 )
355 .await;
356
357 // Create 3 items per project
358 for j in 0..3 {
359 let item_body = format!("title=Seed+Item+{i}+{j}&price_cents=0&item_type=digital");
360 let resp = client
361 .post_form(&format!("/api/projects/{project_id}/items"), &item_body)
362 .await;
363 assert_eq!(
364 resp.status, 200,
365 "Seed create item failed: {} {}",
366 resp.status, resp.text
367 );
368 let item: serde_json::Value = resp.json();
369 let item_id = item["id"].as_str().expect("item should have id");
370
371 // Publish item
372 client
373 .put_form(&format!("/api/items/{item_id}"), "is_public=true")
374 .await;
375
376 item_ids.push(item_id.to_string());
377 }
378
379 usernames.push(username);
380 project_slugs.push(slug);
381 }
382
383 SeedData {
384 usernames,
385 project_slugs,
386 item_ids,
387 }
388 }
389