atagen

jelq

public

an http based roguelike rpg about jelqing

Star0
Watch0
0d604ee3975169bdb2d104b1e7423ace1c71e71a
1
ssh://brewery@pls.lobotomise.me:33/atagen/jelq.git
src/tests.rsRust826 lines26.1 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::{Floor, MumVisit, Run},
15
    router,
16
};
17
18
async fn send(
19
    app: &Router,
20
    method: &str,
21
    uri: &str,
22
    headers: &[(&str, &str)],
23
    body: &str,
24
) -> Response {
25
    let mut request = Request::builder()
26
        .method(Method::from_bytes(method.as_bytes()).unwrap())
27
        .uri(uri);
28
    for (name, value) in headers {
29
        request = request.header(*name, *value);
30
    }
31
    app.clone()
32
        .oneshot(request.body(Body::from(body.to_owned())).unwrap())
33
        .await
34
        .unwrap()
35
}
36
37
async fn json(response: Response) -> Value {
38
    serde_json::from_slice(&to_bytes(response.into_body(), usize::MAX).await.unwrap()).unwrap()
39
}
40
41
async fn text(response: Response) -> String {
42
    String::from_utf8(
43
        to_bytes(response.into_body(), usize::MAX)
44
            .await
45
            .unwrap()
46
            .to_vec(),
47
    )
48
    .unwrap()
49
}
50
51
fn header<'a>(response: &'a Response, name: &str) -> &'a str {
52
    response.headers()[name].to_str().unwrap()
