Skip to main content

max / makenotwork

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