Skip to main content

max / makenotwork

13.9 KB · 390 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 payments: None,
154 payment_caps: makenotwork::payments::PaymentCapabilities::default(),
155 email,
156 docs: Arc::new(DocLoader::load(
157 std::path::Path::new("."),
158 &docengine::DocLoaderConfig {
159 sections: vec![],
160 link_prefix: "/docs".to_string(),
161 unpublished_pattern: None,
162 examples_path: None,
163 pre_process: None,
164 },
165 )),
166 tier_prices: {
167 // Install the process-global TierPrices so CreatorTier accessors
168 // work under the load harness (they read the global). Idempotent.
169 makenotwork::tier_prices::TierPrices::install_test_default();
170 makenotwork::tier_prices::TierPrices::global().clone()
171 },
172 runway_config: makenotwork::tier_prices::RunwayConfig::default(),
173 fee_calculator: makenotwork::fee_calculator::FeeCalculator::load(
174 "docs/business/assumptions.toml",
175 ),
176 scanner: None,
177 webauthn,
178 syntax: None,
179 mt_client: None,
180 wam: None,
181 domain_cache: Arc::new(dashmap::DashMap::new()),
182 metrics_handle: None,
183 page_view_tx: makenotwork::db::page_views::spawn_batcher(pool.clone()),
184 bg: makenotwork::background::spawn_pool_detached(),
185 });
186
187 let app = build_app(&state, session_layer);
188
189 // 4. Seed data
190 println!("Seeding test data...");
191 let seed = Arc::new(seed_data(&app, &pool).await);
192 println!(
193 " Seeded {} creators, {} projects, {} items",
194 seed.usernames.len(),
195 seed.project_slugs.len(),
196 seed.item_ids.len()
197 );
198
199 // 5. Spawn VUs
200 let metrics = MetricsCollector::new();
201 // Started after seeding, which is itself heavy enough to show as a stall and
202 // is not part of what the run is measuring.
203 let probe = BlockingProbe::start(config.probe_interval);
204 let ramp_delay = if config.virtual_users > 1 {
205 config.ramp_up / config.virtual_users
206 } else {
207 Duration::ZERO
208 };
209
210 let test_duration = config.duration;
211 let test_start = Instant::now();
212 let mut handles = Vec::new();
213
214 println!("Spawning {} virtual users...", config.virtual_users);
215
216 for vu in 0..config.virtual_users {
217 // Stagger VU start times
218 if vu > 0 {
219 tokio::time::sleep(ramp_delay).await;
220 }
221
222 let scenario = config
223 .scenario_mix
224 .assign_scenario(vu, config.virtual_users);
225 let ip = format!("10.0.{}.{}", vu / 256, vu % 256);
226 let deadline = test_start + test_duration;
227 let think_time = config.think_time;
228 let m = metrics.clone();
229 let a = app.clone();
230 let s = Arc::clone(&seed);
231 let p = pool.clone();
232
233 let handle = tokio::spawn(async move {
234 match scenario {
235 ScenarioType::AnonymousBrowse => {
236 scenarios::anonymous_browse(a, ip, deadline, think_time, m, &s).await;
237 }
238 ScenarioType::BuyerFlow => {
239 scenarios::buyer_flow(a, ip, deadline, think_time, m, &s).await;
240 }
241 ScenarioType::CreatorFlow => {
242 scenarios::creator_flow(a, ip, deadline, think_time, m, p).await;
243 }
244 ScenarioType::DashboardSession => {
245 scenarios::dashboard_session(a, ip, deadline, think_time, m, p).await;
246 }
247 }
248 });
249
250 handles.push((vu, scenario, handle));
251 }
252
253 // 6. Join all
254 println!("Running for {test_duration:?}...\n");
255 for (vu, scenario, handle) in handles {
256 if let Err(e) = handle.await {
257 eprintln!("VU {vu} ({scenario}) panicked: {e:?}");
258 }
259 }
260
261 // 7. Report
262 metrics.report().print();
263 probe.finish().print();
264
265 // 8. Cleanup (TestDb dropped here)
266 drop(mt);
267 drop(pool);
268 drop(test_db);
269 }
270
271 /// Seed the database with creators, projects, and items via HTTP endpoints.
272 /// Returns shared seed data for all VU scenarios.
273 async fn seed_data(app: &Router, pool: &PgPool) -> SeedData {
274 let mut usernames = Vec::new();
275 let mut project_slugs = Vec::new();
276 let mut item_ids = Vec::new();
277
278 for i in 0..5 {
279 let username = format!("seed_creator_{i}");
280 let slug = format!("seed-project-{i}");
281
282 let mut client = TestClient::new(app.clone());
283 // Unique IP per seed client to avoid rate limiting
284 client.set_forwarded_ip(&format!("192.168.{}.{}", i / 256, i % 256 + 1));
285
286 // Sign up
287 client.fetch_csrf_token().await;
288 let body =
289 format!("username={username}&email={username}%40seed.local&password=seedpass123");
290 let resp = client.post_form("/join/step/account", &body).await;
291 assert_eq!(
292 resp.status, 200,
293 "Seed signup failed for {}: {} {}",
294 username, resp.status, resp.text
295 );
296
297 // Grant creator via SQL
298 let user_id: uuid::Uuid = sqlx::query_scalar("SELECT id FROM users WHERE username = $1")
299 .bind(&username)
300 .fetch_one(pool)
301 .await
302 .expect("Seed user not found");
303
304 sqlx::query("UPDATE users SET can_create_projects = true WHERE id = $1")
305 .bind(user_id)
306 .execute(pool)
307 .await
308 .expect("Failed to grant creator to seed user");
309
310 // Re-login on a FRESH client rather than logging this one out. The
311 // signup left a live session whose SessionUser predates the grant
312 // above, and every way of reusing it depends on the auth path noticing
313 // the change: `authenticate` skips its touch query for
314 // SESSION_TOUCH_CACHE_SECS (5s) and this dance runs well inside that.
315 // A new cookie jar cannot carry a stale user.
316 let mut client = TestClient::new(app.clone());
317 client.set_forwarded_ip(&format!("192.168.{}.{}", i / 256, i % 256 + 1));
318 client.fetch_csrf_token().await;
319 let body = format!("login={username}&password=seedpass123");
320 let resp = client.post_form("/login", &body).await;
321 assert_eq!(
322 resp.status, 303,
323 "Seed login failed for {}: {} {}",
324 username, resp.status, resp.text
325 );
326
327 // Re-fetch the token: logging in rotates the session, and the token
328 // fetched before it is bound to the session that no longer exists. A
329 // rejected CSRF check answers 403 Forbidden with the same generic
330 // message an authorization failure uses, which is why this read for a
331 // long time as "the seed user is not a creator" — the flags were right
332 // the whole time.
333 client.fetch_csrf_token().await;
334
335 // Create project
336 let body = format!(
337 "slug={}&title=Seed+Project+{}",
338 urlencoding::encode(&slug),
339 i
340 );
341 let resp = client.post_form("/api/projects", &body).await;
342 assert_eq!(
343 resp.status, 200,
344 "Seed create project failed: {} {}",
345 resp.status, resp.text
346 );
347 let project: serde_json::Value = resp.json();
348 let project_id = project["id"].as_str().expect("project should have id");
349
350 // Make project public
351 client
352 .put_json(
353 &format!("/api/projects/{project_id}"),
354 r#"{"is_public": true}"#,
355 )
356 .await;
357
358 // Create 3 items per project
359 for j in 0..3 {
360 let item_body = format!("title=Seed+Item+{i}+{j}&price_cents=0&item_type=digital");
361 let resp = client
362 .post_form(&format!("/api/projects/{project_id}/items"), &item_body)
363 .await;
364 assert_eq!(
365 resp.status, 200,
366 "Seed create item failed: {} {}",
367 resp.status, resp.text
368 );
369 let item: serde_json::Value = resp.json();
370 let item_id = item["id"].as_str().expect("item should have id");
371
372 // Publish item
373 client
374 .put_form(&format!("/api/items/{item_id}"), "is_public=true")
375 .await;
376
377 item_ids.push(item_id.to_string());
378 }
379
380 usernames.push(username);
381 project_slugs.push(slug);
382 }
383
384 SeedData {
385 usernames,
386 project_slugs,
387 item_ids,
388 }
389 }
390