53
}
54
55
fn fixture() -> Run {
56
    Run::new("cafebabe".into(), "cafe".into(), 69)
57
}
58
59
fn seeded_with(floor: Floor, setup: impl FnOnce(&mut Run)) -> Router {
60
    let state = Arc::new(AppState::default());
61
    let mut run = fixture();
62
    run.floor = floor;
63
    setup(&mut run);
64
    state.insert(run);
65
    router(state)
66
}
67
68
fn seeded(floor: Floor) -> (Router, String) {
69
    let state = Arc::new(AppState::default());
70
    let mut run = fixture();
71
    run.floor = floor;
72
    let etag = run.origin_etag();
73
    state.insert(run);
74
    (router(state), etag)
75
}
76
77
fn app() -> Router {
78
    crate::app(Config::default())
79
}
80
81
fn mum_at_the_door(run: &mut Run) {
82
    run.mum = Some(MumVisit {
83
        errand: "She heard a noise.",
84
        nonce: "abc".into(),
85
        strikes: 0,
86
        knocked: false,
87
    });
88
}
89
90
#[tokio::test]
91
async fn creating_a_run_returns_location_and_etag() {
92
    let response = send(&app(), "POST", "/runs?seed=1", &[], "").await;
93
    assert_eq!(response.status(), StatusCode::CREATED);
94
    assert!(header(&response, "location").starts_with("/runs/"));
95
    assert!(response.headers().contains_key(header::ETAG));
96
    assert_eq!(header(&response, "server"), "jelqd/0.6.9");
97
}
98
99
#[tokio::test]
100
async fn bodies_do_not_repeat_the_headers() {
101
    let (app, _) = seeded(Floor::Bedroom);
102
    let response = send(&app, "GET", "/runs/cafebabe", &[], "").await;
103
    assert!(response.headers().contains_key("x-eq"));
104
    let body = json(response).await;
105
    for stat in [
106
        "eq",
107
        "girth",
108
        "foreskin",
109
        "stamina",
110
        "cash",
111
        "self_reported_length",
112
    ] {
113
        assert!(body.get(stat).is_none(), "{stat} is already a header");
114
    }
115
}
116
117
#[tokio::test]
118
async fn jelqing_cold_fails_the_precondition() {
119
    let (app, _) = seeded(Floor::Bedroom);
120
    let response = send(&app, "JELQ", "/runs/cafebabe?technique=stretch", &[], "").await;
121
    assert_eq!(response.status(), StatusCode::PRECONDITION_FAILED);
122
    assert_eq!(
123
        header(&response, "content-type"),
124
        "application/problem+json"
125
    );
126
}
127
128
#[tokio::test]
129
async fn wet_without_lube_is_unsupported_media() {
130
    let (app, _) = seeded(Floor::Bedroom);
131
    send(&app, "JELQ", "/runs/cafebabe?technique=warmup", &[], "").await;
132
    let response = send(&app, "JELQ", "/runs/cafebabe?technique=wet", &[], "").await;
133
    assert_eq!(response.status(), StatusCode::UNSUPPORTED_MEDIA_TYPE);
134
    let images = send(
135
        &app,
136
        "JELQ",
137
        "/runs/cafebabe?technique=wet",
138
        &[("content-type", "image/jpeg")],
139
        "",
140
    )
141
    .await;
142
    assert_eq!(json(images).await["detail"], "No.");
143
}
144
145
#[tokio::test]
146
async fn unknown_methods_are_not_implemented() {
147
    let (app, _) = seeded(Floor::Bedroom);
148
    let response = send(&app, "MILK", "/runs/cafebabe", &[], "").await;
149
    assert_eq!(response.status(), StatusCode::NOT_IMPLEMENTED);
150
    let teapot = send(&app, "BREW", "/runs/cafebabe", &[], "").await;
151
    assert_eq!(teapot.status(), StatusCode::IM_A_TEAPOT);
152
}
153
154
#[tokio::test]
155
async fn the_clinic_does_not_advertise_jelq() {
156
    let (app, _) = seeded(Floor::Clinic);
157
    let options = send(&app, "OPTIONS", "/runs/cafebabe", &[], "").await;
158
    assert!(!header(&options, "allow").contains("JELQ"));
159
    let jelq = send(&app, "JELQ", "/runs/cafebabe?technique=wet", &[], "").await;
160
    assert_eq!(jelq.status(), StatusCode::METHOD_NOT_ALLOWED);
161
    assert!(!header(&jelq, "allow").contains("JELQ"));
162
    let (bedroom, _) = seeded(Floor::Bedroom);
163
    let options = send(&bedroom, "OPTIONS", "/runs/cafebabe", &[], "").await;
164
    assert!(header(&options, "allow").contains("JELQ"));
165
}
166
167
#[tokio::test]
168
async fn patch_only_accepts_the_truth() {
169
    let (app, _) = seeded(Floor::Bedroom);
170
    let patch = [("content-type", "application/json-patch+json")];
171
    let replace = send(
172
        &app,
173
        "PATCH",
174
        "/runs/cafebabe",
175
        &patch,
176
        r#"[{"op":"replace","path":"/length","value":12}]"#,
177
    )
178
    .await;
179
    assert_eq!(replace.status(), StatusCode::UNPROCESSABLE_ENTITY);
180
    let lie = send(
181
        &app,
182
        "PATCH",
183
        "/runs/cafebabe",
184
        &patch,
185
        r#"[{"op":"test","path":"/length","value":40}]"#,
186
    )
187
    .await;
188
    assert_eq!(
189
        json(lie).await["type"],
190
        "https://jelq.dev/problems/exaggeration"
191
    );
192
}
193
194
#[tokio::test]
195
async fn deleting_the_foreskin_requires_certainty() {
196
    let (app, _) = seeded(Floor::Bedroom);
197
    let response = send(&app, "DELETE", "/runs/cafebabe/foreskin", &[], "").await;
198
    assert_eq!(response.status(), StatusCode::PRECONDITION_REQUIRED);
199
    let look = send(&app, "GET", "/runs/cafebabe/foreskin", &[], "").await;
200
    assert_eq!(look.status(), StatusCode::NO_CONTENT);
201
    let etag = header(&look, "etag").to_owned();
202
    let response = send(
203
        &app,
204
        "DELETE",
205
        "/runs/cafebabe/foreskin",
206
        &[("if-match", &etag)],
207
        "",
208
    )
209
    .await;
210
    assert_eq!(response.status(), StatusCode::NO_CONTENT);
211
    let response = send(&app, "GET", "/runs/cafebabe/foreskin", &[], "").await;
212
    assert_eq!(response.status(), StatusCode::GONE);
213
}
214
215
#[tokio::test]
216
async fn length_is_chunked_until_the_ruler() {
217
    let (app, _) = seeded(Floor::Bedroom);
218
    let lie = send(&app, "GET", "/runs/cafebabe/length", &[], "").await;
219
    assert!(!lie.headers().contains_key(header::CONTENT_LENGTH));
220
    let app = seeded_with(Floor::Bedroom, |run| run.items.ruler = true);
221
    let truth = send(&app, "GET", "/runs/cafebabe/length", &[], "").await;
222
    let declared: usize = header(&truth, "content-length").parse().unwrap();
223
    let body = text(truth).await;
224
    assert_eq!(body.len(), declared);
225
    assert!(body.starts_with("8=") && body.ends_with('D'));
226
}
227
228
#[tokio::test]
229
async fn length_supports_inch_ranges() {
230
    let (app, _) = seeded(Floor::Bedroom);
231
    let response = send(
232
        &app,
233
        "GET",
234
        "/runs/cafebabe/length",
235
        &[("range", "inches=0-1")],
236
        "",
237
    )
238
    .await;
239
    assert_eq!(response.status(), StatusCode::PARTIAL_CONTENT);
240
    assert!(header(&response, "content-range").starts_with("inches 0.00-1.00/"));
241
    assert_eq!(text(response).await.len(), 25);
242
    let absurd = send(
243
        &app,
244
        "GET",
245
        "/runs/cafebabe/length",
246
        &[("range", "inches=40-50")],
247
        "",
248
    )
249
    .await;
250
    assert_eq!(absurd.status(), StatusCode::RANGE_NOT_SATISFIABLE);
251
}
252
253
#[tokio::test]
254
async fn the_clinic_escalates_toward_honesty() {
255
    let (app, _) = seeded(Floor::Clinic);
256
    let mut hints = Vec::new();
257
    for _ in 0..3 {
258
        let response = send(
259
            &app,
260
            "GET",
261
            "/runs/cafebabe/diagnosis",
262
            &[("authorization", "Excuse \"changing\"")],
263
            "",
264
        )
265
        .await;
266
        assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
267
        hints.push(header(&response, "www-authenticate").to_owned());
268
    }
269
    assert!(hints[2].contains("Be honest."));
270
}
271
272
#[tokio::test]
273
async fn the_true_ending_is_not_modified() {
274
    let (early, _) = seeded(Floor::Bedroom);
275
    let response = send(&early, "GET", "/runs/cafebabe/diagnosis", &[], "").await;
276
    assert_eq!(response.status(), StatusCode::TOO_EARLY);
277
278
    let (app, etag) = seeded(Floor::Clinic);
279
    assert!(
280
        !etag.contains("cafebabe"),
281
        "the origin ETag must not be derivable from the id"
282
    );
283
    let response = send(
284
        &app,
285
        "GET",
286
        "/runs/cafebabe/diagnosis",
287
        &[
288
            ("authorization", "Bearer honesty"),
289
            ("if-none-match", &etag),
290
        ],
291
        "",
292
    )
293
    .await;
294
    assert_eq!(response.status(), StatusCode::NOT_MODIFIED);
295
    assert_eq!(header(&response, "x-diagnosis"), "normal");
296
}
297
298
#[tokio::test]
299
async fn mum_accepts_a_known_excuse() {
300
    let app = seeded_with(Floor::Bedroom, mum_at_the_door);
301
    let response = send(&app, "GET", "/runs/cafebabe", &[], "").await;
302
    assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
303
    assert_eq!(header(&response, "via"), "1.1 mum");
304
    let response = send(
305
        &app,
306
        "GET",
307
        "/runs/cafebabe",
308
        &[("authorization", "Excuse \"homework\"")],
309
        "",
310
    )
311
    .await;
312
    assert_eq!(response.status(), StatusCode::OK);
313
}
314
315
#[tokio::test]
316
async fn mum_runs_out_of_patience() {
317
    let app = seeded_with(Floor::Bedroom, mum_at_the_door);
318
    let lie = [("authorization", "Excuse \"it is for science\"")];
319
    for _ in 0..2 {
320
        let response = send(&app, "GET", "/runs/cafebabe", &lie, "").await;
321
        assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
322
    }
323
    let caught = send(&app, "GET", "/runs/cafebabe", &lie, "").await;
324
    assert_eq!(caught.status(), StatusCode::GONE);
325
    assert_eq!(
326
        json(caught).await["type"],
327
        "https://jelq.dev/problems/caught"
328
    );
329
}
330
331
#[tokio::test]
332
async fn mum_remembers_excuses_by_christmas() {
333
    let app = seeded_with(Floor::Nans, |run| {
334
        mum_at_the_door(run);
335
        run.heard.push("homework".into());
336
    });
337
    let basic = send(
338
        &app,
339
        "GET",
340
        "/runs/cafebabe",
341
        &[("authorization", "Excuse \"changing\"")],
342
        "",
343
    )
344
    .await;
345
    assert_eq!(basic.status(), StatusCode::UNAUTHORIZED);
346
    assert!(header(&basic, "www-authenticate").starts_with("Digest"));
347
    let replay = send(
348
        &app,
349
        "GET",
350
        "/runs/cafebabe",
351
        &[(
352
            "authorization",
353
            "Digest username=\"homework\", nonce=\"abc\"",
354
        )],
355
        "",
356
    )
357
    .await;
358
    assert_eq!(
359
        json(replay).await["type"],
360
        "https://jelq.dev/problems/excuse-replayed"
361
    );
362
    let fresh = send(
363
        &app,
364
        "GET",
365
        "/runs/cafebabe",
366
        &[(
367
            "authorization",
368
            "Digest username=\"wrapping presents\", nonce=\"abc\"",
369
        )],
370
        "",
371
    )
372
    .await;
373
    assert_eq!(fresh.status(), StatusCode::OK);
374
}
375
376
#[tokio::test]
377
async fn a_pulled_muscle_is_a_true_excuse() {
378
    let app = seeded_with(Floor::Nans, |run| {
379
        mum_at_the_door(run);
380
        run.conditions.pulled_muscle = true;
381
        run.heard.push("pulled a muscle".into());
382
    });
383
    let response = send(
384
        &app,
385
        "GET",
386
        "/runs/cafebabe",
387
        &[(
388
            "authorization",
389
            "Digest username=\"pulled a muscle\", nonce=\"abc\"",
390
        )],
391
        "",
392
    )
393
    .await;
394
    assert_eq!(response.status(), StatusCode::OK);
395
    assert!(header(&response, "x-stamina").ends_with("/7"));
396
}
397
398
#[tokio::test]
399
async fn floors_end_when_they_end() {
400
    let app = seeded_with(Floor::Bedroom, |_| {});
401
    let early = send(&app, "POST", "/runs/cafebabe/floor", &[], "").await;
402
    assert_eq!(early.status(), StatusCode::TOO_EARLY);
403
    let app = seeded_with(Floor::Bedroom, |run| run.here.turns = 6);
404
    let next = send(&app, "POST", "/runs/cafebabe/floor", &[], "").await;
405
    assert!(
406
        next.status() == StatusCode::SEE_OTHER || next.status() == StatusCode::CONFLICT,
407
        "either the floor changed or the zip did"
408
    );
409
}
410
411
#[tokio::test]
412
async fn pump_bro_upsells() {
413
    let closed = seeded_with(Floor::Bedroom, |_| {});
414
    let response = send(&closed, "GET", "/runs/cafebabe/shop", &[], "").await;
415
    assert_eq!(response.status(), StatusCode::NOT_FOUND);
416
417
    let app = seeded_with(Floor::ChangingRoom, |_| {});
418
    let pump = send(&app, "POST", "/runs/cafebabe/shop/pump", &[], "").await;
419
    assert_eq!(pump.status(), StatusCode::UPGRADE_REQUIRED);
420
    assert_eq!(header(&pump, "upgrade"), "pump-pro/2.0");
421
    let pro = send(&app, "POST", "/runs/cafebabe/shop/pump-pro", &[], "").await;
422
    assert_eq!(pro.status(), StatusCode::CREATED);
423
    let broke = send(&app, "POST", "/runs/cafebabe/shop/extender", &[], "").await;
424
    assert_eq!(broke.status(), StatusCode::PAYMENT_REQUIRED);
425
}
426
427
#[tokio::test]
428
async fn only_the_key_opens_the_cage() {
429
    let app = seeded_with(Floor::ChangingRoom, |run| run.items.cage = true);
430
    send(&app, "POST", "/runs/cafebabe/shop/ice-pack", &[], "").await;
431
    let still = send(
432
        &app,
433
        "GET",
434
        "/runs/cafebabe/inventory/chastity-cage",
435
        &[],
436
        "",
437
    )
438
    .await;
439
    assert_eq!(still.status(), StatusCode::OK);
440
    send(&app, "POST", "/runs/cafebabe/shop/cage-key", &[], "").await;
441
    let gone = send(
442
        &app,
443
        "GET",
444
        "/runs/cafebabe/inventory/chastity-cage",
445
        &[],
446
        "",
447
    )
448
    .await;
449
    assert_eq!(gone.status(), StatusCode::NOT_FOUND);
450
}
451
452
#[tokio::test]
453
async fn combos_are_multi_status_xml() {
454
    let app = seeded_with(Floor::Bathroom, |run| {
455
        run.foreskin = Some(1.0);
456
        run.items.blinds = true;
457
    });
458
    send(&app, "JELQ", "/runs/cafebabe?technique=warmup", &[], "").await;
459
    let response = send(
460
        &app,
461
        "JELQ",
462
        "/runs/cafebabe?technique=wet,stretch",
463
        &[("content-type", "application/baby-oil")],
464
        "",
465
    )
466
    .await;
467
    assert_eq!(response.status(), StatusCode::MULTI_STATUS);
468
    assert!(text(response).await.contains("<D:multistatus"));
469
}
470
471
#[tokio::test]
472
async fn the_elder_is_stale() {
473
    let app = seeded_with(Floor::Forum, |run| run.length = 5.0);
474
    let unverified = send(&app, "GET", "/runs/cafebabe/elder", &[], "").await;
475
    assert_eq!(
476
        unverified.status(),
477
        StatusCode::PROXY_AUTHENTICATION_REQUIRED
478
    );
479
    let lie = send(
480
        &app,
481
        "GET",
482
        "/runs/cafebabe/elder",
483
        &[("proxy-authorization", "Ruler 9")],
484
        "",
485
    )
486
    .await;
487
    assert_eq!(lie.status(), StatusCode::BAD_REQUEST);
488
    let csv = send(
489
        &app,
490
        "GET",
491
        "/runs/cafebabe/elder",
492
        &[("proxy-authorization", "Ruler 5.0")],
493
        "",
494
    )
495
    .await;
496
    assert_eq!(csv.status(), StatusCode::OK);
497
    let stale = send(
498
        &app,
499
        "GET",
500
        "/runs/cafebabe/elder",
501
        &[("if-modified-since", "Mon, 15 Jun 2009 00:00:00 GMT")],
502
        "",
503
    )
504
    .await;
505
    assert_eq!(stale.status(), StatusCode::NOT_MODIFIED);
506
}
507
508
#[tokio::test]
509
async fn the_displayed_length_is_accepted_as_honest() {
510
    let app = seeded_with(Floor::Forum, |run| run.length = 5.2496);
511
    let response = send(
512
        &app,
513
        "GET",
514
        "/runs/cafebabe/elder",
515
        &[("proxy-authorization", "Ruler 5.25")],
516
        "",
517
    )
518
    .await;
519
    assert_eq!(response.status(), StatusCode::OK);
520
}
521
522
#[tokio::test]
523
async fn the_hotel_has_a_captive_portal() {
524
    let app = seeded_with(Floor::Hotel, |_| {});
525
    let blocked = send(&app, "GET", "/runs/cafebabe", &[], "").await;
526
    assert_eq!(
527
        blocked.status(),
528
        StatusCode::NETWORK_AUTHENTICATION_REQUIRED
529
    );
530
    let accepted = send(&app, "POST", "/runs/cafebabe/portal", &[], "").await;
531
    assert_eq!(accepted.status(), StatusCode::SEE_OTHER);
532
    let online = send(&app, "GET", "/runs/cafebabe", &[], "").await;
533
    assert_eq!(online.status(), StatusCode::OK);
534
}
535
536
#[tokio::test]
537
async fn the_internet_never_forgets() {
538
    let app = seeded_with(Floor::Bedroom, |run| {
539
        run.filmed_at = Some(std::time::SystemTime::now());
540
    });
541
    let footage = send(&app, "GET", "/runs/cafebabe/footage", &[], "").await;
542
    assert_eq!(footage.status(), StatusCode::OK);
543
    assert_eq!(
544
        header(&footage, "cache-control"),
545
        "public, max-age=31536000, immutable"
546
    );
547
    assert!(footage.headers().contains_key(header::AGE));
548
    let delete = send(&app, "DELETE", "/runs/cafebabe/footage", &[], "").await;
549
    assert_eq!(delete.status(), StatusCode::METHOD_NOT_ALLOWED);
550
    assert!(!header(&delete, "allow").contains("DELETE"));
551
}
552
553
#[tokio::test]
554
async fn forged_ruler_cookies_are_lies() {
555
    let forged = send(
556
        &app(),
557
        "GET",
558
        "/leaderboard?sort=content-length",
559
        &[("cookie", "ruler=1")],
560
        "",
561
    )
562
    .await;
563
    assert_eq!(forged.status(), StatusCode::BAD_REQUEST);
564
}
565
566
#[tokio::test]
567
async fn listings_never_reveal_the_private_id() {
568
    let app = app();
569
    let created = send(&app, "POST", "/runs", &[], "").await;
570
    let location = header(&created, "location").to_owned();
571
    let board = text(send(&app, "GET", "/leaderboard", &[], "").await).await;
572
    let private = location.trim_start_matches("/runs/");
573
    assert!(!board.contains(private));
574
    let entry: Value = serde_json::from_str(&board).unwrap();
575
    let public = entry["entries"][0]["public_id"].as_str().unwrap();
576
    let guess = send(&app, "DELETE", &format!("/runs/{public}"), &[], "").await;
577
    assert_eq!(guess.status(), StatusCode::NOT_FOUND);
578
}
579
580
#[tokio::test]
581
async fn seeded_runs_are_unranked() {
582
    let app = app();
583
    send(&app, "POST", "/runs?seed=7", &[], "").await;
584
    let board = json(send(&app, "GET", "/leaderboard", &[], "").await).await;
585
    assert_eq!(board["entries"].as_array().unwrap().len(), 0);
586
    let all = json(send(&app, "GET", "/leaderboard?seeded=include", &[], "").await).await;
587
    assert_eq!(all["entries"][0]["ranked"], false);
588
}
589
590
#[tokio::test]
591
async fn idle_runs_are_evicted_and_the_server_has_a_limit() {
592
    let forgetful = crate::app(Config {
593
        idle_ttl: Duration::ZERO,
594
        ..Config::default()
595
    });
596
    let first = send(&forgetful, "POST", "/runs", &[], "").await;
597
    let location = header(&first, "location").to_owned();
598
    send(&forgetful, "POST", "/runs", &[], "").await;
599
    let gone = send(&forgetful, "GET", &location, &[], "").await;
600
    assert_eq!(gone.status(), StatusCode::NOT_FOUND);
601
602
    let tiny = crate::app(Config {
603
        max_runs: 1,
604
        ..Config::default()
605
    });
606
    send(&tiny, "POST", "/runs", &[], "").await;
607
    let full = send(&tiny, "POST", "/runs", &[], "").await;
608
    assert_eq!(full.status(), StatusCode::SERVICE_UNAVAILABLE);
609
}
610
611
#[tokio::test]
612
async fn shame_from_two_runs_adds_up() {
613
    let state = Arc::new(AppState::default());
614
    state.insert(fixture());
615
    state.insert(Run::new("deadbeef".into(), "dead".into(), 7));
616
    let app = router(state);
617
    let first = send(&app, "TRACE", "/runs/cafebabe", &[], "").await;
618
    assert_eq!(header(&first, "x-shame"), "1");
619
    let cookie = header(&first, "set-cookie")
620
        .split(';')
621
        .next()
622
        .unwrap()
623
        .to_owned();
624
    let second = send(&app, "TRACE", "/runs/deadbeef", &[("cookie", &cookie)], "").await;
625
    assert_eq!(header(&second, "x-shame"), "2");
626
}
627
628
#[tokio::test]
629
async fn the_root_negotiates() {
630
    let app = app();
631
    let browser = send(&app, "GET", "/", &[("accept", "text/html,*/*")], "").await;
632
    assert_eq!(browser.status(), StatusCode::SEE_OTHER);
633
    assert_eq!(header(&browser, "location"), "/ui/");
634
    let curl = send(&app, "GET", "/", &[("accept", "*/*")], "").await;
635
    assert_eq!(json(curl).await["ui"], "/ui/");
636
    let page = send(&app, "GET", "/ui/", &[], "").await;
637
    assert_eq!(page.status(), StatusCode::OK);
638
}
639
640
#[tokio::test]
641
async fn the_ui_replays_what_browsers_cannot_send() {
642
    let app = app();
643
    let envelope =
644
        r#"{"method": "TRACE", "path": "/graveyard", "headers": [["X-Confession", "I did it"]]}"#;
645
    let traced = json(send(&app, "POST", "/ui/send", &[], envelope).await).await;
646
    assert_eq!(traced["status"], 405);
647
    let envelope = r#"{"method": "GET", "path": "/", "headers": [["Accept", "text/html"]]}"#;
648
    let redirected = json(send(&app, "POST", "/ui/send", &[], envelope).await).await;
649
    assert_eq!(redirected["status"], 303);
650
    assert_eq!(redirected["reason"], "See Other");
651
}
652
653
#[tokio::test]
654
async fn the_ui_decodes_gzip_for_display() {
655
    let app = seeded_with(Floor::Bedroom, |run| run.items.ring = true);
656
    let envelope =
657
        r#"{"method": "GET", "path": "/runs/cafebabe", "headers": [["Accept-Encoding", "gzip"]]}"#;
658
    let shown = json(send(&app, "POST", "/ui/send", &[], envelope).await).await;
659
    assert!(
660
        shown["body"]
661
            .as_str()
662
            .unwrap()
663
            .contains("\"public_id\":\"cafe\"")
664
    );
665
    let encoding = shown["headers"]
666
        .as_array()
667
        .unwrap()
668
        .iter()
669
        .any(|h| h[0] == "content-encoding" && h[1] == "gzip");
670
    assert!(encoding);
671
}
672
673
#[tokio::test]
674
async fn http_1_0_gets_the_pdf_treatment() {
675
    let request = Request::builder()
676
        .uri("/graveyard")
677
        .version(Version::HTTP_10)
678
        .body(Body::empty())
679
        .unwrap();
680
    let response = app().oneshot(request).await.unwrap();
681
    assert_eq!(response.status(), StatusCode::HTTP_VERSION_NOT_SUPPORTED);
682
}
683
684
#[tokio::test]
685
async fn mom_speaks_american() {
686
    let app = seeded_with(Floor::Nans, mum_at_the_door);
687
    let us = [("accept-language", "en-US")];
688
    let knock = send(&app, "GET", "/runs/cafebabe", &us, "").await;
689
    assert_eq!(header(&knock, "content-language"), "en-US");
690
    assert_eq!(header(&knock, "via"), "1.1 mom");
691
    assert!(
692
        json(knock).await["detail"]
693
            .as_str()
694
            .unwrap()
695
            .contains("Someone is at the door.")
696
    );
697
    let excuse = [
698
        ("accept-language", "en-US"),
699
        (
700
            "authorization",
701
            "Digest username=\"carving the turkey\", nonce=\"abc\"",
702
        ),
703
    ];
704
    let answered = send(&app, "GET", "/runs/cafebabe", &excuse, "").await;
705
    assert_eq!(answered.status(), StatusCode::OK);
706
    let floor = json(send(&app, "GET", "/runs/cafebabe/floor", &us, "").await).await;
707
    assert_eq!(floor["name"], "Grandma's house for Thanksgiving");
708
}
709
710
#[tokio::test]
711
async fn the_default_locale_is_british() {
712
    let (app, _) = seeded(Floor::Nans);
713
    let floor = send(&app, "GET", "/runs/cafebabe/floor", &[], "").await;
714
    assert_eq!(header(&floor, "content-language"), "en-GB");
715
    assert!(header(&floor, "vary").contains("Accept-Language"));
716
    assert_eq!(json(floor).await["name"], "Nan's house at Christmas");
717
}
718
719
fn advertised(response: &Response) -> Vec<String> {
720
    header(response, "link")
721
        .split(", <")
722
        .filter(|entry| entry.contains("rels/technique"))
723
        .filter_map(|entry| {
724
            entry
725
                .split("title=\"")
726
                .nth(1)?
727
                .split('"')
728
                .next()
729
                .map(str::to_owned)
730
        })
731
        .collect()
732
}
733
734
#[tokio::test]
735
async fn links_offer_only_usable_techniques() {
736
    let app = seeded_with(Floor::Bedroom, |run| run.foreskin = Some(1.0));
737
    let cold = send(&app, "GET", "/runs/cafebabe", &[], "").await;
738
    assert_eq!(advertised(&cold), ["warmup", "ice"]);
739
    let warm = send(&app, "JELQ", "/runs/cafebabe?technique=warmup", &[], "").await;
740
    let usable = advertised(&warm);
741
    assert!(usable.contains(&"wet".to_owned()) && usable.contains(&"dry".to_owned()));
742
    assert!(!usable.contains(&"uli".to_owned()), "uli needs the forum");
743
    assert!(!usable.contains(&"pump".to_owned()), "pump needs a pump");
744
    assert!(
745
        header(&warm, "link").contains("title=\"wet\"; allow=\"JELQ\"; lubricant=\"required\"")
746
    );
747
748
    let (forum, _) = seeded(Floor::Forum);
749
    let response = send(&forum, "GET", "/runs/cafebabe", &[], "").await;
750
    assert!(advertised(&response).is_empty());
751
}
752
753
fn linked_rels(response: &Response) -> Vec<String> {
754
    header(response, "link")
755
        .split(", <")
756
        .filter_map(|entry| {
757
            entry
758
                .split("rels/")
759
                .nth(1)?
760
                .split('"')
761
                .next()
762
                .map(str::to_owned)
763
        })
764
        .filter(|rel| rel != "technique")
765
        .collect()
766
}
767
768
#[tokio::test]
769
async fn links_hide_resources_that_would_do_nothing() {
770
    let (bedroom, _) = seeded(Floor::Bedroom);
771
    let before = linked_rels(&send(&bedroom, "GET", "/runs/cafebabe", &[], "").await);
772
    assert!(before.contains(&"drawer".to_owned()));
773
    assert!(!before.contains(&"diagnosis".to_owned()));
774
    assert!(!before.contains(&"thread".to_owned()));
775
    let searched = send(&bedroom, "POST", "/runs/cafebabe/drawer", &[], "").await;
776
    assert!(!linked_rels(&searched).contains(&"drawer".to_owned()));
777
778
    let (clinic, _) = seeded(Floor::Clinic);
779
    let rels = linked_rels(&send(&clinic, "GET", "/runs/cafebabe", &[], "").await);
780
    assert!(rels.contains(&"diagnosis".to_owned()) && rels.contains(&"thread".to_owned()));
781
}
782
783
#[tokio::test]
784
async fn ignoring_mum_has_consequences() {
785
    let app = seeded_with(Floor::Bedroom, |run| {
786
        mum_at_the_door(run);
787
        run.here.turns = 6;
788
    });
789
    let knock = send(&app, "GET", "/runs/cafebabe", &[], "").await;
790
    assert_eq!(
791
        json(knock).await["type"],
792
        "https://jelq.dev/problems/someone-at-the-door"
793
    );
794
    let leave = send(&app, "POST", "/runs/cafebabe/floor", &[], "").await;
795
    assert_eq!(
796
        leave.status(),
797
        StatusCode::UNAUTHORIZED,
798
        "no leaving with Mum at the door"
799
    );
800
    send(&app, "GET", "/runs/cafebabe", &[], "").await;
801
    let opened = send(&app, "GET", "/runs/cafebabe", &[], "").await;
802
    assert_eq!(opened.status(), StatusCode::GONE);
803
    assert_eq!(
804
        json(opened).await["type"],
805
        "https://jelq.dev/problems/opened"
806
    );
807
}
808
809
#[tokio::test]
810
async fn caged_drawers_are_noisy_and_eventually_give_up_the_key() {
811
    let app = seeded_with(Floor::ChangingRoom, |run| run.items.cage = true);
812
    let mut statuses = Vec::new();
813
    for _ in 0..12 {
814
        let response = send(&app, "POST", "/runs/cafebabe/drawer", &[], "").await;
815
        statuses.push(response.status());
816
        if response.status() == StatusCode::OK {
817
            break;
818
        }
819
    }
820
    assert_eq!(statuses.last(), Some(&StatusCode::OK), "{statuses:?}");
821
    assert!(
822
        statuses[..statuses.len() - 1]
823
            .iter()
824
            .all(|s| *s == StatusCode::NO_CONTENT)
825
    );
826
}