atagen

jelq

public

an http based roguelike rpg about jelqing

Star0
Watch0
c4d70621d377d45e3d418ac9197443c1b5d36399
1
ssh://brewery@pls.lobotomise.me:33/atagen/jelq.git
src/tests.rsRust2117 lines70.2 KB
1
use std::{sync::Arc, time::Duration};
2
3
use axum::{
4
    Router,
5
    body::{Body, to_bytes},
6
    http::{Method, Request, StatusCode, Version, header},
7
    response::Response,
8
};
9
use serde_json::Value;
10
use tower::ServiceExt;
11
12
use crate::{
13
    AppState, Config,
14
    game::{
15
        Floor, MumVisit, Run, Story,
16
        balance::{BRANCH_TURN, SUSPICION_LIMIT},
17
    },
18
    router,
19
};
20
21
async fn send(
22
    app: &Router,
23
    method: &str,
24
    uri: &str,
25
    headers: &[(&str, &str)],
26
    body: &str,
27
) -> Response {
28
    let mut request = Request::builder()
29
        .method(Method::from_bytes(method.as_bytes()).unwrap())
30
        .uri(uri);
31
    for (name, value) in headers {
32
        request = request.header(*name, *value);
33
    }
34
    app.clone()
35
        .oneshot(request.body(Body::from(body.to_owned())).unwrap())
36
        .await
37
        .unwrap()
38
}
39
40
async fn json(response: Response) -> Value {
41
    serde_json::from_slice(&to_bytes(response.into_body(), usize::MAX).await.unwrap()).unwrap()
42
}
43
44
async fn text(response: Response) -> String {
45
    String::from_utf8(
46
        to_bytes(response.into_body(), usize::MAX)
47
            .await
48
            .unwrap()
49
            .to_vec(),
50
    )
51
    .unwrap()
52
}
53
54
fn header<'a>(response: &'a Response, name: &str) -> &'a str {
55
    response.headers()[name].to_str().unwrap()
