Skip to main content

max / makenotwork

11.7 KB · 414 lines History Blame Raw
1 //! Load test scenarios: realistic user journeys executed in a loop until deadline.
2
3 use crate::harness::client::TestClient;
4 use axum::Router;
5 use axum::http::StatusCode;
6 use std::sync::atomic::{AtomicU64, Ordering};
7 use std::time::{Duration, Instant};
8 use tokio::time::sleep;
9
10 use super::metrics::MetricsCollector;
11
12 /// Global counter for unique usernames across VUs.
13 static USER_COUNTER: AtomicU64 = AtomicU64::new(0);
14
15 /// Seed data created during setup, shared (read-only) across all VUs.
16 pub(super) struct SeedData {
17 pub usernames: Vec<String>,
18 pub project_slugs: Vec<String>,
19 pub item_ids: Vec<String>,
20 }
21
22 // Timed request helpers
23
24 async fn timed_get(
25 client: &mut TestClient,
26 uri: &str,
27 label: &str,
28 metrics: &MetricsCollector,
29 ) -> StatusCode {
30 let start = Instant::now();
31 let resp = client.get(uri).await;
32 metrics.record(label.to_string(), start.elapsed(), resp.status);
33 resp.status
34 }
35
36 async fn timed_post_form(
37 client: &mut TestClient,
38 uri: &str,
39 body: &str,
40 label: &str,
41 metrics: &MetricsCollector,
42 ) -> (StatusCode, String) {
43 let start = Instant::now();
44 let resp = client.post_form(uri, body).await;
45 metrics.record(label.to_string(), start.elapsed(), resp.status);
46 (resp.status, resp.text)
47 }
48
49 async fn timed_put_form(
50 client: &mut TestClient,
51 uri: &str,
52 body: &str,
53 label: &str,
54 metrics: &MetricsCollector,
55 ) -> (StatusCode, String) {
56 let start = Instant::now();
57 let resp = client.put_form(uri, body).await;
58 metrics.record(label.to_string(), start.elapsed(), resp.status);
59 (resp.status, resp.text)
60 }
61
62 async fn timed_put_json(
63 client: &mut TestClient,
64 uri: &str,
65 body: &str,
66 label: &str,
67 metrics: &MetricsCollector,
68 ) -> (StatusCode, String) {
69 let start = Instant::now();
70 let resp = client.put_json(uri, body).await;
71 metrics.record(label.to_string(), start.elapsed(), resp.status);
72 (resp.status, resp.text)
73 }
74
75 async fn timed_htmx_get(
76 client: &mut TestClient,
77 uri: &str,
78 label: &str,
79 metrics: &MetricsCollector,
80 ) -> StatusCode {
81 let start = Instant::now();
82 let resp = client.htmx_get(uri).await;
83 metrics.record(label.to_string(), start.elapsed(), resp.status);
84 resp.status
85 }
86
87 #[allow(dead_code)]
88 async fn timed_delete(
89 client: &mut TestClient,
90 uri: &str,
91 label: &str,
92 metrics: &MetricsCollector,
93 ) -> StatusCode {
94 let start = Instant::now();
95 let resp = client.delete(uri).await;
96 metrics.record(label.to_string(), start.elapsed(), resp.status);
97 resp.status
98 }
99
100 // Helpers
101
102 fn next_username(prefix: &str) -> String {
103 let n = USER_COUNTER.fetch_add(1, Ordering::Relaxed);
104 format!("{prefix}_{n}")
105 }
106
107 /// Sign up a new user via the app's /join endpoint. Returns true on success.
108 async fn signup(client: &mut TestClient, username: &str, metrics: &MetricsCollector) -> bool {
109 client.fetch_csrf_token().await;
110
111 let body = format!(
112 "username={}&email={}%40loadtest.local&password=loadtest123",
113 urlencoding::encode(username),
114 urlencoding::encode(username),
115 );
116 let (status, _) = timed_post_form(client, "/join", &body, "POST /join", metrics).await;
117 status.is_success() || status.is_redirection()
118 }
119
120 /// Log in as an existing user.
121 async fn login(client: &mut TestClient, username: &str, metrics: &MetricsCollector) -> bool {
122 client.fetch_csrf_token().await;
123
124 let body = format!(
125 "login={}&password=loadtest123",
126 urlencoding::encode(username),
127 );
128 let (status, _) = timed_post_form(client, "/login", &body, "POST /login", metrics).await;
129 status.is_success() || status.is_redirection()
130 }
131
132 // Scenarios
133
134 /// Anonymous browsing: no auth, cycles through public pages using seed data.
135 pub(super) async fn anonymous_browse(
136 app: Router,
137 ip: String,
138 deadline: Instant,
139 think_time: Duration,
140 metrics: MetricsCollector,
141 seed: &SeedData,
142 ) {
143 let mut client = TestClient::new(app);
144 client.set_forwarded_ip(&ip);
145 let mut cycle = 0usize;
146
147 while Instant::now() < deadline {
148 let u_idx = cycle % seed.usernames.len();
149 let p_idx = cycle % seed.project_slugs.len();
150 let i_idx = cycle % seed.item_ids.len();
151
152 timed_get(&mut client, "/", "GET /", &metrics).await;
153 sleep(think_time).await;
154
155 timed_get(&mut client, "/discover", "GET /discover", &metrics).await;
156 sleep(think_time).await;
157
158 timed_htmx_get(
159 &mut client,
160 "/discover/results",
161 "HTMX /discover/results",
162 &metrics,
163 )
164 .await;
165 sleep(think_time).await;
166
167 let user_url = format!("/u/{}", seed.usernames[u_idx]);
168 timed_get(&mut client, &user_url, "GET /u/{username}", &metrics).await;
169 sleep(think_time).await;
170
171 let proj_url = format!("/p/{}", seed.project_slugs[p_idx]);
172 timed_get(&mut client, &proj_url, "GET /p/{slug}", &metrics).await;
173 sleep(think_time).await;
174
175 let item_url = format!("/i/{}", seed.item_ids[i_idx]);
176 timed_get(&mut client, &item_url, "GET /i/{item_id}", &metrics).await;
177 sleep(think_time).await;
178
179 cycle += 1;
180 }
181 }
182
183 /// Buyer flow: signup, browse discover, add a free item to library.
184 pub(super) async fn buyer_flow(
185 app: Router,
186 ip: String,
187 deadline: Instant,
188 think_time: Duration,
189 metrics: MetricsCollector,
190 seed: &SeedData,
191 ) {
192 let mut cycle = 0usize;
193
194 while Instant::now() < deadline {
195 // Fresh client per cycle (new session)
196 let mut client = TestClient::new(app.clone());
197 client.set_forwarded_ip(&ip);
198
199 let username = next_username("buyer");
200 if !signup(&mut client, &username, &metrics).await {
201 sleep(think_time).await;
202 cycle += 1;
203 continue;
204 }
205 sleep(think_time).await;
206
207 timed_get(&mut client, "/discover", "GET /discover", &metrics).await;
208 sleep(think_time).await;
209
210 timed_htmx_get(
211 &mut client,
212 "/discover/results",
213 "HTMX /discover/results",
214 &metrics,
215 )
216 .await;
217 sleep(think_time).await;
218
219 let i_idx = cycle % seed.item_ids.len();
220 let item_url = format!("/i/{}", seed.item_ids[i_idx]);
221 timed_get(&mut client, &item_url, "GET /i/{item_id}", &metrics).await;
222 sleep(think_time).await;
223
224 let add_url = format!("/api/library/add/{}", seed.item_ids[i_idx]);
225 timed_post_form(&mut client, &add_url, "", "POST /api/library/add", &metrics).await;
226 sleep(think_time).await;
227
228 timed_get(&mut client, "/library", "GET /library", &metrics).await;
229 sleep(think_time).await;
230
231 cycle += 1;
232 }
233 }
234
235 /// Creator flow: signup, grant creator via SQL, create project + items, publish.
236 pub(super) async fn creator_flow(
237 app: Router,
238 ip: String,
239 deadline: Instant,
240 think_time: Duration,
241 metrics: MetricsCollector,
242 pool: sqlx::PgPool,
243 ) {
244 let mut cycle = 0usize;
245
246 while Instant::now() < deadline {
247 let mut client = TestClient::new(app.clone());
248 client.set_forwarded_ip(&ip);
249
250 let username = next_username("creator");
251 if !signup(&mut client, &username, &metrics).await {
252 sleep(think_time).await;
253 cycle += 1;
254 continue;
255 }
256
257 // Grant creator via SQL
258 let user_id: Option<uuid::Uuid> =
259 sqlx::query_scalar("SELECT id FROM users WHERE username = $1")
260 .bind(&username)
261 .fetch_optional(&pool)
262 .await
263 .ok()
264 .flatten();
265
266 let Some(user_id) = user_id else {
267 sleep(think_time).await;
268 cycle += 1;
269 continue;
270 };
271
272 let _ = sqlx::query("UPDATE users SET can_create_projects = true WHERE id = $1")
273 .bind(user_id)
274 .execute(&pool)
275 .await;
276
277 // Re-login to pick up creator permissions
278 timed_post_form(&mut client, "/logout", "", "POST /logout", &metrics).await;
279 sleep(think_time).await;
280
281 if !login(&mut client, &username, &metrics).await {
282 sleep(think_time).await;
283 cycle += 1;
284 continue;
285 }
286 sleep(think_time).await;
287
288 // Create project
289 let slug = format!("proj-{username}");
290 let body = format!(
291 "slug={}&title=Load+Test+Project",
292 urlencoding::encode(&slug)
293 );
294 let (status, text) = timed_post_form(
295 &mut client,
296 "/api/projects",
297 &body,
298 "POST /api/projects",
299 &metrics,
300 )
301 .await;
302
303 if !status.is_success() {
304 sleep(think_time).await;
305 cycle += 1;
306 continue;
307 }
308
309 let project_id = serde_json::from_str::<serde_json::Value>(&text)
310 .ok()
311 .and_then(|v| v["id"].as_str().map(String::from));
312
313 let Some(project_id) = project_id else {
314 sleep(think_time).await;
315 cycle += 1;
316 continue;
317 };
318 sleep(think_time).await;
319
320 // Create 3 items
321 let mut item_ids = Vec::new();
322 for i in 0..3 {
323 let item_body = format!("title=Item+{cycle}+{i}&price_cents=0&item_type=digital");
324 let (status, text) = timed_post_form(
325 &mut client,
326 &format!("/api/projects/{project_id}/items"),
327 &item_body,
328 "POST /api/projects/{id}/items",
329 &metrics,
330 )
331 .await;
332
333 if status.is_success()
334 && let Some(id) = serde_json::from_str::<serde_json::Value>(&text)
335 .ok()
336 .and_then(|v| v["id"].as_str().map(String::from))
337 {
338 item_ids.push(id);
339 }
340 sleep(think_time).await;
341 }
342
343 // Publish project
344 timed_put_json(
345 &mut client,
346 &format!("/api/projects/{project_id}"),
347 r#"{"is_public": true}"#,
348 "PUT /api/projects/{id}",
349 &metrics,
350 )
351 .await;
352 sleep(think_time).await;
353
354 // Publish items
355 for item_id in &item_ids {
356 timed_put_form(
357 &mut client,
358 &format!("/api/items/{item_id}"),
359 "is_public=true",
360 "PUT /api/items/{id}",
361 &metrics,
362 )
363 .await;
364 sleep(think_time).await;
365 }
366
367 // View dashboard
368 timed_get(&mut client, "/dashboard", "GET /dashboard", &metrics).await;
369 sleep(think_time).await;
370
371 cycle += 1;
372 }
373 }
374
375 /// Dashboard session: one-time signup, then loop through dashboard tabs.
376 pub(super) async fn dashboard_session(
377 app: Router,
378 ip: String,
379 deadline: Instant,
380 think_time: Duration,
381 metrics: MetricsCollector,
382 ) {
383 let mut client = TestClient::new(app);
384 client.set_forwarded_ip(&ip);
385
386 let username = next_username("dash");
387 if !signup(&mut client, &username, &metrics).await {
388 return;
389 }
390 sleep(think_time).await;
391
392 let tabs = ["details", "payments", "projects", "creator", "promotions"];
393
394 while Instant::now() < deadline {
395 timed_get(&mut client, "/dashboard", "GET /dashboard", &metrics).await;
396 sleep(think_time).await;
397
398 for tab in &tabs {
399 let url = format!("/dashboard/tabs/{tab}");
400 timed_htmx_get(&mut client, &url, "HTMX /dashboard/tabs/{tab}", &metrics).await;
401 sleep(think_time).await;
402 }
403
404 timed_get(
405 &mut client,
406 "/dashboard/transactions",
407 "GET /dashboard/transactions",
408 &metrics,
409 )
410 .await;
411 sleep(think_time).await;
412 }
413 }
414