Skip to main content

max / makenotwork

11.7 KB · 353 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 // The load runner drives thousands of requests from one IP, so the
72 // production limiter would measure the limiter rather than the server.
73 rate_limits: makenotwork::constants::RateLimits::relaxed(),
74 build: BuildConfig {
75 trigger_token: None,
76 host_linux: None,
77 host_darwin: None,
78 git_repos_path: None,
79 git_ssh_host: None,
80 },
81 email_webhooks: EmailWebhookConfig {
82 webhook_token: None,
83 broadcast_webhook_token: None,
84 inbound_webhook_token: None,
85 enforce_sender_auth: true,
86 },
87 creator_pricing: CreatorTierPricing {
88 fan_plus_price_id: None,
89 tier_prices: std::collections::HashMap::new(),
90 tier_annual_prices: std::collections::HashMap::new(),
91 tier_founder_prices: std::collections::HashMap::new(),
92 tier_founder_annual_prices: std::collections::HashMap::new(),
93 founder_window_open: false,
94 },
95 integrations: IntegrationsConfig {
96 mt_base_url: None,
97 wam_url: None,
98 internal_shared_secret: None,
99 cli_service_token: None,
100 alerts_ingest_token: None,
101 },
102 };
103
104 let email = EmailClient::new(
105 EmailConfig {
106 postmark_token: None,
107 from_address: "loadtest@makenot.work".to_string(),
108 from_name: "LoadTest".to_string(),
109 },
110 Some(pool.clone()),
111 );
112
113 let rp_origin = url::Url::parse(&app_config.host_url).expect("test HOST_URL");
114 let rp_id = rp_origin
115 .host_str()
116 .expect("test HOST_URL host")
117 .to_string();
118 let webauthn = Arc::new(
119 webauthn_rs::WebauthnBuilder::new(&rp_id, &rp_origin)
120 .expect("WebauthnBuilder")
121 .rp_name("LoadTest")
122 .build()
123 .expect("Webauthn"),
124 );
125
126 // Route through the shared `AppState::build` constructor (same as main.rs
127 // and the integration harness), derived in-memory state has one source.
128 let state = AppState::build(AppStateParts {
129 db: pool.clone(),
130 config: app_config,
131 storage: AppStorage {
132 s3: None,
133 synckit_s3: None,
134 public_s3: None,
135 },
136 stripe: None,
137 email,
138 docs: Arc::new(DocLoader::load(
139 std::path::Path::new("."),
140 &docengine::DocLoaderConfig {
141 sections: vec![],
142 link_prefix: "/docs".to_string(),
143 unpublished_pattern: None,
144 examples_path: None,
145 pre_process: None,
146 },
147 )),
148 tier_prices: {
149 // Install the process-global TierPrices so CreatorTier accessors
150 // work under the load harness (they read the global). Idempotent.
151 makenotwork::tier_prices::TierPrices::install_test_default();
152 makenotwork::tier_prices::TierPrices::global().clone()
153 },
154 runway_config: makenotwork::tier_prices::RunwayConfig::default(),
155 fee_calculator: makenotwork::fee_calculator::FeeCalculator::load(
156 "docs/business/assumptions.toml",
157 ),
158 scanner: None,
159 webauthn,
160 syntax: None,
161 mt_client: None,
162 wam: None,
163 domain_cache: Arc::new(dashmap::DashMap::new()),
164 metrics_handle: None,
165 page_view_tx: makenotwork::db::page_views::spawn_batcher(pool.clone()),
166 bg: makenotwork::background::spawn_pool_detached(),
167 });
168
169 let app = build_app(&state, session_layer);
170
171 // 4. Seed data
172 println!("Seeding test data...");
173 let seed = Arc::new(seed_data(&app, &pool).await);
174 println!(
175 " Seeded {} creators, {} projects, {} items",
176 seed.usernames.len(),
177 seed.project_slugs.len(),
178 seed.item_ids.len()
179 );
180
181 // 5. Spawn VUs
182 let metrics = MetricsCollector::new();
183 let ramp_delay = if config.virtual_users > 1 {
184 config.ramp_up / config.virtual_users
185 } else {
186 Duration::ZERO
187 };
188
189 let test_duration = config.duration;
190 let test_start = Instant::now();
191 let mut handles = Vec::new();
192
193 println!("Spawning {} virtual users...", config.virtual_users);
194
195 for vu in 0..config.virtual_users {
196 // Stagger VU start times
197 if vu > 0 {
198 tokio::time::sleep(ramp_delay).await;
199 }
200
201 let scenario = config
202 .scenario_mix
203 .assign_scenario(vu, config.virtual_users);
204 let ip = format!("10.0.{}.{}", vu / 256, vu % 256);
205 let deadline = test_start + test_duration;
206 let think_time = config.think_time;
207 let m = metrics.clone();
208 let a = app.clone();
209 let s = Arc::clone(&seed);
210 let p = pool.clone();
211
212 let handle = tokio::spawn(async move {
213 match scenario {
214 ScenarioType::AnonymousBrowse => {
215 scenarios::anonymous_browse(a, ip, deadline, think_time, m, &s).await;
216 }
217 ScenarioType::BuyerFlow => {
218 scenarios::buyer_flow(a, ip, deadline, think_time, m, &s).await;
219 }
220 ScenarioType::CreatorFlow => {
221 scenarios::creator_flow(a, ip, deadline, think_time, m, p).await;
222 }
223 ScenarioType::DashboardSession => {
224 scenarios::dashboard_session(a, ip, deadline, think_time, m).await;
225 }
226 }
227 });
228
229 handles.push((vu, scenario, handle));
230 }
231
232 // 6. Join all
233 println!("Running for {test_duration:?}...\n");
234 for (vu, scenario, handle) in handles {
235 if let Err(e) = handle.await {
236 eprintln!("VU {vu} ({scenario}) panicked: {e:?}");
237 }
238 }
239
240 // 7. Report
241 metrics.report().print();
242
243 // 8. Cleanup (TestDb dropped here)
244 drop(pool);
245 drop(test_db);
246 }
247
248 /// Seed the database with creators, projects, and items via HTTP endpoints.
249 /// Returns shared seed data for all VU scenarios.
250 async fn seed_data(app: &Router, pool: &PgPool) -> SeedData {
251 let mut usernames = Vec::new();
252 let mut project_slugs = Vec::new();
253 let mut item_ids = Vec::new();
254
255 for i in 0..5 {
256 let username = format!("seed_creator_{i}");
257 let slug = format!("seed-project-{i}");
258
259 let mut client = TestClient::new(app.clone());
260 // Unique IP per seed client to avoid rate limiting
261 client.set_forwarded_ip(&format!("192.168.{}.{}", i / 256, i % 256 + 1));
262
263 // Sign up
264 client.fetch_csrf_token().await;
265 let body =
266 format!("username={username}&email={username}%40seed.local&password=seedpass123");
267 let resp = client.post_form("/join/step/account", &body).await;
268 assert_eq!(
269 resp.status, 200,
270 "Seed signup failed for {}: {} {}",
271 username, resp.status, resp.text
272 );
273
274 // Grant creator via SQL
275 let user_id: uuid::Uuid = sqlx::query_scalar("SELECT id FROM users WHERE username = $1")
276 .bind(&username)
277 .fetch_one(pool)
278 .await
279 .expect("Seed user not found");
280
281 sqlx::query("UPDATE users SET can_create_projects = true WHERE id = $1")
282 .bind(user_id)
283 .execute(pool)
284 .await
285 .expect("Failed to grant creator to seed user");
286
287 // Re-login
288 client.post_form("/logout", "").await;
289 client.fetch_csrf_token().await;
290 let body = format!("login={username}&password=seedpass123");
291 let resp = client.post_form("/login", &body).await;
292 assert_eq!(
293 resp.status, 303,
294 "Seed login failed for {}: {} {}",
295 username, resp.status, resp.text
296 );
297
298 // Create project
299 let body = format!(
300 "slug={}&title=Seed+Project+{}",
301 urlencoding::encode(&slug),
302 i
303 );
304 let resp = client.post_form("/api/projects", &body).await;
305 assert_eq!(
306 resp.status, 200,
307 "Seed create project failed: {} {}",
308 resp.status, resp.text
309 );
310 let project: serde_json::Value = resp.json();
311 let project_id = project["id"].as_str().expect("project should have id");
312
313 // Make project public
314 client
315 .put_json(
316 &format!("/api/projects/{project_id}"),
317 r#"{"is_public": true}"#,
318 )
319 .await;
320
321 // Create 3 items per project
322 for j in 0..3 {
323 let item_body = format!("title=Seed+Item+{i}+{j}&price_cents=0&item_type=digital");
324 let resp = client
325 .post_form(&format!("/api/projects/{project_id}/items"), &item_body)
326 .await;
327 assert_eq!(
328 resp.status, 200,
329 "Seed create item failed: {} {}",
330 resp.status, resp.text
331 );
332 let item: serde_json::Value = resp.json();
333 let item_id = item["id"].as_str().expect("item should have id");
334
335 // Publish item
336 client
337 .put_form(&format!("/api/items/{item_id}"), "is_public=true")
338 .await;
339
340 item_ids.push(item_id.to_string());
341 }
342
343 usernames.push(username);
344 project_slugs.push(slug);
345 }
346
347 SeedData {
348 usernames,
349 project_slugs,
350 item_ids,
351 }
352 }
353