Skip to main content

max / makenotwork

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