Skip to main content

max / makenotwork

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