56
}
57
58
fn fixture() -> Run {
59
    Run::new("cafebabe".into(), "cafe".into(), 69)
60
}
61
62
fn seeded_with(floor: Floor, setup: impl FnOnce(&mut Run)) -> Router {
63
    let state = Arc::new(AppState::default());
64
    let mut run = fixture();
65
    run.floor = floor;
66
    setup(&mut run);
67
    state.insert(run);
68
    router(state)
69
}
70
71
fn seeded(floor: Floor) -> (Router, String) {
72
    let state = Arc::new(AppState::default());
73
    let mut run = fixture();
74
    run.floor = floor;
75
    let etag = run.origin_etag();
76
    state.insert(run);
77
    (router(state), etag)
78
}
79
80
fn app() -> Router {
81
    crate::app(Config::default())
82
}
83
84
fn mum_at_the_door(run: &mut Run) {
85
    run.mum = Some(MumVisit {
86
        errand: "She heard a noise.",
87
        nonce: "abc".into(),
88
        strikes: 0,
89
        knocked: false,
90
    });
91
}
92
93
#[tokio::test]
94
async fn creating_a_run_returns_location_and_etag() {
95
    let response = send(&app(), "POST", "/runs?seed=1", &[], "").await;
96
    assert_eq!(response.status(), StatusCode::CREATED);
97
    assert!(header(&response, "location").starts_with("/runs/"));
98
    assert!(response.headers().contains_key(header::ETAG));
99
    assert_eq!(header(&response, "server"), "jelqd/0.6.9");
100
}
101
102
#[tokio::test]
103
async fn bodies_do_not_repeat_the_headers() {
104
    let (app, _) = seeded(Floor::Bedroom);
105
    let response = send(&app, "GET", "/runs/cafebabe", &[], "").await;
106
    assert!(response.headers().contains_key("x-eq"));
107
    let body = json(response).await;
108
    for stat in [
109
        "eq",
110
        "girth",
111
        "foreskin",
112
        "stamina",
113
        "cash",
114
        "self_reported_length",
115
    ] {
116
        assert!(body.get(stat).is_none(), "{stat} is already a header");
117
    }
118
}
119
120
#[tokio::test]
121
async fn jelqing_cold_fails_the_precondition() {
122
    let (app, _) = seeded(Floor::Bedroom);
123
    let response = send(&app, "JELQ", "/runs/cafebabe?technique=stretch", &[], "").await;
124
    assert_eq!(response.status(), StatusCode::PRECONDITION_FAILED);
125
    assert_eq!(
126
        header(&response, "content-type"),
127
        "application/problem+json"
128
    );
129
}
130
131
#[tokio::test]
132
async fn wet_without_lube_is_unsupported_media() {
133
    let (app, _) = seeded(Floor::Bedroom);
134
    send(&app, "JELQ", "/runs/cafebabe?technique=warmup", &[], "").await;
135
    let response = send(&app, "JELQ", "/runs/cafebabe?technique=wet", &[], "").await;
136
    assert_eq!(response.status(), StatusCode::UNSUPPORTED_MEDIA_TYPE);
137
    let images = send(
138
        &app,
139
        "JELQ",
140
        "/runs/cafebabe?technique=wet",
141
        &[("content-type", "image/jpeg")],
142
        "",
143
    )
144
    .await;
145
    assert_eq!(json(images).await["detail"], "No.");
146
}
147
148
#[tokio::test]
149
async fn unknown_methods_are_not_implemented() {
150
    let (app, _) = seeded(Floor::Bedroom);
151
    let response = send(&app, "MILK", "/runs/cafebabe", &[], "").await;
152
    assert_eq!(response.status(), StatusCode::NOT_IMPLEMENTED);
153
    let teapot = send(&app, "BREW", "/runs/cafebabe", &[], "").await;
154
    assert_eq!(teapot.status(), StatusCode::IM_A_TEAPOT);
155
}
156
157
#[tokio::test]
158
async fn the_clinic_does_not_advertise_jelq() {
159
    let (app, _) = seeded(Floor::Clinic);
160
    let options = send(&app, "OPTIONS", "/runs/cafebabe", &[], "").await;
161
    assert!(!header(&options, "allow").contains("JELQ"));
162
    let jelq = send(&app, "JELQ", "/runs/cafebabe?technique=wet", &[], "").await;
163
    assert_eq!(jelq.status(), StatusCode::METHOD_NOT_ALLOWED);
164
    assert!(!header(&jelq, "allow").contains("JELQ"));
165
    let (bedroom, _) = seeded(Floor::Bedroom);
166
    let options = send(&bedroom, "OPTIONS", "/runs/cafebabe", &[], "").await;
167
    assert!(header(&options, "allow").contains("JELQ"));
168
}
169
170
#[tokio::test]
171
async fn patch_only_accepts_the_truth() {
172
    let (app, _) = seeded(Floor::Bedroom);
173
    let patch = [("content-type", "application/json-patch+json")];
174
    let replace = send(
175
        &app,
176
        "PATCH",
177
        "/runs/cafebabe",
178
        &patch,
179
        r#"[{"op":"replace","path":"/length","value":12}]"#,
180
    )
181
    .await;
182
    assert_eq!(replace.status(), StatusCode::UNPROCESSABLE_ENTITY);
183
    let lie = send(
184
        &app,
185
        "PATCH",
186
        "/runs/cafebabe",
187
        &patch,
188
        r#"[{"op":"test","path":"/length","value":40}]"#,
189
    )
190
    .await;
191
    assert_eq!(
192
        json(lie).await["type"],
193
        "https://jelq.dev/problems/exaggeration"
194
    );
195
}
196
197
#[tokio::test]
198
async fn deleting_the_foreskin_requires_certainty() {
199
    let (app, _) = seeded(Floor::Bedroom);
200
    let response = send(&app, "DELETE", "/runs/cafebabe/foreskin", &[], "").await;
201
    assert_eq!(response.status(), StatusCode::PRECONDITION_REQUIRED);
202
    let look = send(&app, "GET", "/runs/cafebabe/foreskin", &[], "").await;
203
    assert_eq!(look.status(), StatusCode::OK);
204
    let etag = header(&look, "etag").to_owned();
205
    assert!(text(look).await.contains('~'));
206
    let response = send(
207
        &app,
208
        "DELETE",
209
        "/runs/cafebabe/foreskin",
210
        &[("if-match", &etag)],
211
        "",
212
    )
213
    .await;
214
    assert_eq!(response.status(), StatusCode::NO_CONTENT);
215
    let response = send(&app, "GET", "/runs/cafebabe/foreskin", &[], "").await;
216
    assert_eq!(response.status(), StatusCode::GONE);
217
}
218
219
#[tokio::test]
220
async fn length_is_chunked_until_the_ruler() {
221
    let (app, _) = seeded(Floor::Bedroom);
222
    let lie = send(&app, "GET", "/runs/cafebabe/length", &[], "").await;
223
    assert!(!lie.headers().contains_key(header::CONTENT_LENGTH));
224
    let app = seeded_with(Floor::Bedroom, |run| run.items.ruler = true);
225
    let truth = send(&app, "GET", "/runs/cafebabe/length", &[], "").await;
226
    let declared: usize = header(&truth, "content-length").parse().unwrap();
227
    let body = text(truth).await;
228
    assert_eq!(body.len(), declared);
229
    assert!(body.starts_with("8=") && body.ends_with('D'));
230
}
231
232
#[tokio::test]
233
async fn length_supports_inch_ranges() {
234
    let (app, _) = seeded(Floor::Bedroom);
235
    let response = send(
236
        &app,
237
        "GET",
238
        "/runs/cafebabe/length",
239
        &[("range", "inches=0-1")],
240
        "",
241
    )
242
    .await;
243
    assert_eq!(response.status(), StatusCode::PARTIAL_CONTENT);
244
    assert!(header(&response, "content-range").starts_with("inches 0.00-1.00/"));
245
    assert_eq!(text(response).await.len(), 25);
246
    let absurd = send(
247
        &app,
248
        "GET",
249
        "/runs/cafebabe/length",
250
        &[("range", "inches=40-50")],
251
        "",
252
    )
253
    .await;
254
    assert_eq!(absurd.status(), StatusCode::RANGE_NOT_SATISFIABLE);
255
    let malformed = send(
256
        &app,
257
        "GET",
258
        "/runs/cafebabe/length",
259
        &[("range", "inches=6.17")],
260
        "",
261
    )
262
    .await;
263
    assert_eq!(malformed.status(), StatusCode::OK);
264
    let suffix = send(
265
        &app,
266
        "GET",
267
        "/runs/cafebabe/length",
268
        &[("range", "inches=-1")],
269
        "",
270
    )
271
    .await;
272
    assert_eq!(suffix.status(), StatusCode::PARTIAL_CONTENT);
273
    assert!(text(suffix).await.ends_with('D'));
274
    for (range, status) in [
275
        ("inches=1-", StatusCode::PARTIAL_CONTENT),
276
        ("inches=40-", StatusCode::RANGE_NOT_SATISFIABLE),
277
        ("inches=-0", StatusCode::RANGE_NOT_SATISFIABLE),
278
        ("inches=2-1", StatusCode::OK),
279
        ("inches=NaN-2", StatusCode::OK),
280
        ("inches=0-inf", StatusCode::OK),
281
    ] {
282
        let response = send(
283
            &app,
284
            "GET",
285
            "/runs/cafebabe/length",
286
            &[("range", range)],
287
            "",
288
        )
289
        .await;
290
        assert_eq!(response.status(), status, "{range}");
291
    }
292
}
293
294
#[tokio::test]
295
async fn the_clinic_escalates_toward_honesty() {
296
    let (app, _) = seeded(Floor::Clinic);
297
    let mut hints = Vec::new();
298
    for _ in 0..3 {
299
        let response = send(
300
            &app,
301
            "GET",
302
            "/runs/cafebabe/diagnosis",
303
            &[("authorization", "Excuse \"changing\"")],
304
            "",
305
        )
306
        .await;
307
        assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
308
        hints.push(header(&response, "www-authenticate").to_owned());
309
    }
310
    assert!(hints[2].contains("Be honest."));
311
}
312
313
#[tokio::test]
314
async fn the_true_ending_is_not_modified() {
315
    let (early, _) = seeded(Floor::Bedroom);
316
    let response = send(&early, "GET", "/runs/cafebabe/diagnosis", &[], "").await;
317
    assert_eq!(response.status(), StatusCode::TOO_EARLY);
318
319
    let (app, etag) = seeded(Floor::Clinic);
320
    assert!(
321
        !etag.contains("cafebabe"),
322
        "the origin ETag must not be derivable from the id"
323
    );
324
    let response = send(
325
        &app,
326
        "GET",
327
        "/runs/cafebabe/diagnosis",
328
        &[
329
            ("authorization", "Bearer honesty"),
330
            ("if-none-match", &etag),
331
        ],
332
        "",
333
    )
334
    .await;
335
    assert_eq!(response.status(), StatusCode::NOT_MODIFIED);
336
    assert_eq!(header(&response, "x-diagnosis"), "normal");
337
}
338
339
#[tokio::test]
340
async fn mum_accepts_a_known_excuse() {
341
    let app = seeded_with(Floor::Bedroom, mum_at_the_door);
342
    let response = send(&app, "GET", "/runs/cafebabe", &[], "").await;
343
    assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
344
    assert_eq!(header(&response, "via"), "1.1 mum");
345
    let response = send(
346
        &app,
347
        "GET",
348
        "/runs/cafebabe",
349
        &[("authorization", "Excuse \"homework\"")],
350
        "",
351
    )
352
    .await;
353
    assert_eq!(response.status(), StatusCode::OK);
354
}
355
356
#[tokio::test]
357
async fn mum_runs_out_of_patience() {
358
    let app = seeded_with(Floor::Bedroom, mum_at_the_door);
359
    let lie = [("authorization", "Excuse \"it is for science\"")];
360
    for _ in 0..2 {
361
        let response = send(&app, "GET", "/runs/cafebabe", &lie, "").await;
362
        assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
363
    }
364
    let caught = send(&app, "GET", "/runs/cafebabe", &lie, "").await;
365
    assert_eq!(caught.status(), StatusCode::GONE);
366
    assert_eq!(
367
        json(caught).await["type"],
368
        "https://jelq.dev/problems/caught"
369
    );
370
}
371
372
#[tokio::test]
373
async fn mum_remembers_excuses_by_christmas() {
374
    let app = seeded_with(Floor::Nans, |run| {
375
        mum_at_the_door(run);
376
        run.heard.push("homework".into());
377
    });
378
    let basic = send(
379
        &app,
380
        "GET",
381
        "/runs/cafebabe",
382
        &[("authorization", "Excuse \"changing\"")],
383
        "",
384
    )
385
    .await;
386
    assert_eq!(basic.status(), StatusCode::UNAUTHORIZED);
387
    assert!(header(&basic, "www-authenticate").starts_with("Digest"));
388
    let replay = send(
389
        &app,
390
        "GET",
391
        "/runs/cafebabe",
392
        &[(
393
            "authorization",
394
            "Digest username=\"homework\", nonce=\"abc\"",
395
        )],
396
        "",
397
    )
398
    .await;
399
    assert_eq!(
400
        json(replay).await["type"],
401
        "https://jelq.dev/problems/excuse-replayed"
402
    );
403
    let fresh = send(
404
        &app,
405
        "GET",
406
        "/runs/cafebabe",
407
        &[(
408
            "authorization",
409
            "Digest username=\"wrapping presents\", nonce=\"abc\"",
410
        )],
411
        "",
412
    )
413
    .await;
414
    assert_eq!(fresh.status(), StatusCode::OK);
415
}
416
417
#[tokio::test]
418
async fn a_pulled_muscle_is_a_true_excuse() {
419
    let app = seeded_with(Floor::Nans, |run| {
420
        mum_at_the_door(run);
421
        run.conditions.pulled_muscle = true;
422
        run.heard.push("pulled a muscle".into());
423
    });
424
    let response = send(
425
        &app,
426
        "GET",
427
        "/runs/cafebabe",
428
        &[(
429
            "authorization",
430
            "Digest username=\"pulled a muscle\", nonce=\"abc\"",
431
        )],
432
        "",
433
    )
434
    .await;
435
    assert_eq!(response.status(), StatusCode::OK);
436
    assert!(header(&response, "x-stamina").ends_with("/7"));
437
}
438
439
#[tokio::test]
440
async fn floors_end_when_they_end() {
441
    let app = seeded_with(Floor::Bedroom, |_| {});
442
    let early = send(&app, "POST", "/runs/cafebabe/floor", &[], "").await;
443
    assert_eq!(early.status(), StatusCode::TOO_EARLY);
444
    let app = seeded_with(Floor::Bedroom, |run| run.here.turns = 6);
445
    let next = send(&app, "POST", "/runs/cafebabe/floor", &[], "").await;
446
    assert!(
447
        next.status() == StatusCode::SEE_OTHER || next.status() == StatusCode::CONFLICT,
448
        "either the floor changed or the zip did"
449
    );
450
}
451
452
#[tokio::test]
453
async fn pump_bro_upsells() {
454
    let closed = seeded_with(Floor::Bedroom, |_| {});
455
    let response = send(&closed, "GET", "/runs/cafebabe/shop", &[], "").await;
456
    assert_eq!(response.status(), StatusCode::NOT_FOUND);
457
458
    let app = seeded_with(Floor::ChangingRoom, |_| {});
459
    let pump = send(&app, "POST", "/runs/cafebabe/shop/pump", &[], "").await;
460
    assert_eq!(pump.status(), StatusCode::UPGRADE_REQUIRED);
461
    assert_eq!(header(&pump, "upgrade"), "pump-pro/2.0");
462
    let pro = send(&app, "POST", "/runs/cafebabe/shop/pump-pro", &[], "").await;
463
    assert_eq!(pro.status(), StatusCode::CREATED);
464
    let broke = send(&app, "POST", "/runs/cafebabe/shop/extender", &[], "").await;
465
    assert_eq!(broke.status(), StatusCode::PAYMENT_REQUIRED);
466
}
467
468
#[tokio::test]
469
async fn only_the_key_opens_the_cage() {
470
    let app = seeded_with(Floor::ChangingRoom, |run| run.items.cage = true);
471
    send(&app, "POST", "/runs/cafebabe/shop/ice-pack", &[], "").await;
472
    let still = send(
473
        &app,
474
        "GET",
475
        "/runs/cafebabe/inventory/chastity-cage",
476
        &[],
477
        "",
478
    )
479
    .await;
480
    assert_eq!(still.status(), StatusCode::OK);
481
    send(&app, "POST", "/runs/cafebabe/shop/cage-key", &[], "").await;
482
    let gone = send(
483
        &app,
484
        "GET",
485
        "/runs/cafebabe/inventory/chastity-cage",
486
        &[],
487
        "",
488
    )
489
    .await;
490
    assert_eq!(gone.status(), StatusCode::NOT_FOUND);
491
}
492
493
#[tokio::test]
494
async fn combos_are_multi_status_xml() {
495
    let app = seeded_with(Floor::Bathroom, |run| {
496
        run.foreskin = Some(1.0);
497
        run.items.blinds = true;
498
    });
499
    send(&app, "JELQ", "/runs/cafebabe?technique=warmup", &[], "").await;
500
    let response = send(
501
        &app,
502
        "JELQ",
503
        "/runs/cafebabe?technique=wet,stretch",
504
        &[("content-type", "application/baby-oil")],
505
        "",
506
    )
507
    .await;
508
    assert_eq!(response.status(), StatusCode::MULTI_STATUS);
509
    assert!(text(response).await.contains("<D:multistatus"));
510
}
511
512
#[tokio::test]
513
async fn the_elder_is_stale() {
514
    let app = seeded_with(Floor::Forum, |run| run.length = 5.0);
515
    let unverified = send(&app, "GET", "/runs/cafebabe/elder", &[], "").await;
516
    assert_eq!(
517
        unverified.status(),
518
        StatusCode::PROXY_AUTHENTICATION_REQUIRED
519
    );
520
    let lie = send(
521
        &app,
522
        "GET",
523
        "/runs/cafebabe/elder",
524
        &[("proxy-authorization", "Ruler 9")],
525
        "",
526
    )
527
    .await;
528
    assert_eq!(lie.status(), StatusCode::BAD_REQUEST);
529
    let csv = send(
530
        &app,
531
        "GET",
532
        "/runs/cafebabe/elder",
533
        &[("proxy-authorization", "Ruler 5.0")],
534
        "",
535
    )
536
    .await;
537
    assert_eq!(csv.status(), StatusCode::OK);
538
    let stale = send(
539
        &app,
540
        "GET",
541
        "/runs/cafebabe/elder",
542
        &[("if-modified-since", "Mon, 15 Jun 2009 00:00:00 GMT")],
543
        "",
544
    )
545
    .await;
546
    assert_eq!(stale.status(), StatusCode::NOT_MODIFIED);
547
}
548
549
#[tokio::test]
550
async fn the_displayed_length_is_accepted_as_honest() {
551
    let app = seeded_with(Floor::Forum, |run| run.length = 5.2496);
552
    let response = send(
553
        &app,
554
        "GET",
555
        "/runs/cafebabe/elder",
556
        &[("proxy-authorization", "Ruler 5.25")],
557
        "",
558
    )
559
    .await;
560
    assert_eq!(response.status(), StatusCode::OK);
561
}
562
563
#[tokio::test]
564
async fn the_hotel_has_a_captive_portal() {
565
    let app = seeded_with(Floor::Hotel, |_| {});
566
    let blocked = send(&app, "GET", "/runs/cafebabe", &[], "").await;
567
    assert_eq!(
568
        blocked.status(),
569
        StatusCode::NETWORK_AUTHENTICATION_REQUIRED
570
    );
571
    let accepted = send(&app, "POST", "/runs/cafebabe/portal", &[], "").await;
572
    assert_eq!(accepted.status(), StatusCode::SEE_OTHER);
573
    let online = send(&app, "GET", "/runs/cafebabe", &[], "").await;
574
    assert_eq!(online.status(), StatusCode::OK);
575
}
576
577
#[tokio::test]
578
async fn the_internet_never_forgets() {
579
    let app = seeded_with(Floor::Bedroom, |run| {
580
        run.filmed_at = Some(std::time::SystemTime::now());
581
    });
582
    let footage = send(&app, "GET", "/runs/cafebabe/footage", &[], "").await;
583
    assert_eq!(footage.status(), StatusCode::OK);
584
    assert_eq!(
585
        header(&footage, "cache-control"),
586
        "public, max-age=31536000, immutable"
587
    );
588
    assert!(footage.headers().contains_key(header::AGE));
589
    let delete = send(&app, "DELETE", "/runs/cafebabe/footage", &[], "").await;
590
    assert_eq!(delete.status(), StatusCode::METHOD_NOT_ALLOWED);
591
    assert!(!header(&delete, "allow").contains("DELETE"));
592
}
593
594
#[tokio::test]
595
async fn forged_ruler_cookies_are_lies() {
596
    let forged = send(
597
        &app(),
598
        "GET",
599
        "/leaderboard?sort=content-length",
600
        &[("cookie", "ruler=1")],
601
        "",
602
    )
603
    .await;
604
    assert_eq!(forged.status(), StatusCode::BAD_REQUEST);
605
}
606
607
#[tokio::test]
608
async fn listings_never_reveal_the_private_id() {
609
    let app = app();
610
    let created = send(&app, "POST", "/runs", &[], "").await;
611
    let location = header(&created, "location").to_owned();
612
    let board = text(send(&app, "GET", "/leaderboard", &[], "").await).await;
613
    let private = location.trim_start_matches("/runs/");
614
    assert!(!board.contains(private));
615
    let entry: Value = serde_json::from_str(&board).unwrap();
616
    let public = entry["entries"][0]["public_id"].as_str().unwrap();
617
    let guess = send(&app, "DELETE", &format!("/runs/{public}"), &[], "").await;
618
    assert_eq!(guess.status(), StatusCode::NOT_FOUND);
619
}
620
621
#[tokio::test]
622
async fn seeded_runs_are_unranked() {
623
    let app = app();
624
    send(&app, "POST", "/runs?seed=7", &[], "").await;
625
    let board = json(send(&app, "GET", "/leaderboard", &[], "").await).await;
626
    assert_eq!(board["entries"].as_array().unwrap().len(), 0);
627
    let all = json(send(&app, "GET", "/leaderboard?seeded=include", &[], "").await).await;
628
    assert_eq!(all["entries"][0]["ranked"], false);
629
}
630
631
#[tokio::test]
632
async fn idle_runs_are_evicted_and_the_server_has_a_limit() {
633
    let forgetful = crate::app(Config {
634
        idle_ttl: Duration::ZERO,
635
        ..Config::default()
636
    });
637
    let first = send(&forgetful, "POST", "/runs", &[], "").await;
638
    let location = header(&first, "location").to_owned();
639
    send(&forgetful, "POST", "/runs", &[], "").await;
640
    let gone = send(&forgetful, "GET", &location, &[], "").await;
641
    assert_eq!(gone.status(), StatusCode::NOT_FOUND);
642
643
    let tiny = crate::app(Config {
644
        max_runs: 1,
645
        ..Config::default()
646
    });
647
    send(&tiny, "POST", "/runs", &[], "").await;
648
    let full = send(&tiny, "POST", "/runs", &[], "").await;
649
    assert_eq!(full.status(), StatusCode::SERVICE_UNAVAILABLE);
650
}
651
652
#[tokio::test]
653
async fn shame_from_two_runs_adds_up() {
654
    let state = Arc::new(AppState::default());
655
    state.insert(fixture());
656
    state.insert(Run::new("deadbeef".into(), "dead".into(), 7));
657
    let app = router(state);
658
    let first = send(&app, "TRACE", "/runs/cafebabe", &[], "").await;
659
    assert_eq!(header(&first, "x-shame"), "1");
660
    let cookie = header(&first, "set-cookie")
661
        .split(';')
662
        .next()
663
        .unwrap()
664
        .to_owned();
665
    let second = send(&app, "TRACE", "/runs/deadbeef", &[("cookie", &cookie)], "").await;
666
    assert_eq!(header(&second, "x-shame"), "2");
667
}
668
669
#[tokio::test]
670
async fn the_root_negotiates() {
671
    let app = app();
672
    let browser = send(&app, "GET", "/", &[("accept", "text/html,*/*")], "").await;
673
    assert_eq!(browser.status(), StatusCode::SEE_OTHER);
674
    assert_eq!(header(&browser, "location"), "/ui/");
675
    let curl = send(&app, "GET", "/", &[("accept", "*/*")], "").await;
676
    assert_eq!(json(curl).await["ui"], "/ui/");
677
    let page = send(&app, "GET", "/ui/", &[], "").await;
678
    assert_eq!(page.status(), StatusCode::OK);
679
}
680
681
#[tokio::test]
682
async fn the_ui_replays_what_browsers_cannot_send() {
683
    let app = app();
684
    let envelope =
685
        r#"{"method": "TRACE", "path": "/graveyard", "headers": [["X-Confession", "I did it"]]}"#;
686
    let traced = json(send(&app, "POST", "/ui/send", &[], envelope).await).await;
687
    assert_eq!(traced["status"], 405);
688
    let envelope = r#"{"method": "GET", "path": "/", "headers": [["Accept", "text/html"]]}"#;
689
    let redirected = json(send(&app, "POST", "/ui/send", &[], envelope).await).await;
690
    assert_eq!(redirected["status"], 303);
691
    assert_eq!(redirected["reason"], "See Other");
692
}
693
694
#[tokio::test]
695
async fn the_ui_decodes_gzip_for_display() {
696
    let app = seeded_with(Floor::Bedroom, |run| run.items.ring = true);
697
    let envelope =
698
        r#"{"method": "GET", "path": "/runs/cafebabe", "headers": [["Accept-Encoding", "gzip"]]}"#;
699
    let shown = json(send(&app, "POST", "/ui/send", &[], envelope).await).await;
700
    assert!(
701
        shown["body"]
702
            .as_str()
703
            .unwrap()
704
            .contains("\"public_id\":\"cafe\"")
705
    );
706
    let encoding = shown["headers"]
707
        .as_array()
708
        .unwrap()
709
        .iter()
710
        .any(|h| h[0] == "content-encoding" && h[1] == "gzip");
711
    assert!(encoding);
712
}
713
714
#[tokio::test]
715
async fn http_1_0_gets_the_pdf_treatment() {
716
    let request = Request::builder()
717
        .uri("/graveyard")
718
        .version(Version::HTTP_10)
719
        .body(Body::empty())
720
        .unwrap();
721
    let response = app().oneshot(request).await.unwrap();
722
    assert_eq!(response.status(), StatusCode::HTTP_VERSION_NOT_SUPPORTED);
723
}
724
725
#[tokio::test]
726
async fn mom_speaks_american() {
727
    let app = seeded_with(Floor::Nans, mum_at_the_door);
728
    let us = [("accept-language", "en-US")];
729
    let knock = send(&app, "GET", "/runs/cafebabe", &us, "").await;
730
    assert_eq!(header(&knock, "content-language"), "en-US");
731
    assert_eq!(header(&knock, "via"), "1.1 mom");
732
    assert!(
733
        json(knock).await["detail"]
734
            .as_str()
735
            .unwrap()
736
            .contains("Someone is at the door.")
737
    );
738
    let excuse = [
739
        ("accept-language", "en-US"),
740
        (
741
            "authorization",
742
            "Digest username=\"carving the turkey\", nonce=\"abc\"",
743
        ),
744
    ];
745
    let answered = send(&app, "GET", "/runs/cafebabe", &excuse, "").await;
746
    assert_eq!(answered.status(), StatusCode::OK);
747
    let floor = json(send(&app, "GET", "/runs/cafebabe/floor", &us, "").await).await;
748
    assert_eq!(floor["name"], "Grandma's house for Thanksgiving");
749
}
750
751
#[tokio::test]
752
async fn the_default_locale_is_british() {
753
    let (app, _) = seeded(Floor::Nans);
754
    let floor = send(&app, "GET", "/runs/cafebabe/floor", &[], "").await;
755
    assert_eq!(header(&floor, "content-language"), "en-GB");
756
    assert!(header(&floor, "vary").contains("Accept-Language"));
757
    assert_eq!(json(floor).await["name"], "Nan's house at Christmas");
758
}
759
760
fn advertised(response: &Response) -> Vec<String> {
761
    header(response, "link")
762
        .split(", <")
763
        .filter(|entry| entry.contains("rels/technique"))
764
        .filter_map(|entry| {
765
            entry
766
                .split("title=\"")
767
                .nth(1)?
768
                .split('"')
769
                .next()
770
                .map(str::to_owned)
771
        })
772
        .collect()
773
}
774
775
#[tokio::test]
776
async fn links_offer_only_usable_techniques() {
777
    let app = seeded_with(Floor::Bedroom, |run| run.foreskin = Some(1.0));
778
    let cold = send(&app, "GET", "/runs/cafebabe", &[], "").await;
779
    assert_eq!(advertised(&cold), ["warmup", "ice", "meditate"]);
780
    let warm = send(&app, "JELQ", "/runs/cafebabe?technique=warmup", &[], "").await;
781
    let usable = advertised(&warm);
782
    assert!(usable.contains(&"wet".to_owned()) && usable.contains(&"dry".to_owned()));
783
    assert!(!usable.contains(&"uli".to_owned()), "uli needs the forum");
784
    assert!(!usable.contains(&"pump".to_owned()), "pump needs a pump");
785
    assert!(
786
        header(&warm, "link").contains("title=\"wet\"; allow=\"JELQ\"; lubricant=\"required\"")
787
    );
788
789
    let (forum, _) = seeded(Floor::Forum);
790
    let response = send(&forum, "GET", "/runs/cafebabe", &[], "").await;
791
    assert!(advertised(&response).is_empty());
792
}
793
794
fn linked_rels(response: &Response) -> Vec<String> {
795
    header(response, "link")
796
        .split(", <")
797
        .filter_map(|entry| {
798
            entry
799
                .split("rels/")
800
                .nth(1)?
801
                .split('"')
802
                .next()
803
                .map(str::to_owned)
804
        })
805
        .filter(|rel| rel != "technique")
806
        .collect()
807
}
808
809
#[tokio::test]
810
async fn links_hide_resources_that_would_do_nothing() {
811
    let (bedroom, _) = seeded(Floor::Bedroom);
812
    let before = linked_rels(&send(&bedroom, "GET", "/runs/cafebabe", &[], "").await);
813
    assert!(before.contains(&"drawer".to_owned()));
814
    assert!(!before.contains(&"diagnosis".to_owned()));
815
    assert!(!before.contains(&"thread".to_owned()));
816
    let searched = send(&bedroom, "POST", "/runs/cafebabe/drawer", &[], "").await;
817
    assert!(!linked_rels(&searched).contains(&"drawer".to_owned()));
818
819
    let (clinic, _) = seeded(Floor::Clinic);
820
    let rels = linked_rels(&send(&clinic, "GET", "/runs/cafebabe", &[], "").await);
821
    assert!(rels.contains(&"diagnosis".to_owned()) && rels.contains(&"thread".to_owned()));
822
}
823
824
#[tokio::test]
825
async fn ignoring_mum_has_consequences() {
826
    let app = seeded_with(Floor::Bedroom, |run| {
827
        mum_at_the_door(run);
828
        run.here.turns = 6;
829
    });
830
    let knock = send(&app, "GET", "/runs/cafebabe", &[], "").await;
831
    assert_eq!(
832
        json(knock).await["type"],
833
        "https://jelq.dev/problems/someone-at-the-door"
834
    );
835
    let leave = send(&app, "POST", "/runs/cafebabe/floor", &[], "").await;
836
    assert_eq!(
837
        leave.status(),
838
        StatusCode::UNAUTHORIZED,
839
        "no leaving with Mum at the door"
840
    );
841
    send(&app, "GET", "/runs/cafebabe", &[], "").await;
842
    let opened = send(&app, "GET", "/runs/cafebabe", &[], "").await;
843
    assert_eq!(opened.status(), StatusCode::GONE);
844
    assert_eq!(
845
        json(opened).await["type"],
846
        "https://jelq.dev/problems/opened"
847
    );
848
}
849
850
#[tokio::test]
851
async fn caged_drawers_are_noisy_and_eventually_give_up_the_key() {
852
    let app = seeded_with(Floor::ChangingRoom, |run| run.items.cage = true);
853
    let mut statuses = Vec::new();
854
    for _ in 0..12 {
855
        let response = send(&app, "POST", "/runs/cafebabe/drawer", &[], "").await;
856
        statuses.push(response.status());
857
        if response.status() == StatusCode::OK {
858
            break;
859
        }
860
    }
861
    assert_eq!(statuses.last(), Some(&StatusCode::OK), "{statuses:?}");
862
    assert!(
863
        statuses[..statuses.len() - 1]
864
            .iter()
865
            .all(|s| *s == StatusCode::NO_CONTENT)
866
    );
867
}
868
869
#[tokio::test]
870
async fn the_ruler_replaces_the_self_report() {
871
    let app = seeded_with(Floor::Bedroom, |_| {});
872
    let before = send(&app, "GET", "/runs/cafebabe", &[], "").await;
873
    assert!(before.headers().contains_key("x-self-reported-length"));
874
    assert!(!before.headers().contains_key("x-length"));
875
876
    let app = seeded_with(Floor::Bedroom, |run| run.items.ruler = true);
877
    let after = send(&app, "GET", "/runs/cafebabe", &[], "").await;
878
    assert!(!after.headers().contains_key("x-self-reported-length"));
879
    assert!(after.headers().contains_key("x-length"));
880
}
881
882
#[tokio::test]
883
async fn an_open_tab_is_linked_and_jelq_still_answers_with_choices() {
884
    let app = seeded_with(Floor::Bathroom, |run| {
885
        run.story = Story::Pending;
886
        run.warmed_up = true;
887
    });
888
    let response = send(&app, "GET", "/runs/cafebabe", &[], "").await;
889
    let links = header(&response, "link");
890
    assert!(links.contains("/branches/forum>; rel=\"https://jelq.dev/rels/branch\""));
891
    assert!(links.contains("/branches/close-tab>; rel=\"https://jelq.dev/rels/branch\""));
892
    assert!(links.contains("?technique=wet>"));
893
    let jelq = send(&app, "JELQ", "/runs/cafebabe?technique=edge", &[], "").await;
894
    assert_eq!(jelq.status(), StatusCode::MULTIPLE_CHOICES);
895
}
896
897
#[tokio::test]
898
async fn either_story_choice_is_reachable_during_cooldown() {
899
    for choice in ["forum", "close-tab"] {
900
        let app = seeded_with(Floor::Bathroom, |run| {
901
            run.turn = 9;
902
            run.warmed_up = true;
903
            run.eq = 45;
904
            run.stamina = 1;
905
            run.items.door_locks = 1;
906
        });
907
        let tenth = send(&app, "JELQ", "/runs/cafebabe?technique=edge", &[], "").await;
908
        assert_eq!(tenth.status(), StatusCode::OK);
909
        let response = send(&app, "GET", "/runs/cafebabe", &[], "").await;
910
        assert!(!header(&response, "link").contains("?technique="));
911
        let path = format!("/runs/cafebabe/branches/{choice}");
912
        assert!(
913
            header(&response, "link")
914
                .contains(&format!("<{path}>; rel=\"https://jelq.dev/rels/branch\""))
915
        );
916
        let chosen = send(&app, "POST", &path, &[], "").await;
917
        assert_eq!(chosen.status(), StatusCode::SEE_OTHER);
918
        assert!(!header(&chosen, "link").contains("/branches/"));
919
        let repeated = send(&app, "POST", &path, &[], "").await;
920
        assert_eq!(repeated.status(), StatusCode::CONFLICT);
921
    }
922
}
923
924
#[tokio::test]
925
async fn a_door_lock_says_when_it_was_used() {
926
    let app = seeded_with(Floor::Bathroom, |run| {
927
        run.items.door_locks = 1;
928
        run.suspicion = SUSPICION_LIMIT;
929
        run.warmed_up = true;
930
    });
931
    let response = send(&app, "JELQ", "/runs/cafebabe?technique=dry", &[], "").await;
932
    assert_ne!(response.status(), StatusCode::CONFLICT);
933
    assert!(
934
        response
935
            .headers()
936
            .get_all("warning")
937
            .iter()
938
            .any(|w| w.to_str().unwrap().contains("your brother tried the door"))
939
    );
940
}
941
942
#[tokio::test]
943
async fn shopping_survives_browsing_but_not_jelqing() {
944
    let app = seeded_with(Floor::ChangingRoom, |run| run.cash = 50);
945
    send(&app, "GET", "/runs/cafebabe/shop", &[], "").await;
946
    let view = send(&app, "GET", "/runs/cafebabe", &[], "").await;
947
    assert!(header(&view, "link").contains("shop-item"));
948
    let jelq = send(&app, "JELQ", "/runs/cafebabe?technique=warmup", &[], "").await;
949
    assert_ne!(jelq.status(), StatusCode::METHOD_NOT_ALLOWED);
950
    let after = send(&app, "GET", "/runs/cafebabe", &[], "").await;
951
    assert!(!header(&after, "link").contains("shop-item"));
952
}
953
954
#[tokio::test]
955
async fn the_thread_announces_itself_at_the_branch() {
956
    let app = seeded_with(Floor::Bathroom, |run| {
957
        run.turn = 8;
958
        run.warmed_up = true;
959
        run.items.baby_oil = 9;
960
    });
961
    send(
962
        &app,
963
        "JELQ",
964
        "/runs/cafebabe?technique=wet",
965
        &[("content-type", "application/baby-oil")],
966
        "",
967
    )
968
    .await;
969
    let ninth = send(&app, "GET", "/runs/cafebabe", &[], "").await;
970
    assert!(!header(&ninth, "link").contains("/forum/threads/2009"));
971
    let tenth = send(
972
        &app,
973
        "JELQ",
974
        "/runs/cafebabe?technique=wet",
975
        &[("content-type", "application/baby-oil")],
976
        "",
977
    )
978
    .await;
979
    assert!(
980
        tenth
981
            .headers()
982
            .get_all("warning")
983
            .iter()
984
            .any(|w| w.to_str().unwrap().contains("a forum thread from 2009"))
985
    );
986
    assert!(header(&tenth, "link").contains("/forum/threads/2009"));
987
    let announced = send(&app, "GET", "/runs/cafebabe", &[], "").await;
988
    assert!(header(&announced, "link").contains("/forum/threads/2009"));
989
}
990
991
#[tokio::test]
992
async fn rereading_the_thread_teaches_uli() {
993
    let mut taught_runs = 0;
994
    for seed in 0..100u64 {
995
        let app = app_with(Floor::Bedroom, seed, |run| {
996
            run.turn = BRANCH_TURN;
997
            run.warmed_up = true;
998
            run.story = Story::ClosedTab;
999
        });
1000
        let mut taught = false;
1001
        for _ in 0..2 {
1002
            let read = send(&app, "POST", "/runs/cafebabe/re-read", &[], "").await;
1003
            match read.status() {
1004
                StatusCode::NOT_MODIFIED => {
1005
                    assert!(header(&read, "warning").contains("2009"));
1006
                }
1007
                StatusCode::OK => {
1008
                    assert!(header(&read, "location").contains("/techniques"));
1009
                    taught = true;
1010
                    break;
1011
                }
1012
                other => panic!("seed {seed} read the thread into {other}"),
1013
            }
1014
        }
1015
        let state = send(&app, "GET", "/runs/cafebabe", &[], "").await;
1016
        assert_eq!(header(&state, "x-stamina"), "10/10", "reading is free");
1017
        if taught {
1018
            taught_runs += 1;
1019
            let jelq = send(&app, "JELQ", "/runs/cafebabe?technique=uli", &[], "").await;
1020
            assert_ne!(jelq.status(), StatusCode::FORBIDDEN);
1021
        } else {
1022
            let cached = send(&app, "POST", "/runs/cafebabe/re-read", &[], "").await;
1023
            assert_eq!(cached.status(), StatusCode::OK);
1024
            assert!(header(&cached, "warning").starts_with("112"));
1025
            assert!(header(&cached, "age").parse::<u64>().unwrap() > 0);
1026
        }
1027
    }
1028
    assert!(
1029
        (30..=58).contains(&taught_runs),
1030
        "{taught_runs}/100 learned uli in two reads"
1031
    );
1032
}
1033
1034
#[tokio::test]
1035
async fn meditation_is_free_but_only_once_per_floor() {
1036
    let app = seeded_with(Floor::Bedroom, |_| {});
1037
    let response = send(&app, "JELQ", "/runs/cafebabe?technique=meditate", &[], "").await;
1038
    assert_eq!(response.status(), StatusCode::OK);
1039
    assert_eq!(header(&response, "x-eq"), "10");
1040
    assert_eq!(header(&response, "x-stamina"), "10/10");
1041
    let again = send(&app, "JELQ", "/runs/cafebabe?technique=meditate", &[], "").await;
1042
    assert_eq!(again.status(), StatusCode::CONFLICT);
1043
}
1044
1045
#[tokio::test]
1046
async fn meditation_is_listed_from_the_start() {
1047
    let app = seeded_with(Floor::Bedroom, |_| {});
1048
    let view = send(&app, "GET", "/runs/cafebabe", &[], "").await;
1049
    assert!(header(&view, "link").contains("technique=meditate"));
1050
    let jelq = send(&app, "JELQ", "/runs/cafebabe?technique=meditate", &[], "").await;
1051
    assert_eq!(jelq.status(), StatusCode::OK);
1052
}
1053
1054
fn app_with(floor: Floor, seed: u64, setup: impl FnOnce(&mut Run)) -> Router {
1055
    let state = Arc::new(AppState::default());
1056
    let mut run = Run::new("cafebabe".into(), "cafe".into(), seed);
1057
    run.floor = floor;
1058
    setup(&mut run);
1059
    state.insert(run);
1060
    router(state)
1061
}
1062
1063
#[tokio::test]
1064
async fn hold_is_offered_only_at_the_edges() {
1065
    let calm = seeded_with(Floor::Bedroom, |_| {});
1066
    let refused = send(&calm, "HOLD", "/runs/cafebabe", &[], "").await;
1067
    assert_eq!(refused.status(), StatusCode::METHOD_NOT_ALLOWED);
1068
    assert!(!header(&refused, "allow").contains("HOLD"));
1069
    let view = send(&calm, "GET", "/runs/cafebabe", &[], "").await;
1070
    assert!(!header(&view, "link").contains("HOLD"));
1071
    let options = send(&calm, "OPTIONS", "/runs/cafebabe", &[], "").await;
1072
    assert!(!header(&options, "allow").contains("HOLD"));
1073
1074
    let winded = seeded_with(Floor::Bedroom, |run| run.stamina = 2);
1075
    let listed = send(&winded, "GET", "/runs/cafebabe", &[], "").await;
1076
    assert!(header(&listed, "link").contains("HOLD"));
1077
    let options = send(&winded, "OPTIONS", "/runs/cafebabe", &[], "").await;
1078
    assert!(header(&options, "allow").contains("HOLD"));
1079
1080
    let hot = seeded_with(Floor::Bedroom, |run| {
1081
        run.eq = 75;
1082
        run.warmed_up = true;
1083
    });
1084
    let held = send(&hot, "HOLD", "/runs/cafebabe", &[], "").await;
1085
    assert_eq!(held.status(), StatusCode::NO_CONTENT);
1086
    assert_eq!(header(&held, "x-stamina"), "10/10");
1087
}
1088
1089
#[tokio::test]
1090
async fn holding_calms_and_sometimes_reflects() {
1091
    let mut calmed = 0;
1092
    let mut reflected = 0;
1093
    let mut ruptured = 0;
1094
    for seed in 0..120u64 {
1095
        let mut run = Run::new("cafebabe".into(), "cafe".into(), seed);
1096
        run.eq = 75;
1097
        match run.hold() {
1098
            Ok(_) if run.eq == 0 => reflected += 1,
1099
            Ok(_) => calmed += 1,
1100
            Err(_) => ruptured += 1,
1101
        }
1102
    }
1103
    assert!(
1104
        calmed > 100 && reflected > 0,
1105
        "one HOLD in twelve should reflect, got {calmed} calm and {reflected} reflective of 120"
1106
    );
1107
    assert!(
1108
        calmed + reflected + ruptured == 120 && ruptured <= 4,
1109
        "the flat 1 in 250 ruptured {ruptured} of 120 holds"
1110
    );
1111
}
1112
1113
#[tokio::test]
1114
async fn leaving_the_band_warms_the_next_floor() {
1115
    let app = seeded_with(Floor::Bedroom, |run| {
1116
        run.eq = 60;
1117
        run.warmed_up = true;
1118
        run.here.turns = 6;
1119
        run.items.baby_oil = 9;
1120
    });
1121
    let left = send(&app, "POST", "/runs/cafebabe/floor", &[], "").await;
1122
    assert_eq!(left.status(), StatusCode::SEE_OTHER);
1123
    let detail = json(left).await["detail"].as_str().unwrap().to_owned();
1124
    assert!(detail.contains("the warmth came with you"));
1125
1126
    let lube = [("content-type", "application/baby-oil")];
1127
    send(&app, "JELQ", "/runs/cafebabe?technique=warmup", &[], "").await;
1128
    // The shower is a +35 warm-up, so EQ lands in the band and the band bonus rides along.
1129
    // The arrival bonus is the one point the second jelq loses.
1130
    let first = send(&app, "JELQ", "/runs/cafebabe?technique=wet", &lube, "").await;
1131
    assert!(
1132
        header(&first, "server-timing").contains("d20+4"),
1133
        "the first jelq of a warm arrival rolls with +1, got {}",
1134
        header(&first, "server-timing")
1135
    );
1136
    let second = send(&app, "JELQ", "/runs/cafebabe?technique=wet", &lube, "").await;
1137
    assert!(header(&second, "server-timing").contains("d20+3"));
1138
}
1139
1140
#[tokio::test]
1141
async fn greed_multiplies_gains_above_the_band() {
1142
    let lube = [("content-type", "application/baby-oil")];
1143
    for seed in 0..60u64 {
1144
        let app = app_with(Floor::Bedroom, seed, |run| {
1145
            run.eq = 60;
1146
            run.warmed_up = true;
1147
        });
1148
        let in_band = send(&app, "JELQ", "/runs/cafebabe?technique=wet", &lube, "").await;
1149
        let app = app_with(Floor::Bedroom, seed, |run| {
1150
            run.eq = 75;
1151
            run.warmed_up = true;
1152
        });
1153
        let greedy = send(&app, "JELQ", "/runs/cafebabe?technique=wet", &lube, "").await;
1154
        if in_band.status() != StatusCode::OK || greedy.status() != StatusCode::OK {
1155
            continue;
1156
        }
1157
        let steady = json(in_band).await["length"].as_f64().unwrap();
1158
        let eager = json(greedy).await["length"].as_f64().unwrap();
1159
        assert!(
1160
            (eager - steady * 1.5).abs() < 1e-9,
1161
            "seed {seed} gained {eager} above the band against {steady} inside it"
1162
        );
1163
        return;
1164
    }
1165
    panic!("no seed in 60 produced two clean rolls to compare");
1166
}
1167
1168
#[tokio::test]
1169
async fn the_kitchen_is_only_on_the_home_floors() {
1170
    let absent = seeded_with(Floor::Bathroom, |_| {});
1171
    let no_kitchen = send(&absent, "POST", "/runs/cafebabe/kitchen", &[], "").await;
1172
    assert_eq!(no_kitchen.status(), StatusCode::NOT_FOUND);
1173
    let view = send(&absent, "GET", "/runs/cafebabe", &[], "").await;
1174
    assert!(!header(&view, "link").contains("/kitchen"));
1175
1176
    let app = seeded_with(Floor::Bedroom, |_| {});
1177
    let view = send(&app, "GET", "/runs/cafebabe", &[], "").await;
1178
    assert!(header(&view, "link").contains("/kitchen"));
1179
    let raid = send(&app, "POST", "/runs/cafebabe/kitchen", &[], "").await;
1180
    assert_ne!(raid.status(), StatusCode::NOT_FOUND);
1181
}
1182
1183
#[tokio::test]
1184
async fn raids_cost_the_turn_and_the_fridge_pays_out_sometimes() {
1185
    let mut bounties = std::collections::BTreeSet::new();
1186
    let mut told = 0;
1187
    for seed in 0..80u64 {
1188
        let mut run = Run::new("cafebabe".into(), "cafe".into(), seed);
1189
        let Ok(outcome) = run.raid_kitchen() else {
1190
            panic!("the first raid should never fail");
1191
        };
1192
        assert_eq!(run.turn, 1, "a raid costs the floor turn");
1193
        assert!(!run.here.melon_offered, "no melon at starting EQ");
1194
        if outcome.status == StatusCode::NO_CONTENT {
1195
            bounties.insert("empty");
1196
        } else {
1197
            bounties.insert("loot");
1198
        }
1199
        told += u32::from(run.kitchen_tellings);
1200
    }
1201
    assert!(bounties.contains("loot") && bounties.contains("empty"));
1202
    assert!(
1203
        (0..80).any(|seed| {
1204
            let mut run = Run::new("cafebabe".into(), "cafe".into(), seed);
1205
            run.raid_kitchen().is_ok() && run.kitchen_tellings == 0
1206
        }),
1207
        "most raids go unremarked"
1208
    );
1209
    assert!(told > 0, "someone should have been told across 80 raids");
1210
}
1211
1212
#[tokio::test]
1213
async fn the_watermelon_offers_itself_above_ninety() {
1214
    let mut offered = 0;
1215
    let mut caught = 0;
1216
    let mut survived = 0;
1217
    for seed in 0..120u64 {
1218
        let mut run = Run::new("cafebabe".into(), "cafe".into(), seed);
1219
        run.eq = 95;
1220
        let Ok(outcome) = run.raid_kitchen() else {
1221
            continue;
1222
        };
1223
        if outcome.status != StatusCode::MULTIPLE_CHOICES {
1224
            continue;
1225
        }
1226
        offered += 1;
1227
        let gains = run.length;
1228
        let disgrace = run.shame;
1229
        if run.commit_melon().is_err() {
1230
            caught += 1;
1231
            assert_eq!(run.ended.as_ref().unwrap().cause.slug(), "watermelon");
1232
        } else {
1233
            survived += 1;
1234
            assert_eq!(run.eq, 0);
1235
            assert_eq!(run.shame, disgrace + 5);
1236
            assert_eq!(run.length, gains, "the gains are kept");
1237
        }
1238
    }
1239
    assert!(
1240
        offered > 20,
1241
        "the melon should appear in two fifths of raids"
1242
    );
1243
    assert!(caught > 0 && survived > 0, "caught is one in three");
1244
}
1245
1246
#[tokio::test]
1247
async fn a_melon_offer_does_not_survive_a_non_kitchen_turn() {
1248
    for seed in 0..60u64 {
1249
        let jelq_app = app_with(Floor::Bedroom, seed, |run| {
1250
            run.warmed_up = true;
1251
            run.here.melon_offered = true;
1252
        });
1253
        let stroked = send(
1254
            &jelq_app,
1255
            "JELQ",
1256
            "/runs/cafebabe?technique=wet&intensity=2",
1257
            &[],
1258
            "",
1259
        )
1260
        .await;
1261
        if stroked.status() != StatusCode::OK && stroked.status() != StatusCode::PARTIAL_CONTENT {
1262
            continue;
1263
        }
1264
        let bite = send(
1265
            &jelq_app,
1266
            "POST",
1267
            "/runs/cafebabe/kitchen/watermelon",
1268
            &[],
1269
            "",
1270
        )
1271
        .await;
1272
        assert_eq!(
1273
            bite.status(),
1274
            StatusCode::CONFLICT,
1275
            "seed {seed} kept the melon through a jelq"
1276
        );
1277
1278
        let hold_app = app_with(Floor::Bedroom, seed, |run| {
1279
            run.stamina = 2;
1280
            run.here.melon_offered = true;
1281
        });
1282
        let held = send(&hold_app, "HOLD", "/runs/cafebabe", &[], "").await;
1283
        if held.status() != StatusCode::NO_CONTENT {
1284
            continue;
1285
        }
1286
        let bite = send(
1287
            &hold_app,
1288
            "POST",
1289
            "/runs/cafebabe/kitchen/watermelon",
1290
            &[],
1291
            "",
1292
        )
1293
        .await;
1294
        assert_eq!(
1295
            bite.status(),
1296
            StatusCode::CONFLICT,
1297
            "seed {seed} kept the melon through a hold"
1298
        );
1299
1300
        let read_app = app_with(Floor::Bedroom, seed, |run| {
1301
            run.story = Story::ClosedTab;
1302
            run.here.melon_offered = true;
1303
        });
1304
        let read = send(&read_app, "POST", "/runs/cafebabe/re-read", &[], "").await;
1305
        if read.status() == StatusCode::INTERNAL_SERVER_ERROR {
1306
            continue;
1307
        }
1308
        let bite = send(
1309
            &read_app,
1310
            "POST",
1311
            "/runs/cafebabe/kitchen/watermelon",
1312
            &[],
1313
            "",
1314
        )
1315
        .await;
1316
        assert_eq!(
1317
            bite.status(),
1318
            StatusCode::CONFLICT,
1319
            "seed {seed} kept the melon through a re-read"
1320
        );
1321
    }
1322
1323
    let mut stood = 0;
1324
    for seed in 0..60u64 {
1325
        let mut run = Run::new("cafebabe".into(), "cafe".into(), seed);
1326
        run.eq = 95;
1327
        run.here.melon_offered = true;
1328
        if run.raid_kitchen().is_err() {
1329
            continue;
1330
        }
1331
        stood += 1;
1332
        assert!(
1333
            run.here.melon_offered,
1334
            "seed {seed} lost the melon to another raid"
1335
        );
1336
    }
1337
    assert!(stood > 40, "raids at eq 95 rarely rupture");
1338
}
1339
1340
#[tokio::test]
1341
async fn the_kitchen_closes_after_two_tellings() {
1342
    let mut closed = None;
1343
    for seed in 0..300u64 {
1344
        let mut run = Run::new("cafebabe".into(), "cafe".into(), seed);
1345
        let first = run.raid_kitchen();
1346
        let second = run.raid_kitchen();
1347
        if second.is_err() && first.is_ok() {
1348
            assert_eq!(run.kitchen_tellings, 2);
1349
            let Err(third) = run.raid_kitchen() else {
1350
                panic!("the kitchen should be gone");
1351
            };
1352
            assert_eq!(third.status(), StatusCode::GONE);
1353
            closed = Some(seed);
1354
            break;
1355
        }
1356
    }
1357
    assert!(
1358
        closed.is_some(),
1359
        "two tellings should occur within 300 seeds"
1360
    );
1361
}
1362
1363
#[tokio::test]
1364
async fn lubes_run_out_but_spit_never_does() {
1365
    let app = seeded_with(Floor::Bedroom, |run| {
1366
        run.warmed_up = true;
1367
        run.items.baby_oil = 1;
1368
    });
1369
    let lube = [("content-type", "application/baby-oil")];
1370
    let first = send(&app, "JELQ", "/runs/cafebabe?technique=wet", &lube, "").await;
1371
    assert_ne!(first.status(), StatusCode::PRECONDITION_FAILED);
1372
    let second = send(&app, "JELQ", "/runs/cafebabe?technique=wet", &lube, "").await;
1373
    assert_eq!(second.status(), StatusCode::UNSUPPORTED_MEDIA_TYPE);
1374
1375
    let spit = [("content-type", "text/plain; charset=spit")];
1376
    for _ in 0..3 {
1377
        let wet = send(&app, "JELQ", "/runs/cafebabe?technique=wet", &spit, "").await;
1378
        assert_ne!(wet.status(), StatusCode::PRECONDITION_FAILED);
1379
    }
1380
}
1381
1382
#[tokio::test]
1383
async fn energy_drinks_add_three_stamina() {
1384
    let app = seeded_with(Floor::Bedroom, |run| {
1385
        run.stamina = 2;
1386
        run.items.energy_drinks = 1;
1387
    });
1388
    let view = send(&app, "GET", "/runs/cafebabe", &[], "").await;
1389
    assert!(header(&view, "link").contains("technique=energy"));
1390
    let drink = send(&app, "JELQ", "/runs/cafebabe?technique=energy", &[], "").await;
1391
    assert_eq!(drink.status(), StatusCode::OK);
1392
    let view = send(&app, "GET", "/runs/cafebabe", &[], "").await;
1393
    assert_eq!(header(&view, "x-stamina"), "5/10");
1394
    let again = send(&app, "JELQ", "/runs/cafebabe?technique=energy", &[], "").await;
1395
    assert_eq!(again.status(), StatusCode::FAILED_DEPENDENCY);
1396
}
1397
1398
#[tokio::test]
1399
async fn the_working_supplement_resets_stamina() {
1400
    let mut worked = 0;
1401
    let mut bunk = 0;
1402
    for seed in 0..60u64 {
1403
        let app = app_with(Floor::Bedroom, seed, |run| {
1404
            run.stamina = 1;
1405
            run.items.supplements = 1;
1406
        });
1407
        let taken = send(&app, "JELQ", "/runs/cafebabe?technique=supplement", &[], "").await;
1408
        if taken.status() == StatusCode::OK {
1409
            worked += 1;
1410
            let view = send(&app, "GET", "/runs/cafebabe", &[], "").await;
1411
            assert_eq!(header(&view, "x-stamina"), "10/10");
1412
        } else {
1413
            assert_eq!(taken.status(), StatusCode::BAD_GATEWAY);
1414
            bunk += 1;
1415
        }
1416
    }
1417
    assert!(worked > 0 && bunk > 0, "one in five should work too well");
1418
}
1419
1420
#[tokio::test]
1421
async fn dry_jelqs_build_friction_and_real_lube_does_not() {
1422
    let app = seeded_with(Floor::Bedroom, |run| {
1423
        run.warmed_up = true;
1424
        run.foreskin = Some(0.9);
1425
    });
1426
    let dry = send(&app, "JELQ", "/runs/cafebabe?technique=dry", &[], "").await;
1427
    assert_eq!(header(&dry, "x-friction"), "2/10");
1428
    let lube = [("content-type", "application/baby-oil")];
1429
    let wet = send(
1430
        &app,
1431
        "JELQ",
1432
        "/runs/cafebabe?technique=wet&intensity=5",
1433
        &lube,
1434
        "",
1435
    )
1436
    .await;
1437
    assert_eq!(header(&wet, "x-friction"), "2/10");
1438
    let spit = [("content-type", "text/plain; charset=spit")];
1439
    let glisten = send(
1440
        &app,
1441
        "JELQ",
1442
        "/runs/cafebabe?technique=wet&intensity=5",
1443
        &spit,
1444
        "",
1445
    )
1446
    .await;
1447
    assert_eq!(header(&glisten, "x-friction"), "3/10");
1448
}
1449
1450
#[tokio::test]
1451
async fn switching_grips_makes_the_friction_cheaper() {
1452
    let app = seeded_with(Floor::Bedroom, |run| {
1453
        run.warmed_up = true;
1454
        run.foreskin = Some(0.9);
1455
    });
1456
    let dry = send(&app, "JELQ", "/runs/cafebabe?technique=dry", &[], "").await;
1457
    assert_eq!(header(&dry, "x-friction"), "2/10");
1458
    send(
1459
        &app,
1460
        "GET",
1461
        "/runs/cafebabe/grip",
1462
        &[("upgrade", "underhand"), ("connection", "upgrade")],
1463
        "",
1464
    )
1465
    .await;
1466
    let switched = send(&app, "JELQ", "/runs/cafebabe?technique=dry", &[], "").await;
1467
    assert_eq!(header(&switched, "x-friction"), "3/10");
1468
    let settled = send(&app, "JELQ", "/runs/cafebabe?technique=dry", &[], "").await;
1469
    assert_eq!(header(&settled, "x-friction"), "5/10");
1470
}
1471
1472
#[tokio::test]
1473
async fn holding_and_meditating_shed_friction() {
1474
    let app = seeded_with(Floor::Bedroom, |run| {
1475
        run.eq = 80;
1476
        run.friction = 5;
1477
    });
1478
    let held = send(&app, "HOLD", "/runs/cafebabe", &[], "").await;
1479
    assert_eq!(held.status(), StatusCode::NO_CONTENT);
1480
    let view = send(&app, "GET", "/runs/cafebabe", &[], "").await;
1481
    assert_eq!(header(&view, "x-friction"), "3/10");
1482
1483
    let taught = seeded_with(Floor::Bedroom, |run| {
1484
        run.friction = 5;
1485
    });
1486
    let calmed = send(
1487
        &taught,
1488
        "JELQ",
1489
        "/runs/cafebabe?technique=meditate",
1490
        &[],
1491
        "",
1492
    )
1493
    .await;
1494
    assert_eq!(calmed.status(), StatusCode::OK);
1495
    let view = send(&taught, "GET", "/runs/cafebabe", &[], "").await;
1496
    assert_eq!(header(&view, "x-friction"), "3/10");
1497
}
1498
1499
#[tokio::test]
1500
async fn a_friction_burn_halves_the_gains() {
1501
    let lube = [("content-type", "application/baby-oil")];
1502
    let path = "/runs/cafebabe?technique=wet&intensity=5";
1503
    for seed in 0..60u64 {
1504
        let steady = app_with(Floor::Bedroom, seed, |run| {
1505
            run.warmed_up = true;
1506
            run.eq = 60;
1507
            run.items.baby_oil = 9;
1508
        });
1509
        let hurt_app = app_with(Floor::Bedroom, seed, |run| {
1510
            run.warmed_up = true;
1511
            run.eq = 60;
1512
            run.items.baby_oil = 9;
1513
            run.conditions.friction_burn = true;
1514
        });
1515
        let calm = send(&steady, "JELQ", path, &lube, "").await;
1516
        let hurt = send(&hurt_app, "JELQ", path, &lube, "").await;
1517
        if calm.status() != StatusCode::OK || hurt.status() != StatusCode::OK {
1518
            continue;
1519
        }
1520
        let calm_gain = json(calm).await["length"].as_f64().unwrap();
1521
        let hurt_gain = json(hurt).await["length"].as_f64().unwrap();
1522
        assert!(
1523
            (hurt_gain * 2.0 - calm_gain).abs() < 1e-9,
1524
            "seed {seed} burned {hurt_gain} against {calm_gain}"
1525
        );
1526
        return;
1527
    }
1528
    panic!("no seed in 60 produced two clean rolls to compare");
1529
}
1530
1531
#[tokio::test]
1532
async fn the_burn_warns_and_the_ice_treats_it() {
1533
    let app = seeded_with(Floor::Bedroom, |run| {
1534
        run.warmed_up = true;
1535
        run.eq = 60;
1536
        run.items.baby_oil = 9;
1537
        run.friction = 7;
1538
        run.conditions.friction_burn = true;
1539
    });
1540
    let lube = [("content-type", "application/baby-oil")];
1541
    let jelq = send(
1542
        &app,
1543
        "JELQ",
1544
        "/runs/cafebabe?technique=wet&intensity=5",
1545
        &lube,
1546
        "",
1547
    )
1548
    .await;
1549
    let warnings: Vec<_> = jelq
1550
        .headers()
1551
        .get_all("warning")
1552
        .iter()
1553
        .filter_map(|w| w.to_str().ok())
1554
        .collect();
1555
    assert!(warnings.contains(&"199 - \"friction burn\""));
1556
    send(&app, "JELQ", "/runs/cafebabe?technique=ice", &[], "").await;
1557
    let view = send(&app, "GET", "/runs/cafebabe", &[], "").await;
1558
    let warnings: Vec<_> = view
1559
        .headers()
1560
        .get_all("warning")
1561
        .iter()
1562
        .filter_map(|w| w.to_str().ok())
1563
        .collect();
1564
    assert!(!warnings.contains(&"199 - \"friction burn\""));
1565
}
1566
1567
#[tokio::test]
1568
async fn the_burn_shifts_the_rupture_tiers() {
1569
    async fn ruptures(burned: bool) -> u32 {
1570
        let mut gone = 0;
1571
        for seed in 0..400u64 {
1572
            let app = app_with(Floor::Bedroom, seed, |run| {
1573
                run.warmed_up = true;
1574
                run.eq = 60;
1575
                run.items.baby_oil = 9;
1576
                run.conditions.friction_burn = burned;
1577
            });
1578
            let lube = [("content-type", "application/baby-oil")];
1579
            let jelq = send(
1580
                &app,
1581
                "JELQ",
1582
                "/runs/cafebabe?technique=wet&intensity=5",
1583
                &lube,
1584
                "",
1585
            )
1586
            .await;
1587
            if jelq.status() == StatusCode::INTERNAL_SERVER_ERROR {
1588
                gone += 1;
1589
            }
1590
        }
1591
        gone
1592
    }
1593
    let burned = ruptures(true).await;
1594
    let calm = ruptures(false).await;
1595
    assert!(
1596
        burned >= 8 && calm <= 4,
1597
        "burned {burned} ruptures against {calm}"
1598
    );
1599
}
1600
1601
#[tokio::test]
1602
async fn meditation_respects_the_priapism_floor() {
1603
    let app = seeded_with(Floor::Bedroom, |run| {
1604
        run.eq = 80;
1605
        run.conditions.priapism_turns = 3;
1606
    });
1607
    let calmed = send(&app, "JELQ", "/runs/cafebabe?technique=meditate", &[], "").await;
1608
    assert_eq!(calmed.status(), StatusCode::OK);
1609
    let view = send(&app, "GET", "/runs/cafebabe", &[], "").await;
1610
    assert_eq!(header(&view, "x-eq"), "75");
1611
}
1612
1613
#[tokio::test]
1614
async fn the_kitchen_pays_out_at_the_documented_odds() {
1615
    let mut oil = 0;
1616
    let mut drinks = 0;
1617
    let mut packs = 0;
1618
    for seed in 0..600u64 {
1619
        let mut run = Run::new("cafebabe".into(), "cafe".into(), seed);
1620
        run.kitchen_tellings = 0;
1621
        let held = (
1622
            run.items.coconut_oil,
1623
            run.items.energy_drinks,
1624
            run.items.ice_packs,
1625
        );
1626
        let _ = run.raid_kitchen();
1627
        oil += u32::from(run.items.coconut_oil > held.0);
1628
        drinks += u32::from(run.items.energy_drinks > held.1);
1629
        packs += u32::from(run.items.ice_packs > held.2);
1630
    }
1631
    assert!((170..=230).contains(&oil), "coconut {oil} in 600");
1632
    assert!((90..=150).contains(&drinks), "energy {drinks} in 600");
1633
    assert!((45..=105).contains(&packs), "ice {packs} in 600");
1634
}
1635
1636
#[tokio::test]
1637
async fn a_refused_jelq_leaves_the_lube_alone() {
1638
    let app = seeded_with(Floor::Bedroom, |run| {
1639
        run.warmed_up = true;
1640
        run.items.extender = true;
1641
        run.girth = 6.0;
1642
        run.length = 6.0;
1643
    });
1644
    let lube = [("content-type", "application/baby-oil")];
1645
    let full = send(
1646
        &app,
1647
        "JELQ",
1648
        "/runs/cafebabe?technique=wet&intensity=5",
1649
        &lube,
1650
        "",
1651
    )
1652
    .await;
1653
    assert_eq!(full.status(), StatusCode::INSUFFICIENT_STORAGE);
1654
    let inv = send(&app, "GET", "/runs/cafebabe/inventory", &[], "").await;
1655
    let body = json(inv).await;
1656
    let held = body["items"].as_array().unwrap();
1657
    assert!(held.iter().any(|i| i["name"] == "baby-oil"));
1658
}
1659
1660
#[tokio::test]
1661
async fn an_empty_bottle_is_declined_before_the_enemy_stirs() {
1662
    let control = seeded_with(Floor::ChangingRoom, |run| {
1663
        run.warmed_up = true;
1664
        run.suspicion = SUSPICION_LIMIT;
1665
    });
1666
    let lube = [("content-type", "application/baby-oil")];
1667
    let caught = send(
1668
        &control,
1669
        "JELQ",
1670
        "/runs/cafebabe?technique=wet&intensity=5",
1671
        &lube,
1672
        "",
1673
    )
1674
    .await;
1675
    assert_eq!(caught.status(), StatusCode::UNAVAILABLE_FOR_LEGAL_REASONS);
1676
1677
    let app = seeded_with(Floor::ChangingRoom, |run| {
1678
        run.warmed_up = true;
1679
        run.suspicion = SUSPICION_LIMIT;
1680
        run.items.baby_oil = 0;
1681
    });
1682
    let refused = send(
1683
        &app,
1684
        "JELQ",
1685
        "/runs/cafebabe?technique=wet&intensity=5",
1686
        &lube,
1687
        "",
1688
    )
1689
    .await;
1690
    assert_eq!(refused.status(), StatusCode::UNSUPPORTED_MEDIA_TYPE);
1691
    let view = send(&app, "GET", "/runs/cafebabe", &[], "").await;
1692
    assert_eq!(header(&view, "x-suspicion"), "5/5");
1693
    assert_eq!(header(&view, "x-shame"), "0");
1694
}
1695
1696
#[tokio::test]
1697
async fn the_top_tier_rolls_a_single_third() {
1698
    let mut ruptures = 0;
1699
    let mut early = 0;
1700
    for seed in 0..400u64 {
1701
        let app = app_with(Floor::Bedroom, seed, |run| {
1702
            run.warmed_up = true;
1703
            run.eq = 96;
1704
            run.items.baby_oil = 9;
1705
            run.items.blinds = true;
1706
        });
1707
        let lube = [("content-type", "application/baby-oil")];
1708
        let jelq = send(
1709
            &app,
1710
            "JELQ",
1711
            "/runs/cafebabe?technique=wet&intensity=5",
1712
            &lube,
1713
            "",
1714
        )
1715
        .await;
1716
        match jelq.status() {
1717
            StatusCode::INTERNAL_SERVER_ERROR => ruptures += 1,
1718
            StatusCode::TOO_EARLY => early += 1,
1719
            other => panic!("seed {seed} ended in {other}"),
1720
        }
1721
    }
1722
    assert!(
1723
        (90..=175).contains(&ruptures),
1724
        "ruptures {ruptures} of 400 against {early} early"
1725
    );
1726
    assert!(
1727
        (225..=310).contains(&early),
1728
        "early {early} of 400 against {ruptures} ruptures"
1729
    );
1730
}
1731
1732
#[tokio::test]
1733
async fn priapism_holds_the_floor_through_every_reset() {
1734
    let exit = seeded_with(Floor::Bedroom, |run| {
1735
        run.warmed_up = true;
1736
        run.eq = 90;
1737
        run.here.turns = 99;
1738
        run.conditions.priapism_turns = 3;
1739
    });
1740
    let out = send(&exit, "POST", "/runs/cafebabe/floor", &[], "").await;
1741
    assert_eq!(out.status(), StatusCode::SEE_OTHER);
1742
    let view = send(&exit, "GET", "/runs/cafebabe", &[], "").await;
1743
    assert_eq!(header(&view, "x-eq"), "75");
1744
1745
    let melon = seeded_with(Floor::Bedroom, |run| {
1746
        run.eq = 95;
1747
        run.here.melon_offered = true;
1748
        run.conditions.priapism_turns = 2;
1749
    });
1750
    let bite = send(&melon, "POST", "/runs/cafebabe/kitchen/watermelon", &[], "").await;
1751
    assert_eq!(bite.status(), StatusCode::OK);
1752
    let view = send(&melon, "GET", "/runs/cafebabe", &[], "").await;
1753
    assert_eq!(header(&view, "x-eq"), "75");
1754
}
1755
1756
#[tokio::test]
1757
async fn holding_rolls_a_flat_rupture_chance() {
1758
    async fn ruptures(eq: i32, stamina: i32) -> u32 {
1759
        let mut gone = 0;
1760
        for seed in 0..600u64 {
1761
            let app = app_with(Floor::Bedroom, seed, |run| {
1762
                run.warmed_up = true;
1763
                run.eq = eq;
1764
                run.stamina = stamina;
1765
            });
1766
            let held = send(&app, "HOLD", "/runs/cafebabe", &[], "").await;
1767
            if held.status() == StatusCode::INTERNAL_SERVER_ERROR {
1768
                gone += 1;
1769
            }
1770
        }
1771
        gone
1772
    }
1773
    let high = ruptures(90, 10).await;
1774
    let low = ruptures(30, 2).await;
1775
    assert!((0..=9).contains(&high), "high holds ruptured {high}/600");
1776
    assert!((0..=9).contains(&low), "low holds ruptured {low}/600");
1777
}
1778
1779
#[tokio::test]
1780
async fn the_kitchen_rolls_the_normal_tiers() {
1781
    async fn ruptures(eq: i32) -> u32 {
1782
        let mut gone = 0;
1783
        for seed in 0..600u64 {
1784
            let app = app_with(Floor::Bedroom, seed, |run| run.eq = eq);
1785
            let raid = send(&app, "POST", "/runs/cafebabe/kitchen", &[], "").await;
1786
            if raid.status() == StatusCode::INTERNAL_SERVER_ERROR {
1787
                gone += 1;
1788
            }
1789
        }
1790
        gone
1791
    }
1792
    let hot = ruptures(96).await;
1793
    let cool = ruptures(40).await;
1794
    assert!((40..=80).contains(&hot), "hot kitchen ruptured {hot}/600");
1795
    assert!((0..=9).contains(&cool), "cool kitchen ruptured {cool}/600");
1796
}
1797
1798
#[tokio::test]
1799
async fn the_thread_cannot_be_reread_before_anyone_mentions_it() {
1800
    let app = seeded_with(Floor::Bedroom, |run| run.warmed_up = true);
1801
    let read = send(&app, "POST", "/runs/cafebabe/re-read", &[], "").await;
1802
    assert_eq!(read.status(), StatusCode::NOT_FOUND);
1803
    let after = send(&app, "GET", "/runs/cafebabe", &[], "").await;
1804
    assert_eq!(header(&after, "x-floor"), "1");
1805
    assert!(!header(&after, "link").contains("/re-read"));
1806
}
1807
1808
#[tokio::test]
1809
async fn any_other_action_takes_the_melon_off_the_table() {
1810
    for (method, uri) in [
1811
        ("JELQ", "/runs/cafebabe?technique=warmup"),
1812
        ("POST", "/runs/cafebabe/drawer"),
1813
    ] {
1814
        let app = seeded_with(Floor::Bedroom, |run| {
1815
            run.eq = 95;
1816
            run.here.melon_offered = true;
1817
        });
1818
        send(&app, method, uri, &[], "").await;
1819
        let melon = send(&app, "POST", "/runs/cafebabe/kitchen/watermelon", &[], "").await;
1820
        assert_eq!(melon.status(), StatusCode::CONFLICT, "{method} {uri}");
1821
    }
1822
}
1823
1824
#[tokio::test]
1825
async fn a_refused_jelq_does_not_break_the_edging_streak() {
1826
    let app = seeded_with(Floor::ChangingRoom, |run| {
1827
        run.warmed_up = true;
1828
        run.eq = 45;
1829
        run.here.edges = 2;
1830
    });
1831
    let refused = send(
1832
        &app,
1833
        "JELQ",
1834
        "/runs/cafebabe?technique=dry",
1835
        &[("content-type", "application/baby-oil")],
1836
        "",
1837
    )
1838
    .await;
1839
    assert_eq!(refused.status(), StatusCode::UNSUPPORTED_MEDIA_TYPE);
1840
    let third = send(&app, "JELQ", "/runs/cafebabe?technique=edge", &[], "").await;
1841
    assert_eq!(third.status(), StatusCode::LOOP_DETECTED);
1842
}
1843
1844
#[tokio::test]
1845
async fn an_interrupted_edge_keeps_no_gains() {
1846
    let app = seeded_with(Floor::ChangingRoom, |run| {
1847
        run.warmed_up = true;
1848
        run.eq = 10;
1849
    });
1850
    let before = send(&app, "GET", "/runs/cafebabe", &[], "").await;
1851
    let girth = header(&before, "x-girth").to_owned();
1852
    let edge = send(&app, "JELQ", "/runs/cafebabe?technique=edge", &[], "").await;
1853
    assert_eq!(edge.status(), StatusCode::NOT_FOUND);
1854
    assert_eq!(header(&edge, "x-girth"), girth);
1855
}
1856
1857
#[tokio::test]
1858
async fn rummaging_for_the_key_leaves_the_drawer_unsearched() {
1859
    let mut found = 0;
1860
    for seed in 0..20u64 {
1861
        let app = app_with(Floor::Hotel, seed, |run| {
1862
            run.here.wifi_accepted = true;
1863
            run.items.cage = true;
1864
        });
1865
        for _ in 0..30 {
1866
            let rummage = send(&app, "POST", "/runs/cafebabe/drawer", &[], "").await;
1867
            if rummage.status() == StatusCode::OK {
1868
                break;
1869
            }
1870
        }
1871
        let search = send(&app, "POST", "/runs/cafebabe/drawer", &[], "").await;
1872
        if search.status() == StatusCode::FOUND {
1873
            assert!(header(&search, "location").ends_with("/inventory/do-not-disturb-sign"));
1874
            found += 1;
1875
        }
1876
    }
1877
    assert!(
1878
        found >= 18,
1879
        "only {found} of 20 hotel drawers survived the rummage"
1880
    );
1881
}
1882
1883
#[tokio::test]
1884
async fn a_full_server_buries_the_dead_to_make_room() {
1885
    let app = crate::app(Config {
1886
        max_runs: 1,
1887
        ..Config::default()
1888
    });
1889
    let first = send(&app, "POST", "/runs", &[], "").await;
1890
    let location = header(&first, "location").to_owned();
1891
    let full = send(&app, "POST", "/runs", &[], "").await;
1892
    assert_eq!(full.status(), StatusCode::SERVICE_UNAVAILABLE);
1893
    send(&app, "DELETE", &location, &[], "").await;
1894
    let second = send(&app, "POST", "/runs", &[], "").await;
1895
    assert_eq!(second.status(), StatusCode::CREATED);
1896
}
1897
1898
#[tokio::test]
1899
async fn the_towel_warms_once_until_the_kettle_boils() {
1900
    let app = seeded_with(Floor::Bedroom, |_| {});
1901
    let warm = send(&app, "JELQ", "/runs/cafebabe?technique=warmup", &[], "").await;
1902
    assert_eq!(warm.status(), StatusCode::OK);
1903
    let again = send(&app, "JELQ", "/runs/cafebabe?technique=warmup", &[], "").await;
1904
    assert_eq!(again.status(), StatusCode::CONFLICT);
1905
1906
    let app = seeded_with(Floor::Bedroom, |run| {
1907
        run.warmed_up = true;
1908
        run.here.kettle_boiled = true;
1909
    });
1910
    let reheated = send(&app, "JELQ", "/runs/cafebabe?technique=warmup", &[], "").await;
1911
    assert_eq!(reheated.status(), StatusCode::OK);
1912
    let cold = send(&app, "JELQ", "/runs/cafebabe?technique=warmup", &[], "").await;
1913
    assert_eq!(cold.status(), StatusCode::CONFLICT);
1914
}
1915
1916
#[tokio::test]
1917
async fn the_refractory_period_is_the_next_hold() {
1918
    let app = seeded_with(Floor::Hotel, |run| {
1919
        run.here.wifi_accepted = true;
1920
        run.warmed_up = true;
1921
        run.eq = 60;
1922
        run.stamina = 0;
1923
        run.exhausted = true;
1924
    });
1925
    let tired = send(&app, "JELQ", "/runs/cafebabe?technique=edge", &[], "").await;
1926
    assert_eq!(tired.status(), StatusCode::TOO_MANY_REQUESTS);
1927
    let hold = send(&app, "HOLD", "/runs/cafebabe", &[], "").await;
1928
    assert_eq!(hold.status(), StatusCode::OK);
1929
    assert_eq!(header(&hold, "x-stamina"), "10/10");
1930
    let cold = send(&app, "JELQ", "/runs/cafebabe?technique=edge", &[], "").await;
1931
    assert_eq!(cold.status(), StatusCode::PRECONDITION_FAILED);
1932
}
1933
1934
#[tokio::test]
1935
async fn friction_rises_with_vigour_and_edging_chafes() {
1936
    let spit = [("content-type", "text/plain; charset=spit")];
1937
    for (intensity, friction) in [(5, "1/10"), (7, "2/10"), (10, "3/10")] {
1938
        let app = seeded_with(Floor::Hotel, |run| {
1939
            run.here.wifi_accepted = true;
1940
            run.warmed_up = true;
1941
            run.eq = 55;
1942
            run.foreskin = Some(0.5);
1943
        });
1944
        let uri = format!("/runs/cafebabe?technique=wet&intensity={intensity}");
1945
        let jelq = send(&app, "JELQ", &uri, &spit, "").await;
1946
        assert_eq!(
1947
            header(&jelq, "x-friction"),
1948
            friction,
1949
            "intensity {intensity}"
1950
        );
1951
    }
1952
    let app = seeded_with(Floor::Hotel, |run| {
1953
        run.here.wifi_accepted = true;
1954
        run.warmed_up = true;
1955
        run.eq = 55;
1956
        run.foreskin = Some(0.5);
1957
    });
1958
    let edge = send(&app, "JELQ", "/runs/cafebabe?technique=edge", &[], "").await;
1959
    assert_eq!(header(&edge, "x-friction"), "2/10");
1960
}
1961
1962
#[tokio::test]
1963
async fn the_body_changes_how_the_skin_takes_it() {
1964
    let spit = [("content-type", "text/plain; charset=spit")];
1965
    for (foreskin, girth, friction) in [(0.5, 4.5, "1/10"), (1.2, 4.5, "0/10"), (0.5, 5.8, "2/10")]
1966
    {
1967
        let app = seeded_with(Floor::Hotel, |run| {
1968
            run.here.wifi_accepted = true;
1969
            run.warmed_up = true;
1970
            run.eq = 55;
1971
            run.foreskin = Some(foreskin);
1972
            run.girth = girth;
1973
        });
1974
        let jelq = send(&app, "JELQ", "/runs/cafebabe?technique=wet", &spit, "").await;
1975
        assert_eq!(header(&jelq, "x-friction"), friction, "{foreskin} {girth}");
1976
    }
1977
}
1978
1979
#[tokio::test]
1980
async fn a_thick_girth_outgrows_the_underhand_grip() {
1981
    let app = seeded_with(Floor::Bedroom, |run| run.girth = 5.8);
1982
    let grip = send(
1983
        &app,
1984
        "GET",
1985
        "/runs/cafebabe/grip",
1986
        &[("upgrade", "underhand"), ("connection", "upgrade")],
1987
        "",
1988
    )
1989
    .await;
1990
    assert_eq!(grip.status(), StatusCode::PAYLOAD_TOO_LARGE);
1991
}
1992
1993
#[tokio::test]
1994
async fn respectable_girth_waves_off_the_gym_staff() {
1995
    for (girth, status) in [
1996
        (4.5, StatusCode::UNAVAILABLE_FOR_LEGAL_REASONS),
1997
        (5.2, StatusCode::OK),
1998
    ] {
1999
        let app = seeded_with(Floor::ChangingRoom, |run| {
2000
            run.warmed_up = true;
2001
            run.eq = 45;
2002
            run.girth = girth;
2003
            run.suspicion = SUSPICION_LIMIT;
2004
        });
2005
        let edge = send(&app, "JELQ", "/runs/cafebabe?technique=edge", &[], "").await;
2006
        if girth > 5.0 {
2007
            assert_ne!(edge.status(), StatusCode::UNAVAILABLE_FOR_LEGAL_REASONS);
2008
            assert!(header(&edge, "warning").contains("gym staff"));
2009
        } else {
2010
            assert_eq!(edge.status(), status);
2011
        }
2012
    }
2013
}
2014
2015
#[tokio::test]
2016
async fn kev_is_the_bar_in_the_changing_room() {
2017
    for (length, shamed) in [(5.0, true), (7.0, false)] {
2018
        let app = seeded_with(Floor::Bathroom, |run| {
2019
            run.length = length;
2020
            run.here.turns = 6;
2021
        });
2022
        let left = send(&app, "POST", "/runs/cafebabe/floor", &[], "").await;
2023
        assert_eq!(header(&left, "x-floor"), "3");
2024
        let warning = left
2025
            .headers()
2026
            .get("warning")
2027
            .map(|w| w.to_str().unwrap().to_owned());
2028
        assert_eq!(
2029
            warning.is_some_and(|w| w.contains("Kev")),
2030
            shamed,
2031
            "length {length}"
2032
        );
2033
    }
2034
}
2035
2036
#[tokio::test]
2037
async fn the_forum_respects_volume() {
2038
    let state = Arc::new(AppState::default());
2039
    for (id, length, girth) in [("slim", 7.0, 4.0), ("stout", 5.0, 5.5)] {
2040
        let mut run = Run::new(id.into(), id.into(), 1);
2041
        run.length = length;
2042
        run.girth = girth;
2043
        state.insert(run);
2044
    }
2045
    let app = router(state);
2046
    let board = json(
2047
        send(
2048
            &app,
2049
            "GET",
2050
            "/leaderboard?sort=volume&seeded=include",
2051
            &[],
2052
            "",
2053
        )
2054
        .await,
2055
    )
2056
    .await;
2057
    assert_eq!(board["entries"][0]["public_id"], "stout");
2058
    assert!(board["entries"][0]["volume"].as_f64().unwrap() > 0.0);
2059
}
2060
2061
#[tokio::test]
2062
async fn the_dead_outlive_the_server() {
2063
    let path = std::env::temp_dir().join(format!("jelqd-{}.sqlite", std::process::id()));
2064
    let config = || Config {
2065
        db: Some(path.clone()),
2066
        ..Config::default()
2067
    };
2068
    let first = crate::app(config());
2069
    let created = send(&first, "POST", "/runs", &[], "").await;
2070
    let location = header(&created, "location").to_owned();
2071
    send(&first, "DELETE", &location, &[], "").await;
2072
    drop(first);
2073
2074
    let second = crate::app(config());
2075
    let gone = send(&second, "GET", &location, &[], "").await;
2076
    assert_eq!(gone.status(), StatusCode::GONE);
2077
    let graveyard = json(send(&second, "GET", "/graveyard", &[], "").await).await;
2078
    assert_eq!(graveyard["runs"].as_array().unwrap().len(), 1);
2079
    let board = json(send(&second, "GET", "/leaderboard", &[], "").await).await;
2080
    assert_eq!(board["entries"][0]["status"], "gone");
2081
    for suffix in ["", "-wal", "-shm"] {
2082
        let _ = std::fs::remove_file(format!("{}{suffix}", path.display()));
2083
    }
2084
}
2085
2086
#[tokio::test]
2087
async fn pump_bro_charges_the_thin_more_for_girth() {
2088
    for (girth, ring, oil) in [(4.5, 11, 4), (5.2, 8, 4)] {
2089
        let app = seeded_with(Floor::ChangingRoom, |run| run.girth = girth);
2090
        let shop = json(send(&app, "GET", "/runs/cafebabe/shop", &[], "").await).await;
2091
        let price = |slug: &str| {
2092
            shop["stock"]
2093
                .as_array()
2094
                .unwrap()
2095
                .iter()
2096
                .find(|item| item["item"] == slug)
2097
                .unwrap()["price"]
2098
                .as_u64()
2099
                .unwrap()
2100
        };
2101
        assert_eq!(price("cock-ring"), ring, "girth {girth}");
2102
        assert_eq!(price("baby-oil"), oil, "girth {girth}");
2103
    }
2104
}
2105
2106
#[tokio::test]
2107
async fn a_long_run_fills_the_jeans_before_a_thick_one() {
2108
    let app = seeded_with(Floor::Bedroom, |run| {
2109
        run.warmed_up = true;
2110
        run.items.extender = true;
2111
        run.girth = 4.8;
2112
        run.length = 7.5;
2113
    });
2114
    let lube = [("content-type", "application/baby-oil")];
2115
    let full = send(&app, "JELQ", "/runs/cafebabe?technique=wet", &lube, "").await;
2116
    assert_eq!(full.status(), StatusCode::INSUFFICIENT_STORAGE);
2117
}