Skip to main content

max / makenotwork

Pin the status every workflow test was only asserting the shape of is_success() passes on a 200 the handler never promised, and is_client_error() passes on the 404 you get when a route silently disappears, which is the regression the test existed to catch. 824 sites now name the code, leaving 6 that genuinely do not contract one, each with a comment saying why. The codes were measured, not guessed: a temporary track_caller probe stood in for each predicate, the suite ran, and every site whose observations agreed on one code was pinned to it. That found what the loose form was hiding. Three signup tests and the load harness POSTed /join, which is GET-only, so a 405 satisfied is_client_error() and none of them had ever reached the uniqueness check. Those now post to /join/step/account.
Author: Max Johnson <me@maxj.phd> · 2026-08-07 00:20 UTC
Signed with PGP, not checked
Commit: 7a10abb0928e80a53f9c9945d220e7c6e260d983
Parent: beda9e8
80 files changed, +1737 insertions, -2441 deletions
@@ -458,7 +458,9 @@
458 458
459 459 `assumptions.rs`, `migration_hygiene.rs`, `frontend_globals.rs`, `test_hygiene.rs`, and `workflows/enum_drift.rs` are ratchets: they read the repo (the TOML corpus, the migration set, `static/*.js`, the test suite itself, the live schema) and fail when a convention is broken or a `HIGH_WATER` count rises. Add one whenever a rule is worth enforcing but too tedious to catch in review, and write the failure message so it names the number to set.
460 460
461 - `test_hygiene.rs` enforces the conventions below. Two of its rules are hard (every workflow module has a `//!` header; every `#[ignore]` carries a reason) and three are ratchets over counts that are too large to fix in one sweep: loose status assertions, `test_`-prefixed names, and modules over 800 lines. Lower a `HIGH_WATER` when you clean a file up; the failure message names the number.
461 + `test_hygiene.rs` enforces the conventions below. Two of its rules are hard (every workflow module has a `//!` header; every `#[ignore]` carries a reason) and three are ratchets over counts: loose status assertions, `test_`-prefixed names, and modules over 800 lines. Lower a `HIGH_WATER` when you clean a file up; the failure message names the number.
462 +
463 + The loose-status count went 830 to 6 on 2026-08-06, so that one is close to a hard rule now. The method is worth reusing on the other two: rather than reason about what each site should assert, a temporary `#[track_caller]` probe stood in for the predicate, the suite ran once, and every site whose observations agreed on a single status was pinned to it. Measuring beats guessing at that volume, and the sites left over are exactly the ones that needed a human.
462 464
463 465 ### Conventions
464 466
@@ -18,18 +18,32 @@
18 18 use std::fs;
19 19 use std::path::{Path, PathBuf};
20 20
21 - /// Loose status assertions in `tests/workflows/`: 830 on 2026-08-03.
21 + /// Loose status assertions in `tests/workflows/`: 6 on 2026-08-06, down from 830.
22 22 ///
23 23 /// `assert!(resp.status.is_success())` passes on a 200 when the handler promised
24 24 /// 201, and `is_client_error()` passes on the 404 you get when a route silently
25 25 /// disappears, which is exactly the regression the test existed to catch. The
26 26 /// exact form, `assert_eq!(resp.status, 403)`, pins the contract.
27 27 ///
28 - /// Some sites genuinely do not contract a code (a helper that only cares the call
29 - /// worked). Those stay loose and stay counted: the number is a budget, not an
30 - /// accusation, and a file that legitimately keeps a few loose assertions simply
31 - /// stops contributing new ones.
32 - const LOOSE_STATUS_HIGH_WATER: usize = 830;
28 + /// The 824 that went were converted to the code the handler actually returns,
29 + /// measured rather than guessed: a temporary `#[track_caller]` probe replaced
30 + /// each predicate, the suite ran, and every site whose observations agreed on one
31 + /// code was pinned to it. That is also how the compound conditions fell. Half of
32 + /// `resp.status.is_redirection() || resp.status.is_success()` is dead the moment
33 + /// the handler settles on one, and every checkout POST carrying that line settles
34 + /// on 303.
35 + ///
36 + /// It also caught what the loose form was there to hide. Three signup tests and
37 + /// the load harness POSTed `/join`, which is GET-only, so a 405 was satisfying
38 + /// `is_client_error()` and none of them had ever reached the uniqueness check.
39 + /// Those are repointed at `/join/step/account`.
40 + ///
41 + /// The 6 left are the genuinely uncontracted ones, and each carries a comment
42 + /// saying why: three branch on the status rather than assert it, one ranges over
43 + /// cases that answer with different codes, and two are login paths that render a
44 + /// 200 page with the error in the body, where the status is not where the
45 + /// contract lives.
46 + const LOOSE_STATUS_HIGH_WATER: usize = 6;
33 47
34 48 /// `test_`-prefixed test functions across `src/` and `tests/`: 176 on 2026-08-03.
35 49 ///
@@ -39,12 +53,14 @@
39 53 /// stragglers.
40 54 const TEST_PREFIX_HIGH_WATER: usize = 176;
41 55
42 - /// Workflow modules over [`MAX_MODULE_LINES`]: 11 on 2026-08-03.
56 + /// Workflow modules over [`MAX_MODULE_LINES`]: 10 on 2026-08-06, was 11 until
57 + /// pinning the status assertions collapsed the wrapped `assert!`s in one of them
58 + /// back onto a single line.
43 59 ///
44 60 /// Past this a module stops being findable: nobody locates the existing test for
45 61 /// a behavior, so they write a second one beside it. The fix is to split by
46 62 /// domain, which is why `tests/workflows/` has 123 files rather than 12.
47 - const OVERSIZED_MODULE_HIGH_WATER: usize = 11;
63 + const OVERSIZED_MODULE_HIGH_WATER: usize = 10;
48 64
49 65 /// The point at which a workflow module should have been split.
50 66 const MAX_MODULE_LINES: usize = 800;
@@ -113,7 +113,17 @@
113 113 urlencoding::encode(username),
114 114 urlencoding::encode(username),
115 115 );
116 - let (status, _) = timed_post_form(client, "/join", &body, "POST /join", metrics).await;
116 + // `/join` is the GET-only wizard page; the form posts to the account step.
117 + // Aimed at `/join` this returned 405 and every creator scenario gave up on
118 + // its first call, so the load numbers were measuring a rejected request.
119 + let (status, _) = timed_post_form(
120 + client,
121 + "/join/step/account",
122 + &body,
123 + "POST /join/step/account",
124 + metrics,
125 + )
126 + .await;
117 127 status.is_success() || status.is_redirection()
118 128 }
119 129
@@ -17,11 +17,10 @@
17 17 .client
18 18 .put_form("/api/users/me", "display_name=New+Name&bio=Hello+world")
19 19 .await;
20 - assert!(
21 - resp.status.is_success(),
20 + assert_eq!(
21 + resp.status, 200,
22 22 "Update profile failed: {} {}",
23 - resp.status,
24 - resp.text
23 + resp.status, resp.text
25 24 );
26 25
27 26 // Non-HTMX form request returns JSON ProfileResponse
@@ -46,11 +45,10 @@
46 45 "current_password=oldpassword1&new_password=newpassword1",
47 46 )
48 47 .await;
49 - assert!(
50 - resp.status.is_success(),
48 + assert_eq!(
49 + resp.status, 204,
51 50 "Change password failed: {} {}",
52 - resp.status,
53 - resp.text
51 + resp.status, resp.text
54 52 );
55 53
56 54 h.client.post_form("/logout", "").await;
@@ -109,11 +107,10 @@
109 107 let path = url.strip_prefix("").unwrap_or(&url);
110 108
111 109 let resp = h.client.get(path).await;
112 - assert!(
113 - resp.status.is_success(),
110 + assert_eq!(
111 + resp.status, 200,
114 112 "Verify email failed: {} {}",
115 - resp.status,
116 - resp.text
113 + resp.status, resp.text
117 114 );
118 115 assert!(
119 116 resp.text.contains("Email Verified"),
@@ -194,11 +191,10 @@
194 191
195 192 // Use the login link
196 193 let resp = h.client.get(&format!("/login-link?token={token}")).await;
197 - assert!(
198 - resp.status.is_success() || resp.status.is_redirection(),
194 + assert_eq!(
195 + resp.status, 303,
199 196 "Login link failed: {} {}",
200 - resp.status,
201 - resp.text
197 + resp.status, resp.text
202 198 );
203 199
204 200 // Verify we're logged in
@@ -226,11 +222,10 @@
226 222
227 223 // Resend verification, should succeed (email logged in dev mode)
228 224 let resp = h.client.post_form("/api/resend-verification", "").await;
229 - assert!(
230 - resp.status.is_success(),
225 + assert_eq!(
226 + resp.status, 200,
231 227 "Resend verification failed: {} {}",
232 - resp.status,
233 - resp.text
228 + resp.status, resp.text
234 229 );
235 230
236 231 // Now verify the email directly in DB
@@ -242,10 +237,9 @@
242 237
243 238 // Resend again, should return "already verified" info (still 200, not an error)
244 239 let resp = h.client.post_form("/api/resend-verification", "").await;
245 - assert!(
246 - resp.status.is_success(),
240 + assert_eq!(
241 + resp.status, 200,
247 242 "Resend when verified should still succeed: {} {}",
248 - resp.status,
249 - resp.text
243 + resp.status, resp.text
250 244 );
251 245 }
@@ -30,22 +30,17 @@
30 30 "title=Last+Item&price_cents=0",
31 31 )
32 32 .await;
33 - assert!(
34 - resp.status.is_success(),
35 - "Create item failed: {}",
36 - resp.text
37 - );
33 + assert_eq!(resp.status, 200, "Create item failed: {}", resp.text);
38 34
39 35 // Request account deletion, this sends an email in dev mode (logged)
40 36 let resp = h
41 37 .client
42 38 .post_form("/api/account/request-deletion", "username=doomed")
43 39 .await;
44 - assert!(
45 - resp.status.is_success(),
40 + assert_eq!(
41 + resp.status, 200,
46 42 "Request deletion failed: {} {}",
47 - resp.status,
48 - resp.text
43 + resp.status, resp.text
49 44 );
50 45
51 46 // Delete the user via the two-step confirm-delete flow.
@@ -63,11 +58,10 @@
63 58
64 59 // Step 1: GET renders the confirmation page (no deletion yet)
65 60 let resp = h.client.get(&confirm_url).await;
66 - assert!(
67 - resp.status.is_success(),
61 + assert_eq!(
62 + resp.status, 200,
68 63 "Confirm delete page failed: {} {}",
69 - resp.status,
70 - resp.text
64 + resp.status, resp.text
71 65 );
72 66 assert!(
73 67 resp.text.contains("Delete My Account Permanently"),
@@ -77,11 +71,10 @@
77 71 // Step 2: POST performs the actual deletion
78 72 let form_body = format!("user={user_id}&expires={expires}&sig={sig}");
79 73 let resp = h.client.post_form("/confirm-delete", &form_body).await;
80 - assert!(
81 - resp.status.is_success(),
74 + assert_eq!(
75 + resp.status, 200,
82 76 "Confirm delete POST failed: {} {}",
83 - resp.status,
84 - resp.text
77 + resp.status, resp.text
85 78 );
86 79
87 80 // Verify cascade: user, projects, and items should all be gone
@@ -110,11 +110,10 @@
110 110 "reason=Violated+terms+of+service",
111 111 )
112 112 .await;
113 - assert!(
114 - resp.status.is_success(),
113 + assert_eq!(
114 + resp.status, 200,
115 115 "Admin suspend failed: {} {}",
116 - resp.status,
117 - resp.text
116 + resp.status, resp.text
118 117 );
119 118
120 119 // Verify in DB
@@ -144,11 +143,10 @@
144 143 .client
145 144 .post_form(&format!("/api/admin/users/{}/suspend", *user_id), "reason=")
146 145 .await;
147 - assert!(
148 - resp.status.is_client_error(),
146 + assert_eq!(
147 + resp.status, 422,
149 148 "Empty reason should be rejected: {} {}",
150 - resp.status,
151 - resp.text
149 + resp.status, resp.text
152 150 );
153 151 }
154 152
@@ -168,11 +166,10 @@
168 166 .client
169 167 .post_form(&format!("/api/admin/users/{}/unsuspend", *user_id), "")
170 168 .await;
171 - assert!(
172 - resp.status.is_success(),
169 + assert_eq!(
170 + resp.status, 200,
173 171 "Admin unsuspend failed: {} {}",
174 - resp.status,
175 - resp.text
172 + resp.status, resp.text
176 173 );
177 174
178 175 // Verify cleared
@@ -200,11 +197,10 @@
200 197 .client
201 198 .post_form(&format!("/api/admin/users/{}/trust", *user_id), "")
202 199 .await;
203 - assert!(
204 - resp.status.is_success(),
200 + assert_eq!(
201 + resp.status, 200,
205 202 "Admin trust failed: {} {}",
206 - resp.status,
207 - resp.text
203 + resp.status, resp.text
208 204 );
209 205
210 206 let trusted: bool = sqlx::query_scalar("SELECT upload_trusted FROM users WHERE id = $1")
@@ -231,11 +227,10 @@
231 227 .client
232 228 .post_form(&format!("/api/admin/users/{}/untrust", *user_id), "")
233 229 .await;
234 - assert!(
235 - resp.status.is_success(),
230 + assert_eq!(
231 + resp.status, 200,
236 232 "Admin untrust failed: {} {}",
237 - resp.status,
238 - resp.text
233 + resp.status, resp.text
239 234 );
240 235
241 236 let trusted: bool = sqlx::query_scalar("SELECT upload_trusted FROM users WHERE id = $1")
@@ -301,11 +296,10 @@
301 296 .client
302 297 .post_form(&format!("/api/admin/uploads/items/{item_id}/promote"), "")
303 298 .await;
304 - assert!(
305 - resp.status.is_success(),
299 + assert_eq!(
300 + resp.status, 200,
306 301 "Approve item failed: {} {}",
307 - resp.status,
308 - resp.text
302 + resp.status, resp.text
309 303 );
310 304
311 305 let status: String = sqlx::query_scalar("SELECT scan_status FROM items WHERE id = $1")
@@ -336,11 +330,10 @@
336 330 "",
337 331 )
338 332 .await;
339 - assert!(
340 - resp.status.is_success(),
333 + assert_eq!(
334 + resp.status, 200,
341 335 "Reject item failed: {} {}",
342 - resp.status,
343 - resp.text
336 + resp.status, resp.text
344 337 );
345 338
346 339 let status: String = sqlx::query_scalar("SELECT scan_status FROM items WHERE id = $1")
@@ -372,11 +365,10 @@
372 365 "",
373 366 )
374 367 .await;
375 - assert!(
376 - resp.status.is_success(),
368 + assert_eq!(
369 + resp.status, 200,
377 370 "Approve version failed: {} {}",
378 - resp.status,
379 - resp.text
371 + resp.status, resp.text
380 372 );
381 373
382 374 let status: String = sqlx::query_scalar("SELECT scan_status FROM versions WHERE id = $1")
@@ -408,11 +400,10 @@
408 400 "",
409 401 )
410 402 .await;
411 - assert!(
412 - resp.status.is_success(),
403 + assert_eq!(
404 + resp.status, 200,
413 405 "Reject version failed: {} {}",
414 - resp.status,
415 - resp.text
406 + resp.status, resp.text
416 407 );
417 408
418 409 let status: String = sqlx::query_scalar("SELECT scan_status FROM versions WHERE id = $1")
@@ -444,11 +435,10 @@
444 435 "appeal_text=I+believe+this+was+a+mistake",
445 436 )
446 437 .await;
447 - assert!(
448 - resp.status.is_success() || resp.status == 204,
438 + assert_eq!(
439 + resp.status, 204,
449 440 "Appeal submission failed: {} {}",
450 - resp.status,
451 - resp.text
441 + resp.status, resp.text
452 442 );
453 443
454 444 // Admin decides
@@ -461,11 +451,10 @@
461 451 "decision=approved&response=Suspension+was+a+mistake",
462 452 )
463 453 .await;
464 - assert!(
465 - resp.status.is_success(),
454 + assert_eq!(
455 + resp.status, 200,
466 456 "Admin approve appeal failed: {} {}",
467 - resp.status,
468 - resp.text
457 + resp.status, resp.text
469 458 );
470 459
471 460 // Verify: user is unsuspended and appeal decision recorded
@@ -512,11 +501,10 @@
512 501 "decision=denied&response=Violation+confirmed",
513 502 )
514 503 .await;
515 - assert!(
516 - resp.status.is_success(),
504 + assert_eq!(
505 + resp.status, 200,
517 506 "Admin deny appeal failed: {} {}",
518 - resp.status,
519 - resp.text
507 + resp.status, resp.text
520 508 );
521 509
522 510 // Verify: user stays suspended, decision recorded
@@ -560,11 +548,10 @@
560 548 "decision=approved&response=",
561 549 )
562 550 .await;
563 - assert!(
564 - resp.status.is_client_error(),
551 + assert_eq!(
552 + resp.status, 422,
565 553 "Empty response should be rejected: {} {}",
566 - resp.status,
567 - resp.text
554 + resp.status, resp.text
568 555 );
569 556 }
570 557
@@ -22,11 +22,7 @@
22 22 .client
23 23 .post_form("/api/projects", "slug=victim-shop&title=Victim+Shop")
24 24 .await;
25 - assert!(
26 - resp.status.is_success(),
27 - "Create project failed: {}",
28 - resp.text
29 - );
25 + assert_eq!(resp.status, 200, "Create project failed: {}", resp.text);
30 26 let project: Value = resp.json();
31 27 let project_id = project["id"].as_str().unwrap().to_string();
32 28
@@ -38,11 +34,7 @@
38 34 "title=Secret+Item&item_type=digital&price_cents=1000",
39 35 )
40 36 .await;
41 - assert!(
42 - resp.status.is_success(),
43 - "Create item failed: {}",
44 - resp.text
45 - );
37 + assert_eq!(resp.status, 200, "Create item failed: {}", resp.text);
46 38 let item: Value = resp.json();
47 39 let item_id = item["id"].as_str().unwrap().to_string();
48 40
@@ -65,11 +57,7 @@
65 57 r#"{"title": "Draft Post"}"#,
66 58 )
67 59 .await;
68 - assert!(
69 - resp.status.is_success(),
70 - "Create blog post failed: {}",
71 - resp.text
72 - );
60 + assert_eq!(resp.status, 200, "Create blog post failed: {}", resp.text);
73 61 let post: Value = resp.json();
74 62 let post_id = post["id"].as_str().unwrap().to_string();
75 63
@@ -338,8 +326,8 @@
338 326 .client
339 327 .post_form("/api/projects", "slug=my-project&title=My+Project")
340 328 .await;
341 - assert!(
342 - resp.status.is_success(),
329 + assert_eq!(
330 + resp.status, 200,
343 331 "Project creation should work: {}",
344 332 resp.text
345 333 );
@@ -354,11 +342,7 @@
354 342 "title=My+Item&item_type=digital&price_cents=500",
355 343 )
356 344 .await;
357 - assert!(
358 - resp.status.is_success(),
359 - "Item creation should work: {}",
360 - resp.text
361 - );
345 + assert_eq!(resp.status, 200, "Item creation should work: {}", resp.text);
362 346 let item: Value = resp.json();
363 347 let item_id = item["id"].as_str().unwrap();
364 348
@@ -423,7 +407,7 @@
423 407
424 408 // List attacker's projects, should be empty (attacker has none)
425 409 let resp = h.client.get("/api/projects").await;
426 - assert!(resp.status.is_success());
410 + assert_eq!(resp.status, 200, "{}", resp.text);
427 411 let list: Value = resp.json();
428 412 let data = list["data"].as_array().unwrap();
429 413 assert!(
@@ -434,7 +418,7 @@
434 418
435 419 // List attacker's promo codes, should be empty
436 420 let resp = h.client.get("/api/promo-codes").await;
437 - assert!(resp.status.is_success());
421 + assert_eq!(resp.status, 200, "{}", resp.text);
438 422 let list: Value = resp.json();
439 423 let data = list["data"].as_array().unwrap();
440 424 assert!(
@@ -22,10 +22,7 @@
22 22
23 23 // Verify we can access the API while logged in
24 24 let resp = h.client.get("/api/projects").await;
25 - assert!(
26 - resp.status.is_success(),
27 - "Should have API access while logged in"
28 - );
25 + assert_eq!(resp.status, 200, "Should have API access while logged in");
29 26
30 27 h.client.post_form("/logout", "").await;
31 28
@@ -132,6 +129,9 @@
132 129 let resp = h.failed_login_attempt("lockuser", "wrongpassword").await;
133 130 // First 4 attempts: "Invalid username/email or password"
134 131 // 5th attempt: triggers lockout
132 + // Stays loose on purpose: a rejected login re-renders the login page at
133 + // 200 with the reason in the body, so the status carries no contract
134 + // here and the text arms are what actually assert.
135 135 assert!(
136 136 resp.status.is_client_error()
137 137 || resp.text.contains("Invalid")
@@ -145,6 +145,7 @@
145 145
146 146 // Now try with the CORRECT password, should still be locked
147 147 let resp = h.failed_login_attempt("lockuser", "correctpass1").await;
148 + // Loose for the same reason as the loop above: lockout renders at 200.
148 149 assert!(
149 150 resp.status.is_client_error()
150 151 || resp.text.contains("locked")
@@ -232,7 +233,7 @@
232 233
233 234 // Enable TOTP
234 235 let resp = h.client.post_form("/api/users/me/totp/setup", "").await;
235 - assert!(resp.status.is_success(), "TOTP setup failed: {}", resp.text);
236 + assert_eq!(resp.status, 200, "TOTP setup failed: {}", resp.text);
236 237
237 238 // Extract secret from HTML response (inside <details> > <code>)
238 239 let details_start = resp
@@ -268,11 +269,10 @@
268 269 .client
269 270 .post_form("/api/users/me/totp/confirm", &format!("code={code}"))
270 271 .await;
271 - assert!(
272 - resp.status.is_success(),
272 + assert_eq!(
273 + resp.status, 200,
273 274 "TOTP confirm failed: {} {}",
274 - resp.status,
275 - resp.text
275 + resp.status, resp.text
276 276 );
277 277
278 278 h.client.post_form("/logout", "").await;
@@ -280,14 +280,10 @@
280 280 // Login with correct password, should redirect to 2FA page (not complete login)
281 281 let resp = h.failed_login_attempt("twofa", "password123").await;
282 282 // The response should indicate 2FA is needed (redirect to /auth/2fa or /auth/verify-2fa)
283 - assert!(
284 - resp.status.is_redirection()
285 - || resp.status.is_success()
286 - || resp.text.contains("2fa")
287 - || resp.text.contains("verify"),
283 + assert_eq!(
284 + resp.status, 303,
288 285 "Login with TOTP enabled should require 2FA: {} {}",
289 - resp.status,
290 - resp.text
286 + resp.status, resp.text
291 287 );
292 288
293 289 // NOW try to access protected API endpoints, should fail because
@@ -339,11 +335,10 @@
339 335
340 336 // First use, should succeed
341 337 let resp = h.client.get(&format!("/login-link?token={token}")).await;
342 - assert!(
343 - resp.status.is_success() || resp.status.is_redirection(),
338 + assert_eq!(
339 + resp.status, 303,
344 340 "First login link use should succeed: {} {}",
345 - resp.status,
346 - resp.text
341 + resp.status, resp.text
347 342 );
348 343
349 344 // Verify we're logged in
@@ -356,6 +351,8 @@
356 351 let resp = h.client.get(&format!("/login-link?token={token}")).await;
357 352 // Second use should fail, token was consumed
358 353 // Could be 400, 401, redirect to login, or error page
354 + // Not an assertion: this reads the status to build a condition, and the
355 + // path it takes today is the 200 error page, so there is no code to pin.
359 356 let is_rejected = resp.status.is_client_error()
360 357 || resp.text.contains("invalid")
361 358 || resp.text.contains("Invalid")
@@ -473,11 +470,10 @@
473 470 .client
474 471 .post_form("/login", "login=emptytest&password=")
475 472 .await;
476 - assert!(
477 - resp.status.is_client_error() || resp.text.contains("Invalid"),
473 + assert_eq!(
474 + resp.status, 403,
478 475 "Empty password should be rejected: {} {}",
479 - resp.status,
480 - resp.text
476 + resp.status, resp.text
481 477 );
482 478
483 479 // Empty username
@@ -485,20 +481,18 @@
485 481 .client
486 482 .post_form("/login", "login=&password=password123")
487 483 .await;
488 - assert!(
489 - resp.status.is_client_error() || resp.text.contains("Invalid"),
484 + assert_eq!(
485 + resp.status, 403,
490 486 "Empty username should be rejected: {} {}",
491 - resp.status,
492 - resp.text
487 + resp.status, resp.text
493 488 );
494 489
495 490 // Both empty
496 491 let resp = h.client.post_form("/login", "login=&password=").await;
497 - assert!(
498 - resp.status.is_client_error() || resp.text.contains("Invalid"),
492 + assert_eq!(
493 + resp.status, 403,
499 494 "Both empty should be rejected: {} {}",
500 - resp.status,
501 - resp.text
495 + resp.status, resp.text
502 496 );
503 497
504 498 // Verify no session was created
@@ -583,11 +577,7 @@
583 577 "current_password=oldpassword1&new_password=newpassword1",
584 578 )
585 579 .await;
586 - assert!(
587 - resp.status.is_success(),
588 - "Password change failed: {}",
589 - resp.text
590 - );
580 + assert_eq!(resp.status, 204, "Password change failed: {}", resp.text);
591 581
592 582 h.client.post_form("/logout", "").await;
593 583
@@ -596,11 +586,10 @@
596 586 .client
597 587 .post_form("/login", "login=oldpw&password=oldpassword1")
598 588 .await;
599 - assert!(
600 - resp.status.is_client_error() || resp.text.contains("Invalid"),
589 + assert_eq!(
590 + resp.status, 403,
601 591 "Old password should be rejected after change: {} {}",
602 - resp.status,
603 - resp.text
592 + resp.status, resp.text
604 593 );
605 594
606 595 // Verify not logged in
@@ -643,11 +632,10 @@
643 632 let resp = other
644 633 .post_form("/login", "login=multisess&password=oldpassword1")
645 634 .await;
646 - assert!(
647 - resp.status.is_success() || resp.status.is_redirection(),
635 + assert_eq!(
636 + resp.status, 303,
648 637 "session B login failed: {} {}",
649 - resp.status,
650 - resp.text
638 + resp.status, resp.text
651 639 );
652 640 let resp = other.get("/dashboard").await;
653 641 assert_eq!(
@@ -663,11 +651,7 @@
663 651 "current_password=oldpassword1&new_password=newpassword1",
664 652 )
665 653 .await;
666 - assert!(
667 - resp.status.is_success(),
668 - "password change failed: {}",
669 - resp.text
670 - );
654 + assert_eq!(resp.status, 204, "password change failed: {}", resp.text);
671 655
672 656 // Session B is now revoked (cookie no longer resolves to a live session).
673 657 let resp = other.get("/dashboard").await;
@@ -25,11 +25,7 @@
25 25 "title=Free+Item&item_type=digital&price_cents=0",
26 26 )
27 27 .await;
28 - assert!(
29 - resp.status.is_success(),
30 - "Create free item failed: {}",
31 - resp.text
32 - );
28 + assert_eq!(resp.status, 200, "Create free item failed: {}", resp.text);
33 29 let free: Value = resp.json();
34 30 let free_item_id = free["id"].as_str().unwrap().to_string();
35 31
@@ -76,11 +72,10 @@
76 72 .client
77 73 .post_form(&format!("/api/library/add/{free_item_id}"), "")
78 74 .await;
79 - assert!(
80 - resp.status.is_success(),
75 + assert_eq!(
76 + resp.status, 200,
81 77 "Adding own free item to library should work: {} {}",
82 - resp.status,
83 - resp.text
78 + resp.status, resp.text
84 79 );
85 80
86 81 // Adding again should be idempotent (no error, not double-counted)
@@ -88,11 +83,10 @@
88 83 .client
89 84 .post_form(&format!("/api/library/add/{free_item_id}"), "")
90 85 .await;
91 - assert!(
92 - resp.status.is_success(),
86 + assert_eq!(
87 + resp.status, 200,
93 88 "Duplicate add should not error: {} {}",
94 - resp.status,
95 - resp.text
89 + resp.status, resp.text
96 90 );
97 91 }
98 92
@@ -146,11 +140,10 @@
146 140 .client
147 141 .post_form(&format!("/stripe/checkout/{item_id}"), "")
148 142 .await;
149 - assert!(
150 - resp.status.is_client_error(),
143 + assert_eq!(
144 + resp.status, 400,
151 145 "Draft item checkout should be rejected: {} {}",
152 - resp.status,
153 - resp.text
146 + resp.status, resp.text
154 147 );
155 148 }
156 149
@@ -197,11 +190,10 @@
197 190 .client
198 191 .post_form(&format!("/api/library/add/{item_id}"), "")
199 192 .await;
200 - assert!(
201 - resp.status.is_client_error(),
193 + assert_eq!(
194 + resp.status, 404,
202 195 "Draft free item should not be claimable: {} {}",
203 - resp.status,
204 - resp.text
196 + resp.status, resp.text
205 197 );
206 198 }
207 199
@@ -247,11 +239,10 @@
247 239 .client
248 240 .post_form(&format!("/api/library/add/{paid_item_id}"), "")
249 241 .await;
250 - assert!(
251 - resp.status.is_client_error(),
242 + assert_eq!(
243 + resp.status, 400,
252 244 "Paid item should not be claimable via library-add: {} {}",
253 - resp.status,
254 - resp.text
245 + resp.status, resp.text
255 246 );
256 247 }
257 248
@@ -273,11 +264,7 @@
273 264 "code=FREE100&code_purpose=discount&discount_type=percentage&discount_value=100",
274 265 )
275 266 .await;
276 - assert!(
277 - resp.status.is_success(),
278 - "Create promo code failed: {}",
279 - resp.text
280 - );
267 + assert_eq!(resp.status, 200, "Create promo code failed: {}", resp.text);
281 268
282 269 // Switch to buyer
283 270 h.client.post_form("/logout", "").await;
@@ -293,11 +280,10 @@
293 280 "promo_code=FREE100",
294 281 )
295 282 .await;
296 - assert!(
297 - resp.status.is_redirection() || resp.status.is_success(),
283 + assert_eq!(
284 + resp.status, 303,
298 285 "First purchase should succeed: {} {}",
299 - resp.status,
300 - resp.text
286 + resp.status, resp.text
301 287 );
302 288
303 289 // Second purchase attempt → should redirect to item page (already owned)
@@ -334,11 +320,7 @@
334 320 "code=STEALME&code_purpose=discount&discount_type=percentage&discount_value=100",
335 321 )
336 322 .await;
337 - assert!(
338 - resp.status.is_success(),
339 - "Create promo code failed: {}",
340 - resp.text
341 - );
323 + assert_eq!(resp.status, 200, "Create promo code failed: {}", resp.text);
342 324
343 325 // Seller B creates a published paid item
344 326 h.client.post_form("/logout", "").await;
@@ -408,7 +390,7 @@
408 390 "title=Other+Item&item_type=digital&price_cents=500",
409 391 )
410 392 .await;
411 - assert!(resp.status.is_success());
393 + assert_eq!(resp.status, 200, "{}", resp.text);
412 394 let other: Value = resp.json();
413 395 let other_item_id = other["id"].as_str().unwrap();
414 396 h.client
@@ -425,11 +407,7 @@
425 407 ),
426 408 )
427 409 .await;
428 - assert!(
429 - resp.status.is_success(),
430 - "Create scoped code failed: {}",
431 - resp.text
432 - );
410 + assert_eq!(resp.status, 200, "Create scoped code failed: {}", resp.text);
433 411
434 412 // Buyer uses code on the SECOND item
435 413 h.client.post_form("/logout", "").await;
@@ -469,7 +447,7 @@
469 447 .client
470 448 .post_form("/api/projects", "slug=proj1-shop&title=Proj1")
471 449 .await;
472 - assert!(resp.status.is_success());
450 + assert_eq!(resp.status, 200, "{}", resp.text);
473 451 let p1: Value = resp.json();
474 452 let project1_id = p1["id"].as_str().unwrap().to_string();
475 453
@@ -478,7 +456,7 @@
478 456 .client
479 457 .post_form("/api/projects", "slug=proj2-shop&title=Proj2")
480 458 .await;
481 - assert!(resp.status.is_success());
459 + assert_eq!(resp.status, 200, "{}", resp.text);
482 460 let p2: Value = resp.json();
483 461 let project2_id = p2["id"].as_str().unwrap();
484 462
@@ -489,7 +467,7 @@
489 467 "title=P2+Item&item_type=digital&price_cents=800",
490 468 )
491 469 .await;
492 - assert!(resp.status.is_success());
470 + assert_eq!(resp.status, 200, "{}", resp.text);
493 471 let item2: Value = resp.json();
494 472 let item2_id = item2["id"].as_str().unwrap();
495 473
@@ -510,8 +488,8 @@
510 488 ),
511 489 )
512 490 .await;
513 - assert!(
514 - resp.status.is_success(),
491 + assert_eq!(
492 + resp.status, 200,
515 493 "Create project-scoped code failed: {}",
516 494 resp.text
517 495 );
@@ -585,11 +563,7 @@
585 563 &format!("code_purpose=free_access&item_id={item_id}&max_uses=1"),
586 564 )
587 565 .await;
588 - assert!(
589 - resp.status.is_success(),
590 - "Create code failed: {}",
591 - resp.text
592 - );
566 + assert_eq!(resp.status, 200, "Create code failed: {}", resp.text);
593 567 let code: Value = resp.json();
594 568 let key_code = code["code"].as_str().unwrap().to_string();
595 569
@@ -603,11 +577,10 @@
603 577 .client
604 578 .post_form("/api/promo-codes/claim", &format!("code={key_code}"))
605 579 .await;
606 - assert!(
607 - resp.status.is_success(),
580 + assert_eq!(
581 + resp.status, 200,
608 582 "First claim should succeed: {} {}",
609 - resp.status,
610 - resp.text
583 + resp.status, resp.text
611 584 );
612 585
613 586 // Buyer 2 tries to claim, should be rejected (max_uses exhausted)
@@ -658,11 +631,7 @@
658 631 "title=PWYW+Item&item_type=digital&price_cents=1000&pwyw_enabled=true&pwyw_min_cents=500",
659 632 )
660 633 .await;
661 - assert!(
662 - resp.status.is_success(),
663 - "Create PWYW item failed: {}",
664 - resp.text
665 - );
634 + assert_eq!(resp.status, 200, "Create PWYW item failed: {}", resp.text);
666 635 let item: Value = resp.json();
667 636 let item_id = item["id"].as_str().unwrap();
668 637
@@ -717,11 +686,7 @@
717 686 "title=PWYW+Item2&item_type=digital&price_cents=1000&pwyw_enabled=true&pwyw_min_cents=500",
718 687 )
719 688 .await;
720 - assert!(
721 - resp.status.is_success(),
722 - "Create PWYW item failed: {}",
723 - resp.text
724 - );
689 + assert_eq!(resp.status, 200, "Create PWYW item failed: {}", resp.text);
725 690 let item: Value = resp.json();
726 691 let item_id = item["id"].as_str().unwrap();
727 692
@@ -781,7 +746,7 @@
781 746 "title=PWYW+Disc&item_type=digital&price_cents=1000&pwyw_enabled=true&pwyw_min_cents=0",
782 747 )
783 748 .await;
784 - assert!(resp.status.is_success());
749 + assert_eq!(resp.status, 200, "{}", resp.text);
785 750 let item: Value = resp.json();
786 751 let item_id = item["id"].as_str().unwrap();
787 752
@@ -803,7 +768,7 @@
803 768 "code=FULL100&code_purpose=discount&discount_type=percentage&discount_value=100",
804 769 )
805 770 .await;
806 - assert!(resp.status.is_success());
771 + assert_eq!(resp.status, 200, "{}", resp.text);
807 772
808 773 // Buyer chooses $50 PWYW, but 100% discount on $10 list price → $0 → free claim
809 774 h.client.post_form("/logout", "").await;
@@ -819,10 +784,9 @@
819 784 )
820 785 .await;
821 786 // Should succeed via free-claim path (redirect to /library)
822 - assert!(
823 - resp.status.is_redirection() || resp.status.is_success(),
787 + assert_eq!(
788 + resp.status, 303,
824 789 "100% discount should trigger free claim: {} {}",
825 - resp.status,
826 - resp.text
790 + resp.status, resp.text
827 791 );
828 792 }
@@ -34,11 +34,10 @@
34 34 // Malformed UUIDs (URI-safe characters only)
35 35 for bad_id in &["not-a-uuid", "12345", "0000-bad-format"] {
36 36 let resp = h.client.get(&format!("/api/items/{bad_id}/versions")).await;
37 - assert!(
38 - resp.status.is_client_error() || resp.status == 404 || resp.status == 405,
37 + assert_eq!(
38 + resp.status, 400,
39 39 "Invalid UUID '{}' should not cause 500, got: {}",
40 - bad_id,
41 - resp.status
40 + bad_id, resp.status
42 41 );
43 42 }
44 43
@@ -70,11 +69,10 @@
70 69 "title=Evil+Item&item_type=digital&price_cents=-100",
71 70 )
72 71 .await;
73 - assert!(
74 - resp.status.is_client_error(),
72 + assert_eq!(
73 + resp.status, 422,
75 74 "Negative price_cents should be rejected: {} {}",
76 - resp.status,
77 - resp.text
75 + resp.status, resp.text
78 76 );
79 77 }
80 78
@@ -92,11 +90,10 @@
92 90 "title=Expensive&item_type=digital&price_cents=2000000000",
93 91 )
94 92 .await;
95 - assert!(
96 - resp.status.is_client_error(),
93 + assert_eq!(
94 + resp.status, 422,
97 95 "Price > $10,000 should be rejected: {} {}",
98 - resp.status,
99 - resp.text
96 + resp.status, resp.text
100 97 );
101 98 }
102 99
@@ -114,11 +111,10 @@
114 111 "title=Free+Item&item_type=digital&price_cents=0",
115 112 )
116 113 .await;
117 - assert!(
118 - resp.status.is_success(),
114 + assert_eq!(
115 + resp.status, 200,
119 116 "Zero price (free item) should be accepted: {} {}",
120 - resp.status,
121 - resp.text
117 + resp.status, resp.text
122 118 );
123 119 }
124 120
@@ -139,11 +135,10 @@
139 135 &format!("title={long_title}&item_type=digital"),
140 136 )
141 137 .await;
142 - assert!(
143 - resp.status.is_client_error(),
138 + assert_eq!(
139 + resp.status, 422,
144 140 "201-char title should be rejected: {} {}",
145 - resp.status,
146 - resp.text
141 + resp.status, resp.text
147 142 );
148 143 }
149 144
@@ -162,11 +157,10 @@
162 157 &format!("title=Normal&item_type=digital&description={long_desc}"),
163 158 )
164 159 .await;
165 - assert!(
166 - resp.status.is_client_error(),
160 + assert_eq!(
161 + resp.status, 422,
167 162 "5001-char description should be rejected: {} {}",
168 - resp.status,
169 - resp.text
163 + resp.status, resp.text
170 164 );
171 165 }
172 166
@@ -184,11 +178,10 @@
184 178 "title=&item_type=digital",
185 179 )
186 180 .await;
187 - assert!(
188 - resp.status.is_client_error(),
181 + assert_eq!(
182 + resp.status, 422,
189 183 "Empty title should be rejected: {} {}",
190 - resp.status,
191 - resp.text
184 + resp.status, resp.text
192 185 );
193 186 }
194 187
@@ -211,11 +204,10 @@
211 204 &format!("title={xss_title}&item_type=digital"),
212 205 )
213 206 .await;
214 - assert!(
215 - resp.status.is_success(),
207 + assert_eq!(
208 + resp.status, 200,
216 209 "XSS title should be accepted as content: {} {}",
217 - resp.status,
218 - resp.text
210 + resp.status, resp.text
219 211 );
220 212 let item: Value = resp.json();
221 213 assert_eq!(
@@ -240,11 +232,10 @@
240 232 &format!("title={sqli_title}&item_type=digital"),
241 233 )
242 234 .await;
243 - assert!(
244 - resp.status.is_success(),
235 + assert_eq!(
236 + resp.status, 200,
245 237 "SQL injection payload should be stored as literal text: {} {}",
246 - resp.status,
247 - resp.text
238 + resp.status, resp.text
248 239 );
249 240 let item: Value = resp.json();
250 241 assert_eq!(
@@ -255,8 +246,8 @@
255 246
256 247 // Verify the items table still works (not dropped)
257 248 let resp = h.client.get("/api/projects").await;
258 - assert!(
259 - resp.status.is_success(),
249 + assert_eq!(
250 + resp.status, 200,
260 251 "Projects list should still work after SQL injection attempt"
261 252 );
262 253 }
@@ -278,11 +269,10 @@
278 269 .client
279 270 .post_form("/api/projects", "slug=inputtest-proj&title=Duplicate+Shop")
280 271 .await;
281 - assert!(
282 - resp.status.is_success(),
272 + assert_eq!(
273 + resp.status, 200,
283 274 "Duplicate slug should auto-suffix, not fail: {} {}",
284 - resp.status,
285 - resp.text
275 + resp.status, resp.text
286 276 );
287 277 let second: serde_json::Value = resp.json();
288 278 assert_eq!(
@@ -298,6 +288,11 @@
298 288
299 289 /// Vulnerability tested: Duplicate username on signup.
300 290 /// Second signup with same username should be rejected.
291 + ///
292 + /// The `auth.rs` sibling asserts what the user is told; this one asserts what
293 + /// the table holds, which is the part an attacker cares about. Both spent their
294 + /// lives POSTing GET-only `/join` and passing on the 405, so neither had ever
295 + /// reached the uniqueness check.
301 296 #[tokio::test]
302 297 async fn duplicate_username_rejected() {
303 298 let mut h = TestHarness::new().await;
@@ -305,24 +300,31 @@
305 300 h.client.post_form("/logout", "").await;
306 301
307 302 // Try signing up again with the same username but different email
303 + h.client.fetch_csrf_token().await;
308 304 let resp = h
309 305 .client
310 306 .post_form(
311 - "/join",
312 - "username=dupuser&email=dup2@test.com&password=password456&confirm_password=password456",
307 + "/join/step/account",
308 + "username=dupuser&email=dup2@test.com&password=password456",
313 309 )
314 310 .await;
315 - // Should fail, check we get an error, not a second account
316 - // The response could be a redirect back to join with error or a 400/409
317 - let is_error = !resp.status.is_success()
318 - || resp.text.contains("taken")
319 - || resp.text.contains("already")
320 - || resp.text.contains("exists")
321 - || resp.status.is_redirection();
322 - assert!(
323 - is_error,
324 - "Duplicate username should be rejected: {} {}",
325 - resp.status, resp.text
311 + assert_eq!(resp.status, 200, "{}", resp.text);
312 +
313 + let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM users WHERE username = 'dupuser'")
314 + .fetch_one(&h.db)
315 + .await
316 + .unwrap();
317 + assert_eq!(
318 + count, 1,
319 + "the duplicate signup must not create a second row"
320 + );
321 + let stole: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM users WHERE email = 'dup2@test.com'")
322 + .fetch_one(&h.db)
323 + .await
324 + .unwrap();
325 + assert_eq!(
326 + stole, 0,
327 + "and must not create an account under the new email"
326 328 );
327 329 }
328 330
@@ -345,11 +347,10 @@
345 347 .client
346 348 .post_form("/api/projects", "slug=a&title=One+Char")
347 349 .await;
348 - assert!(
349 - !resp.status.is_success(),
350 + assert_eq!(
351 + resp.status, 422,
350 352 "1-char slug should be rejected: {} {}",
351 - resp.status,
352 - resp.text
353 + resp.status, resp.text
353 354 );
354 355
355 356 // 2-char slug, should be accepted (boundary)
@@ -357,11 +358,10 @@
357 358 .client
358 359 .post_form("/api/projects", "slug=ab&title=Two+Char")
359 360 .await;
360 - assert!(
361 - resp.status.is_success(),
361 + assert_eq!(
362 + resp.status, 200,
362 363 "2-char slug should be accepted: {} {}",
363 - resp.status,
364 - resp.text
364 + resp.status, resp.text
365 365 );
366 366
367 367 // 100-char slug, should be accepted (boundary)
@@ -370,11 +370,10 @@
370 370 .client
371 371 .post_form("/api/projects", &format!("slug={slug_100}&title=Max+Slug"))
372 372 .await;
373 - assert!(
374 - resp.status.is_success(),
373 + assert_eq!(
374 + resp.status, 200,
375 375 "100-char slug should be accepted: {} {}",
376 - resp.status,
377 - resp.text
376 + resp.status, resp.text
378 377 );
379 378
380 379 // 101-char slug, should be rejected (over max)
@@ -383,11 +382,10 @@
383 382 .client
384 383 .post_form("/api/projects", &format!("slug={slug_101}&title=Over+Max"))
385 384 .await;
386 - assert!(
387 - !resp.status.is_success(),
385 + assert_eq!(
386 + resp.status, 422,
388 387 "101-char slug should be rejected: {} {}",
389 - resp.status,
390 - resp.text
388 + resp.status, resp.text
391 389 );
392 390
393 391 // Slug with special characters, should be rejected
@@ -395,11 +393,10 @@
395 393 .client
396 394 .post_form("/api/projects", "slug=my_shop!&title=Special+Chars")
397 395 .await;
398 - assert!(
399 - !resp.status.is_success(),
396 + assert_eq!(
397 + resp.status, 422,
400 398 "Slug with special chars should be rejected: {} {}",
401 - resp.status,
402 - resp.text
399 + resp.status, resp.text
403 400 );
404 401 }
405 402
@@ -422,11 +419,10 @@
422 419 &format!("title={cjk_200}&item_type=digital"),
423 420 )
424 421 .await;
425 - assert!(
426 - resp.status.is_success(),
422 + assert_eq!(
423 + resp.status, 200,
427 424 "200 CJK chars should be accepted (chars.count() not bytes): {} {}",
428 - resp.status,
429 - resp.text
425 + resp.status, resp.text
430 426 );
431 427
432 428 // 201 CJK characters, should be rejected
@@ -438,11 +434,10 @@
438 434 &format!("title={cjk_201}&item_type=digital"),
439 435 )
440 436 .await;
441 - assert!(
442 - resp.status.is_client_error(),
437 + assert_eq!(
438 + resp.status, 422,
443 439 "201 CJK chars should be rejected: {} {}",
444 - resp.status,
445 - resp.text
440 + resp.status, resp.text
446 441 );
447 442 }
448 443
@@ -461,11 +456,10 @@
461 456 "pwyw_enabled=on&pwyw_min_cents=-500",
462 457 )
463 458 .await;
464 - assert!(
465 - resp.status.is_client_error(),
459 + assert_eq!(
460 + resp.status, 422,
466 461 "Negative pwyw_min_cents should be rejected: {} {}",
467 - resp.status,
468 - resp.text
462 + resp.status, resp.text
469 463 );
470 464 }
471 465
@@ -482,11 +476,10 @@
482 476 "code=BIGDISCOUNT&code_purpose=discount&discount_type=fixed&discount_value=99999999",
483 477 )
484 478 .await;
485 - assert!(
486 - resp.status.is_client_error(),
479 + assert_eq!(
480 + resp.status, 400,
487 481 "Fixed discount above MAX_PRICE_CENTS should be rejected: {} {}",
488 - resp.status,
489 - resp.text
482 + resp.status, resp.text
490 483 );
491 484 }
492 485
@@ -510,11 +503,10 @@
510 503 "title=Evil+Link&url=javascript:alert(document.cookie)",
511 504 )
512 505 .await;
513 - assert!(
514 - !resp.status.is_success(),
506 + assert_eq!(
507 + resp.status, 422,
515 508 "javascript: scheme should be rejected: {} {}",
516 - resp.status,
517 - resp.text
509 + resp.status, resp.text
518 510 );
519 511
520 512 // data: scheme, another XSS vector
@@ -525,10 +517,9 @@
525 517 "title=Data+Link&url=data:text/html,<script>alert(1)</script>",
526 518 )
527 519 .await;
528 - assert!(
529 - !resp.status.is_success(),
520 + assert_eq!(
521 + resp.status, 422,
530 522 "data: scheme should be rejected: {} {}",
531 - resp.status,
532 - resp.text
523 + resp.status, resp.text
533 524 );
534 525 }
@@ -13,11 +13,10 @@
13 13 .client
14 14 .post_form("/api/users/me/appeal", "appeal_text=Please+unsuspend+me")
15 15 .await;
16 - assert!(
17 - resp.status.is_client_error() || resp.status.is_redirection(),
16 + assert_eq!(
17 + resp.status, 403,
18 18 "Unauthenticated appeal should be rejected: {} {}",
19 - resp.status,
20 - resp.text
19 + resp.status, resp.text
21 20 );
22 21 }
23 22
@@ -32,11 +31,10 @@
32 31 .client
33 32 .post_form("/api/users/me/appeal", "appeal_text=I+want+to+appeal")
34 33 .await;
35 - assert!(
36 - resp.status.is_client_error(),
34 + assert_eq!(
35 + resp.status, 400,
37 36 "Non-suspended user appeal should be rejected: {} {}",
38 - resp.status,
39 - resp.text
37 + resp.status, resp.text
40 38 );
41 39 }
42 40
@@ -55,11 +53,10 @@
55 53 .client
56 54 .post_form("/api/users/me/appeal", "appeal_text=")
57 55 .await;
58 - assert!(
59 - resp.status.is_client_error(),
56 + assert_eq!(
57 + resp.status, 422,
60 58 "Empty appeal text should be rejected: {} {}",
61 - resp.status,
62 - resp.text
59 + resp.status, resp.text
63 60 );
64 61 }
65 62
@@ -78,11 +75,10 @@
78 75 .client
79 76 .post_form("/api/users/me/appeal", &format!("appeal_text={long_text}"))
80 77 .await;
81 - assert!(
82 - resp.status.is_client_error(),
78 + assert_eq!(
79 + resp.status, 422,
83 80 "Appeal text >2000 chars should be rejected: {} {}",
84 - resp.status,
85 - resp.text
81 + resp.status, resp.text
86 82 );
87 83 }
88 84
@@ -103,10 +99,9 @@
103 99 "appeal_text=I+believe+this+was+a+mistake",
104 100 )
105 101 .await;
106 - assert!(
107 - resp.status.is_success() || resp.status == 204,
102 + assert_eq!(
103 + resp.status, 204,
108 104 "Suspended user appeal should succeed: {} {}",
109 - resp.status,
110 - resp.text
105 + resp.status, resp.text
111 106 );
112 107 }
@@ -50,10 +50,7 @@
50 50 );
51 51
52 52 let resp = h.client.post_form("/logout", "").await;
53 - assert!(
54 - resp.status.is_success() || resp.status.is_redirection(),
55 - "Logout should succeed"
56 - );
53 + assert_eq!(resp.status, 303, "Logout should succeed");
57 54
58 55 // Dashboard should now redirect (302) or return 401
59 56 let resp = h.client.get("/dashboard").await;
@@ -80,8 +77,8 @@
80 77 let body = "username=intruder&email=owner@example.com&password=password123";
81 78 let resp = h.client.post_form("/join/step/account", body).await;
82 79
83 - assert!(
84 - resp.status.is_success(),
80 + assert_eq!(
81 + resp.status, 200,
85 82 "taken-email signup should not error: {}",
86 83 resp.status
87 84 );
@@ -167,32 +164,76 @@
167 164 );
168 165 }
169 166
167 + /// A duplicate email must answer exactly like a fresh signup.
168 + ///
169 + /// Saying "this email is already registered" turns the signup form into an
170 + /// account-existence oracle for an identifier the owner did not make public, so
171 + /// `join_wizard::step_account_create` returns the same step-2 partial a fresh
172 + /// signup gets and tells the real owner out of band instead. `WizardJoinProfileTemplate`
173 + /// carries nothing but the step nav, which is why the two responses can be
174 + /// compared byte for byte rather than probed for the absence of a phrase.
175 + ///
176 + /// This test spent its life POSTing `/join`, which is GET-only, and passing on
177 + /// the 405. Named `duplicate_email_rejected` then, which is the opposite of the
178 + /// behavior the handler is careful to have.
170 179 #[tokio::test]
171 - async fn duplicate_email_rejected() {
180 + async fn duplicate_email_answers_like_a_fresh_signup() {
172 181 let mut h = TestHarness::new().await;
173 182 let _user_id = h
174 183 .signup("orig_user", "dupe@example.com", "password123")
175 184 .await;
176 185 h.client.post_form("/logout", "").await;
177 186
178 - // Attempt signup with the same email but a different username
187 + // The baseline: a signup with no collision at all.
188 + h.client.fetch_csrf_token().await;
189 + let fresh = h
190 + .client
191 + .post_form(
192 + "/join/step/account",
193 + "username=fresh_user&email=fresh@example.com&password=password123",
194 + )
195 + .await;
196 + assert_eq!(fresh.status, 200, "{}", fresh.text);
197 + // Pin what the baseline IS, so the comparison below cannot be satisfied by
198 + // two identical failures.
199 + assert!(
200 + fresh.text.contains(r#"hx-post="/join/step/profile""#),
201 + "a clean signup should advance to the profile step: {}",
202 + fresh.text
203 + );
204 + h.client.post_form("/logout", "").await;
205 +
206 + // Same request, but the email is taken.
207 + h.client.fetch_csrf_token().await;
179 208 let resp = h
180 209 .client
181 210 .post_form(
182 - "/join",
183 - "username=other_user&email=dupe@example.com&password=password123&password_confirm=password123",
211 + "/join/step/account",
212 + "username=other_user&email=dupe@example.com&password=password123",
184 213 )
185 214 .await;
186 - assert!(
187 - resp.status.is_client_error()
188 - || resp.text.contains("already")
189 - || resp.text.contains("taken"),
190 - "Duplicate email signup should fail: {} {}",
191 - resp.status,
192 - resp.text
215 + assert_eq!(resp.status, 200, "{}", resp.text);
216 + assert_eq!(
217 + resp.text, fresh.text,
218 + "a taken email must be indistinguishable from a free one",
193 219 );
220 +
221 + // The collision is concealed, not ignored: no second account exists.
222 + let count: i64 =
223 + sqlx::query_scalar("SELECT COUNT(*) FROM users WHERE email = 'dupe@example.com'")
224 + .fetch_one(&h.db)
225 + .await
226 + .unwrap();
227 + assert_eq!(count, 1, "the duplicate signup must not create an account");
194 228 }
195 229
230 + /// A duplicate username is revealed, unlike a duplicate email: usernames are
231 + /// public handles that appear in profile URLs, and the user has to be told to
232 + /// pick another one. Re-renders the account step at 200 with the field flagged.
233 + ///
234 + /// Was POSTing GET-only `/join` and passing on the 405. `adversarial_input.rs`
235 + /// has a sibling covering the same route from the "did it create a second row"
236 + /// angle.
196 237 #[tokio::test]
197 238 async fn duplicate_username_rejected() {
198 239 let mut h = TestHarness::new().await;
@@ -201,22 +242,27 @@
201 242 .await;
202 243 h.client.post_form("/logout", "").await;
203 244
204 - // Attempt signup with the same username but a different email
245 + h.client.fetch_csrf_token().await;
205 246 let resp = h
206 247 .client
207 248 .post_form(
208 - "/join",
209 - "username=taken_name&email=second@example.com&password=password123&password_confirm=password123",
249 + "/join/step/account",
250 + "username=taken_name&email=second@example.com&password=password123",
210 251 )
211 252 .await;
253 + assert_eq!(resp.status, 200, "{}", resp.text);
212 254 assert!(
213 - resp.status.is_client_error()
214 - || resp.text.contains("already")
215 - || resp.text.contains("taken"),
216 - "Duplicate username signup should fail: {} {}",
217 - resp.status,
255 + resp.text.contains("already taken"),
256 + "the account step must come back saying the username is taken: {}",
218 257 resp.text
219 258 );
259 +
260 + let count: i64 =
261 + sqlx::query_scalar("SELECT COUNT(*) FROM users WHERE email = 'second@example.com'")
262 + .fetch_one(&h.db)
263 + .await
264 + .unwrap();
265 + assert_eq!(count, 0, "the rejected signup must not create an account");
220 266 }
221 267
222 268 #[tokio::test]
@@ -252,11 +298,10 @@
252 298 "current_password=oldpass123&new_password=newpass456",
253 299 )
254 300 .await;
255 - assert!(
256 - resp.status.is_success(),
301 + assert_eq!(
302 + resp.status, 204,
257 303 "Password change should succeed: {} {}",
258 - resp.status,
259 - resp.text
304 + resp.status, resp.text
260 305 );
261 306
262 307 h.client.post_form("/logout", "").await;
@@ -38,11 +38,10 @@
38 38 r#"{"title": "First Post", "body_markdown": "Hello **world**!", "is_published": false}"#,
39 39 )
40 40 .await;
41 - assert!(
42 - resp.status.is_success(),
41 + assert_eq!(
42 + resp.status, 200,
43 43 "Create blog post failed: {} {}",
44 - resp.status,
45 - resp.text
44 + resp.status, resp.text
46 45 );
47 46 let post: Value = resp.json();
48 47 let post_id = post["id"].as_str().unwrap();
@@ -58,11 +57,10 @@
58 57 ),
59 58 )
60 59 .await;
61 - assert!(
62 - resp.status.is_success(),
60 + assert_eq!(
61 + resp.status, 200,
63 62 "Publish blog post failed: {} {}",
64 - resp.status,
65 - resp.text
63 + resp.status, resp.text
66 64 );
67 65
68 66 // Check blog page is accessible
@@ -117,11 +115,10 @@
117 115 r#"{"title": "Draft Post", "body_markdown": "Initial body", "is_published": false}"#,
118 116 )
119 117 .await;
120 - assert!(
121 - resp.status.is_success(),
118 + assert_eq!(
119 + resp.status, 200,
122 120 "Create draft failed: {} {}",
123 - resp.status,
124 - resp.text
121 + resp.status, resp.text
125 122 );
126 123 let post: Value = resp.json();
127 124 let post_id = post["id"].as_str().unwrap();
@@ -130,11 +127,10 @@
130 127
131 128 // Read it back via GET /api/blog/{id}
132 129 let resp = h.client.get(&format!("/api/blog/{post_id}")).await;
133 - assert!(
134 - resp.status.is_success(),
130 + assert_eq!(
131 + resp.status, 200,
135 132 "Get blog post failed: {} {}",
136 - resp.status,
137 - resp.text
133 + resp.status, resp.text
138 134 );
139 135 let fetched: Value = resp.json();
140 136 assert_eq!(fetched["title"].as_str(), Some("Draft Post"));
@@ -151,11 +147,10 @@
151 147 ),
152 148 )
153 149 .await;
154 - assert!(
155 - resp.status.is_success(),
150 + assert_eq!(
151 + resp.status, 200,
156 152 "Update blog post failed: {} {}",
157 - resp.status,
158 - resp.text
153 + resp.status, resp.text
159 154 );
160 155
161 156 // Verify changes persisted
@@ -177,11 +172,10 @@
177 172 ),
178 173 )
179 174 .await;
180 - assert!(
181 - resp.status.is_success(),
175 + assert_eq!(
176 + resp.status, 200,
182 177 "Publish blog post failed: {} {}",
183 - resp.status,
184 - resp.text
178 + resp.status, resp.text
185 179 );
186 180
187 181 // Verify is_published
@@ -200,11 +194,10 @@
200 194 ),
201 195 )
202 196 .await;
203 - assert!(
204 - resp.status.is_success(),
197 + assert_eq!(
198 + resp.status, 200,
205 199 "Auto-save failed: {} {}",
206 - resp.status,
207 - resp.text
200 + resp.status, resp.text
208 201 );
209 202 let resp = h.client.get(&format!("/api/blog/{post_id}")).await;
210 203 let fetched: Value = resp.json();
@@ -228,11 +221,10 @@
228 221 ),
229 222 )
230 223 .await;
231 - assert!(
232 - resp.status.is_success(),
224 + assert_eq!(
225 + resp.status, 200,
233 226 "Explicit unpublish failed: {} {}",
234 - resp.status,
235 - resp.text
227 + resp.status, resp.text
236 228 );
237 229 let resp = h.client.get(&format!("/api/blog/{post_id}")).await;
238 230 let fetched: Value = resp.json();
@@ -243,11 +235,10 @@
243 235 );
244 236
245 237 let resp = h.client.delete(&format!("/api/blog/{post_id}")).await;
246 - assert!(
247 - resp.status.is_success(),
238 + assert_eq!(
239 + resp.status, 200,
248 240 "Delete blog post failed: {} {}",
249 - resp.status,
250 - resp.text
241 + resp.status, resp.text
251 242 );
252 243
253 244 // Verify 404 after deletion
@@ -281,11 +272,7 @@
281 272 r#"{"title": "Owner Post", "body_markdown": "Secret content", "is_published": false}"#,
282 273 )
283 274 .await;
284 - assert!(
285 - resp.status.is_success(),
286 - "Create post failed: {}",
287 - resp.text
288 - );
275 + assert_eq!(resp.status, 200, "Create post failed: {}", resp.text);
289 276 let post: Value = resp.json();
290 277 let post_id = post["id"].as_str().unwrap();
291 278 let post_slug = post["slug"].as_str().unwrap();
@@ -359,11 +346,7 @@
359 346 r#"{"title": "Public Post", "body_markdown": "Visible to all", "is_published": true}"#,
360 347 )
361 348 .await;
362 - assert!(
363 - resp.status.is_success(),
364 - "Create public post failed: {}",
365 - resp.text
366 - );
349 + assert_eq!(resp.status, 200, "Create public post failed: {}", resp.text);
367 350 let public_post: Value = resp.json();
368 351 let public_slug = public_post["slug"].as_str().unwrap();
369 352
@@ -375,22 +358,14 @@
375 358 r#"{"title": "Draft Post", "body_markdown": "Hidden from public", "is_published": false}"#,
376 359 )
377 360 .await;
378 - assert!(
379 - resp.status.is_success(),
380 - "Create draft post failed: {}",
381 - resp.text
382 - );
361 + assert_eq!(resp.status, 200, "Create draft post failed: {}", resp.text);
383 362
384 363 // List via API (public endpoint) should show only the published post
385 364 let resp = h
386 365 .client
387 366 .get(&format!("/api/projects/{project_id}/blog"))
388 367 .await;
389 - assert!(
390 - resp.status.is_success(),
391 - "List blog posts failed: {}",
392 - resp.text
393 - );
368 + assert_eq!(resp.status, 200, "List blog posts failed: {}", resp.text);
394 369 let list: Value = resp.json();
395 370 let posts = list["data"].as_array().unwrap();
396 371 assert_eq!(
@@ -456,8 +431,8 @@
456 431 r#"{"title": "Off-topic note", "body_markdown": "Body", "is_published": true, "show_on_landing": true}"#,
457 432 )
458 433 .await;
459 - assert!(
460 - resp.status.is_success(),
434 + assert_eq!(
435 + resp.status, 200,
461 436 "Create flagged non-changelog post failed: {}",
462 437 resp.text
463 438 );
@@ -496,8 +471,8 @@
496 471 r#"{"title": "Shipped gallery widget", "body_markdown": "Body", "is_published": true, "show_on_landing": true}"#,
497 472 )
498 473 .await;
499 - assert!(
500 - resp.status.is_success(),
474 + assert_eq!(
475 + resp.status, 200,
501 476 "Create flagged changelog post failed: {}",
502 477 resp.text
503 478 );
@@ -16,11 +16,10 @@
16 16 "subject=Hello&body=Test+broadcast+message",
17 17 )
18 18 .await;
19 - assert!(
20 - resp.status.is_client_error() || resp.status.is_redirection(),
19 + assert_eq!(
20 + resp.status, 403,
21 21 "Unauthenticated broadcast should be rejected: {} {}",
22 - resp.status,
23 - resp.text
22 + resp.status, resp.text
24 23 );
25 24 }
26 25
@@ -103,11 +102,10 @@
103 102 "subject=Test+Broadcast&body=Hello+followers+this+is+a+test",
104 103 )
105 104 .await;
106 - assert!(
107 - resp.status.is_success(),
105 + assert_eq!(
106 + resp.status, 200,
108 107 "Creator broadcast should succeed: {} {}",
109 - resp.status,
110 - resp.text
108 + resp.status, resp.text
111 109 );
112 110 // Success response should contain "form-status success", not error
113 111 assert!(
@@ -13,11 +13,7 @@
13 13 .client
14 14 .post_form("/api/projects", "slug=bundle-proj&title=Bundle+Project")
15 15 .await;
16 - assert!(
17 - resp.status.is_success(),
18 - "Create project failed: {}",
19 - resp.text
20 - );
16 + assert_eq!(resp.status, 200, "Create project failed: {}", resp.text);
21 17 let project: Value = resp.json();
22 18 let project_id = project["id"].as_str().unwrap().to_string();
23 19
@@ -29,11 +25,7 @@
29 25 "title=My+Bundle&item_type=bundle&price_cents=1999",
30 26 )
31 27 .await;
32 - assert!(
33 - resp.status.is_success(),
34 - "Create bundle failed: {}",
35 - resp.text
36 - );
28 + assert_eq!(resp.status, 200, "Create bundle failed: {}", resp.text);
37 29 let bundle: Value = resp.json();
38 30 let bundle_id = bundle["id"].as_str().unwrap().to_string();
39 31
@@ -45,11 +37,7 @@
45 37 "title=Child+Item&item_type=digital&price_cents=0",
46 38 )
47 39 .await;
48 - assert!(
49 - resp.status.is_success(),
50 - "Create child item failed: {}",
51 - resp.text
52 - );
40 + assert_eq!(resp.status, 200, "Create child item failed: {}", resp.text);
53 41 let child: Value = resp.json();
54 42 let child_id = child["id"].as_str().unwrap().to_string();
55 43
@@ -70,11 +58,10 @@
70 58 &json!({"item_id": child_id}).to_string(),
71 59 )
72 60 .await;
73 - assert!(
74 - resp.status.is_success(),
61 + assert_eq!(
62 + resp.status, 200,
75 63 "Bundle add failed: {} {}",
76 - resp.status,
77 - resp.text
64 + resp.status, resp.text
78 65 );
79 66 }
80 67
@@ -126,11 +113,10 @@
126 113 &json!({"item_id": another_id}).to_string(),
127 114 )
128 115 .await;
129 - assert!(
130 - resp.status.is_client_error(),
116 + assert_eq!(
117 + resp.status, 400,
131 118 "Adding to non-bundle item should be rejected: {} {}",
132 - resp.status,
133 - resp.text
119 + resp.status, resp.text
134 120 );
135 121 }
136 122
@@ -154,11 +140,10 @@
154 140 .client
155 141 .delete(&format!("/api/items/{bundle_id}/bundle/{child_id}"))
156 142 .await;
157 - assert!(
158 - resp.status.is_success(),
143 + assert_eq!(
144 + resp.status, 200,
159 145 "Bundle remove failed: {} {}",
160 - resp.status,
161 - resp.text
146 + resp.status, resp.text
162 147 );
163 148 }
164 149
@@ -183,11 +168,10 @@
183 168 .client
184 169 .delete(&format!("/api/items/{bundle_id}/bundle/{other_id}"))
185 170 .await;
186 - assert!(
187 - resp.status.is_success(),
171 + assert_eq!(
172 + resp.status, 200,
188 173 "Idempotent remove should succeed: {} {}",
189 - resp.status,
190 - resp.text
174 + resp.status, resp.text
191 175 );
192 176 }
193 177
@@ -214,11 +198,10 @@
214 198 r#"{"listed": false}"#,
215 199 )
216 200 .await;
217 - assert!(
218 - resp.status.is_success(),
201 + assert_eq!(
202 + resp.status, 200,
219 203 "Toggle listed failed: {} {}",
220 - resp.status,
221 - resp.text
204 + resp.status, resp.text
222 205 );
223 206
224 207 // Toggle listed back to true
@@ -229,11 +212,10 @@
229 212 r#"{"listed": true}"#,
230 213 )
231 214 .await;
232 - assert!(
233 - resp.status.is_success(),
215 + assert_eq!(
216 + resp.status, 200,
234 217 "Toggle listed back failed: {} {}",
235 - resp.status,
236 - resp.text
218 + resp.status, resp.text
237 219 );
238 220 }
239 221
@@ -251,11 +233,10 @@
251 233 r#"{"title": "New Track"}"#,
252 234 )
253 235 .await;
254 - assert!(
255 - resp.status.is_success(),
236 + assert_eq!(
237 + resp.status, 200,
256 238 "Create child failed: {} {}",
257 - resp.status,
258 - resp.text
239 + resp.status, resp.text
259 240 );
260 241 let data: Value = resp.json();
261 242 assert!(data["item_id"].is_string(), "Should return item_id");
@@ -274,11 +255,10 @@
274 255 r#"{"title": ""}"#,
275 256 )
276 257 .await;
277 - assert!(
278 - resp.status.is_client_error(),
258 + assert_eq!(
259 + resp.status, 422,
279 260 "Empty title should be rejected: {} {}",
280 - resp.status,
281 - resp.text
261 + resp.status, resp.text
282 262 );
283 263 }
284 264
@@ -315,11 +295,10 @@
315 295 &json!({"item_id": other_id}).to_string(),
316 296 )
317 297 .await;
318 - assert!(
319 - resp.status.is_client_error(),
298 + assert_eq!(
299 + resp.status, 400,
320 300 "Cross-project bundle add should be rejected: {} {}",
321 - resp.status,
322 - resp.text
301 + resp.status, resp.text
323 302 );
324 303 }
325 304
@@ -425,11 +404,10 @@
425 404 "share_contact=false",
426 405 )
427 406 .await;
428 - assert!(
429 - resp.status.is_redirection() || resp.status.is_success(),
407 + assert_eq!(
408 + resp.status, 303,
430 409 "Bundle checkout should redirect: {} {}",
431 - resp.status,
432 - resp.text
410 + resp.status, resp.text
433 411 );
434 412
435 413 // Find pending transaction
@@ -32,11 +32,10 @@
32 32 .client
33 33 .post_form(&format!("/api/cart/{item_id}"), "")
34 34 .await;
35 - assert!(
36 - resp.status.is_success(),
35 + assert_eq!(
36 + resp.status, 200,
37 37 "Toggle add failed: {} {}",
38 - resp.status,
39 - resp.text
38 + resp.status, resp.text
40 39 );
41 40 let data: Value = resp.json();
42 41 assert_eq!(data["in_cart"], true, "First toggle should add to cart");
@@ -46,11 +45,10 @@
46 45 .client
47 46 .post_form(&format!("/api/cart/{item_id}"), "")
48 47 .await;
49 - assert!(
50 - resp.status.is_success(),
48 + assert_eq!(
49 + resp.status, 200,
51 50 "Toggle remove failed: {} {}",
52 - resp.status,
53 - resp.text
51 + resp.status, resp.text
54 52 );
55 53 let data: Value = resp.json();
56 54 assert_eq!(
@@ -71,11 +69,10 @@
71 69 .client
72 70 .post_form(&format!("/api/cart/{}", setup.item_id), "")
73 71 .await;
74 - assert!(
75 - resp.status.is_client_error(),
72 + assert_eq!(
73 + resp.status, 400,
76 74 "Own item should not be added to cart: {} {}",
77 - resp.status,
78 - resp.text
75 + resp.status, resp.text
79 76 );
80 77 }
81 78
@@ -99,11 +96,10 @@
99 96 .client
100 97 .post_form(&format!("/api/cart/{}", setup.item_id), "")
101 98 .await;
102 - assert!(
103 - resp.status.is_client_error() || resp.status == 404,
99 + assert_eq!(
100 + resp.status, 404,
104 101 "Unpublished item should not be added to cart: {} {}",
105 - resp.status,
106 - resp.text
102 + resp.status, resp.text
107 103 );
108 104 }
109 105
@@ -117,11 +113,10 @@
117 113 h.login("emptycount", "password123").await;
118 114
119 115 let resp = h.client.get("/api/cart/count").await;
120 - assert!(
121 - resp.status.is_success(),
116 + assert_eq!(
117 + resp.status, 200,
122 118 "Cart count failed: {} {}",
123 - resp.status,
124 - resp.text
119 + resp.status, resp.text
125 120 );
126 121 let data: Value = resp.json();
127 122 assert_eq!(data["count"], 0);
@@ -142,7 +137,7 @@
142 137 .await;
143 138
144 139 let resp = h.client.get("/api/cart/count").await;
145 - assert!(resp.status.is_success());
140 + assert_eq!(resp.status, 200, "{}", resp.text);
146 141 let data: Value = resp.json();
147 142 assert_eq!(data["count"], 1);
148 143 }
@@ -162,11 +157,10 @@
162 157 .post_form(&format!("/api/cart/{item_id}"), "")
163 158 .await;
164 159 let resp = h.client.delete(&format!("/api/cart/{item_id}")).await;
165 - assert!(
166 - resp.status.is_success() || resp.status == 204,
160 + assert_eq!(
161 + resp.status, 204,
167 162 "Remove from cart failed: {} {}",
168 - resp.status,
169 - resp.text
163 + resp.status, resp.text
170 164 );
171 165
172 166 // Verify count is 0
@@ -209,11 +203,10 @@
209 203 r#"{"amount_cents": 1000}"#,
210 204 )
211 205 .await;
212 - assert!(
213 - resp.status.is_success(),
206 + assert_eq!(
207 + resp.status, 200,
214 208 "PWYW amount update failed: {} {}",
215 - resp.status,
216 - resp.text
209 + resp.status, resp.text
217 210 );
218 211 }
219 212
@@ -247,11 +240,10 @@
247 240 r#"{"amount_cents": 100}"#,
248 241 )
249 242 .await;
250 - assert!(
251 - resp.status.is_client_error(),
243 + assert_eq!(
244 + resp.status, 400,
252 245 "Below-minimum PWYW should be rejected: {} {}",
253 - resp.status,
254 - resp.text
246 + resp.status, resp.text
255 247 );
256 248 }
257 249
@@ -285,11 +277,10 @@
285 277 r#"{"amount_cents": 1000001}"#,
286 278 )
287 279 .await;
288 - assert!(
289 - resp.status.is_client_error(),
280 + assert_eq!(
281 + resp.status, 400,
290 282 "Above-cap PWYW should be rejected: {} {}",
291 - resp.status,
292 - resp.text
283 + resp.status, resp.text
293 284 );
294 285 }
295 286