This repository has no description
1use std::sync::Arc;
2
3use axum::body::{Body, to_bytes};
4use bobbin_edge_index::{CoverageWatch, EdgeStore, IssueStateKind, PullStatusKind, StateIndex};
5use bobbin_knot_proxy::{KnotHttpConfig, KnotProxy, KnotProxyConfig};
6use bobbin_record_lru::{CacheCapacity, LruRecordStore};
7use bobbin_resolver::RepoIdResolver;
8use bobbin_runtime::{RuntimeHasher, SystemClock};
9use bobbin_search::{DEFAULT_WRITER_HEAP_BYTES, SearchIndex, SearchReader};
10use bobbin_slingshot_client::SlingshotClient;
11use bobbin_types::edges::Edge;
12use bobbin_types::ids::SubjectRef;
13use bobbin_xrpc::{AppState, router};
14use http::{Request, StatusCode};
15use jacquard_common::DefaultStr;
16use jacquard_common::types::did::Did;
17use jacquard_common::types::nsid::Nsid;
18use jacquard_common::types::string::AtUri;
19use serde_json::{Value, json};
20use tower::ServiceExt;
21use url::Url;
22use wiremock::matchers::{method, path, query_param};
23use wiremock::{Mock, MockServer, ResponseTemplate};
24
25const CID: &str = "bafyreieqygohnz2zqyvtvktbjpvhutphobcmbsnt4q5lc36ri7vpcmoz4i";
26
27const COUNT: &str = "sh.tangled.query.enrichResponse#count";
28const DISTINCT_AUTHORS: &str = "sh.tangled.query.enrichResponse#distinctAuthors";
29const VIEWER: &str = "sh.tangled.query.enrichResponse#viewer";
30const MINIDOC: &str = "com.bad-example.identity.miniDoc";
31
32fn at(s: &str) -> AtUri<DefaultStr> {
33 AtUri::new_owned(s).unwrap()
34}
35
36fn did(s: &str) -> Did<DefaultStr> {
37 Did::new_owned(s).unwrap()
38}
39
40fn nsid(s: &'static str) -> Nsid<DefaultStr> {
41 Nsid::new_static(s).unwrap()
42}
43
44static EDGE_COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
45
46fn next_sort_micros() -> u64 {
47 EDGE_COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
48}
49
50struct Harness {
51 server: MockServer,
52 edges: Arc<EdgeStore>,
53 state: AppState,
54}
55
56impl Harness {
57 async fn new() -> Self {
58 let server = MockServer::start().await;
59 let edges = Arc::new(EdgeStore::new(RuntimeHasher::default()));
60 let coverage = Arc::new(CoverageWatch::new());
61 let state = AppState::new(
62 Arc::new(LruRecordStore::new(CacheCapacity::from_bytes(64 * 1024))),
63 SlingshotClient::with_default_http(Url::parse(&server.uri()).unwrap()).unwrap(),
64 edges.clone(),
65 Arc::new(StateIndex::<IssueStateKind>::new(RuntimeHasher::default())),
66 Arc::new(StateIndex::<PullStatusKind>::new(RuntimeHasher::default())),
67 coverage.clone(),
68 Arc::new(
69 KnotProxy::new(
70 KnotProxyConfig::default(),
71 KnotHttpConfig::default(),
72 Arc::new(SystemClock::new()),
73 RuntimeHasher::default(),
74 )
75 .unwrap(),
76 ),
77 Arc::new(
78 SearchIndex::new(DEFAULT_WRITER_HEAP_BYTES, Arc::new(SystemClock::new())).unwrap(),
79 ) as Arc<dyn SearchReader>,
80 Arc::new(RepoIdResolver::detached(RuntimeHasher::default())),
81 Arc::new(bobbin_xrpc::default_directory()),
82 );
83 Self {
84 server,
85 edges,
86 state,
87 }
88 }
89
90 fn add_edge(&self, kind: &'static str, subject: SubjectRef, source: &AtUri<DefaultStr>) {
91 self.edges.add(Edge {
92 kind: nsid(kind),
93 subject,
94 source: source.clone(),
95 sort_micros: next_sort_micros(),
96 });
97 }
98
99 async fn mount(&self, did: &Did<DefaultStr>, collection: &str, rkey: &str, value: Value) {
100 let uri = format!("at://{}/{}/{}", did.as_ref(), collection, rkey);
101 let body = json!({ "uri": uri, "cid": CID, "value": value });
102 Mock::given(method("GET"))
103 .and(path("/xrpc/com.atproto.repo.getRecord"))
104 .and(query_param("repo", did.as_ref()))
105 .and(query_param("collection", collection))
106 .and(query_param("rkey", rkey))
107 .respond_with(ResponseTemplate::new(200).set_body_json(body))
108 .mount(&self.server)
109 .await;
110 }
111}
112
113fn enrich_request(body: Value) -> Request<Body> {
114 Request::builder()
115 .method("POST")
116 .uri("/xrpc/sh.tangled.query.enrichResponse")
117 .header("content-type", "application/json")
118 .body(Body::from(serde_json::to_vec(&body).unwrap()))
119 .unwrap()
120}
121
122async fn json_response(resp: axum::response::Response) -> (StatusCode, Value) {
123 let status = resp.status();
124 let bytes = to_bytes(resp.into_body(), 1 << 20).await.unwrap();
125 let parsed: Value = serde_json::from_slice(&bytes).expect("JSON body");
126 (status, parsed)
127}
128
129fn repo_body(name: &str, repo_did: &Did<DefaultStr>) -> Value {
130 json!({
131 "$type": "sh.tangled.repo",
132 "name": name,
133 "knot": "oyster.cafe",
134 "repoDid": repo_did.as_ref(),
135 "createdAt": "2026-05-01T00:00:00Z"
136 })
137}
138
139fn follow_body(subject: &Did<DefaultStr>) -> Value {
140 json!({
141 "$type": "sh.tangled.graph.follow",
142 "createdAt": "2026-05-01T00:00:00Z",
143 "subject": subject.as_ref()
144 })
145}
146
147/// one repo owned by `owner`, with `stars`/`issues` counts against its repo did
148async fn repo_fixture(h: &Harness, owner: &Did<DefaultStr>, repo_did: &Did<DefaultStr>) {
149 let repo_uri = at(&format!("at://{}/sh.tangled.repo/reef", owner.as_ref()));
150 h.add_edge("sh.tangled.repo", SubjectRef::Did(owner.clone()), &repo_uri);
151 h.mount(
152 owner,
153 "sh.tangled.repo",
154 "reef",
155 repo_body("reef", repo_did),
156 )
157 .await;
158 for (i, stargazer) in ["did:plc:a", "did:plc:b", "did:plc:a"].iter().enumerate() {
159 h.add_edge(
160 "sh.tangled.feed.star",
161 SubjectRef::Did(repo_did.clone()),
162 &at(&format!("at://{stargazer}/sh.tangled.feed.star/s{i}")),
163 );
164 }
165 h.add_edge(
166 "sh.tangled.repo.issue",
167 SubjectRef::Did(repo_did.clone()),
168 &at("at://did:plc:a/sh.tangled.repo.issue/i0"),
169 );
170}
171
172#[tokio::test]
173async fn zero_config_counts_stars_and_issues_for_repo_did() {
174 let h = Harness::new().await;
175 let owner = did("did:plc:nel");
176 let repo_did = did("did:plc:limpet");
177 repo_fixture(&h, &owner, &repo_did).await;
178
179 let app = router(h.state.clone());
180 let (status, body) = json_response(
181 app.oneshot(enrich_request(json!({
182 "xrpc": "sh.tangled.repo.listRepos",
183 "params": { "subject": owner.as_ref() },
184 "enrich": [
185 { "source": "sh.tangled.feed.star:subject", "type": COUNT },
186 { "source": "sh.tangled.feed.star:subject", "type": DISTINCT_AUTHORS },
187 { "source": "sh.tangled.repo.issue:subject", "type": COUNT }
188 ]
189 })))
190 .await
191 .unwrap(),
192 )
193 .await;
194
195 assert_eq!(status, StatusCode::OK, "{body}");
196 assert_eq!(body["output"]["items"].as_array().unwrap().len(), 1);
197 let stats = &body["data"];
198 assert_eq!(
199 stats["did:plc:limpet"]["sh.tangled.feed.star:subject"][COUNT],
200 json!(3)
201 );
202 assert_eq!(
203 stats["did:plc:limpet"]["sh.tangled.feed.star:subject"][DISTINCT_AUTHORS],
204 json!(2)
205 );
206 assert_eq!(
207 stats["did:plc:limpet"]["sh.tangled.repo.issue:subject"][COUNT],
208 json!(1)
209 );
210 assert!(stats["at://did:plc:nel/sh.tangled.repo/reef"].is_null());
211}
212
213#[tokio::test]
214async fn follow_counts_cover_both_directions() {
215 let h = Harness::new().await;
216 let owner = did("did:plc:nel");
217 // followers, edges pointing at owner
218 for (i, fan) in ["did:plc:a", "did:plc:b"].iter().enumerate() {
219 h.add_edge(
220 "sh.tangled.graph.follow",
221 SubjectRef::Did(owner.clone()),
222 &at(&format!("at://{fan}/sh.tangled.graph.follow/f{i}")),
223 );
224 h.mount(
225 &did(fan),
226 "sh.tangled.graph.follow",
227 &format!("f{i}"),
228 follow_body(&owner),
229 )
230 .await;
231 }
232 // following, via the .by mirror edge since owner is the author here
233 h.add_edge(
234 "sh.tangled.graph.follow.by",
235 SubjectRef::Did(owner.clone()),
236 &at("at://did:plc:nel/sh.tangled.graph.follow/f0"),
237 );
238
239 let app = router(h.state.clone());
240 let (status, body) = json_response(
241 app.oneshot(enrich_request(json!({
242 "xrpc": "sh.tangled.graph.listFollows",
243 "params": { "subject": owner.as_ref() },
244 "enrich": [{ "source": "sh.tangled.graph.follow:subject", "type": COUNT }, { "source": "sh.tangled.graph.follow:.repo", "type": COUNT }]
245 })))
246 .await
247 .unwrap(),
248 )
249 .await;
250
251 assert_eq!(status, StatusCode::OK, "{body}");
252 let nel = &body["data"]["did:plc:nel"];
253 assert_eq!(
254 nel["sh.tangled.graph.follow:subject"][COUNT],
255 json!(2),
256 "{body}"
257 );
258 assert_eq!(
259 nel["sh.tangled.graph.follow:.repo"][COUNT],
260 json!(1),
261 "{body}"
262 );
263}
264
265// at-uri authorities join the ref set, so record authors get stats keyed by
266// their bare did without appearing as a value anywhere in the response
267#[tokio::test]
268async fn authorities_of_record_uris_become_refs() {
269 let h = Harness::new().await;
270 let owner = did("did:plc:nel");
271 for (i, fan) in ["did:plc:a", "did:plc:b"].iter().enumerate() {
272 h.add_edge(
273 "sh.tangled.graph.follow",
274 SubjectRef::Did(owner.clone()),
275 &at(&format!("at://{fan}/sh.tangled.graph.follow/f{i}")),
276 );
277 h.mount(
278 &did(fan),
279 "sh.tangled.graph.follow",
280 &format!("f{i}"),
281 follow_body(&owner),
282 )
283 .await;
284 }
285 // each fan also follows one other person
286 for (i, fan) in ["did:plc:a", "did:plc:b"].iter().enumerate() {
287 h.add_edge(
288 "sh.tangled.graph.follow.by",
289 SubjectRef::Did(did(fan)),
290 &at(&format!("at://{fan}/sh.tangled.graph.follow/g{i}")),
291 );
292 }
293
294 let app = router(h.state.clone());
295 let (status, body) = json_response(
296 app.oneshot(enrich_request(json!({
297 "xrpc": "sh.tangled.graph.listFollows",
298 "params": { "subject": owner.as_ref() },
299 "enrich": [
300 { "source": "sh.tangled.graph.follow:subject", "type": COUNT },
301 { "source": "sh.tangled.graph.follow:.repo", "type": COUNT }
302 ]
303 })))
304 .await
305 .unwrap(),
306 )
307 .await;
308
309 assert_eq!(status, StatusCode::OK, "{body}");
310 for fan in ["did:plc:a", "did:plc:b"] {
311 let entry = &body["data"][fan];
312 assert_eq!(
313 entry["sh.tangled.graph.follow:subject"][COUNT],
314 json!(0),
315 "{body}"
316 );
317 assert_eq!(
318 entry["sh.tangled.graph.follow:.repo"][COUNT],
319 json!(1),
320 "{body}"
321 );
322 }
323}
324
325#[tokio::test]
326async fn sources_scope_which_refs_get_enriched() {
327 let h = Harness::new().await;
328 let owner = did("did:plc:nel");
329 let repo_did = did("did:plc:limpet");
330 repo_fixture(&h, &owner, &repo_did).await;
331
332 let app = router(h.state.clone());
333 let (status, body) = json_response(
334 app.oneshot(enrich_request(json!({
335 "xrpc": "sh.tangled.repo.listRepos",
336 "params": { "subject": owner.as_ref() },
337 "enrich": [{ "source": "sh.tangled.feed.star:subject", "type": COUNT }],
338 "sources": ["items[].value.repoDid"]
339 })))
340 .await
341 .unwrap(),
342 )
343 .await;
344 assert_eq!(status, StatusCode::OK, "{body}");
345 let stats = &body["data"];
346 assert_eq!(
347 stats["did:plc:limpet"]["sh.tangled.feed.star:subject"][COUNT],
348 json!(3)
349 );
350 assert_eq!(stats.as_object().unwrap().len(), 1, "{body}");
351
352 // a path matching nothing is empty stats, not an error, since selection is vector-matched
353 let app = router(h.state.clone());
354 let (status, body) = json_response(
355 app.oneshot(enrich_request(json!({
356 "xrpc": "sh.tangled.repo.listRepos",
357 "params": { "subject": owner.as_ref() },
358 "enrich": [{ "source": "sh.tangled.feed.star:subject", "type": COUNT }],
359 "sources": ["items[].value.nope"]
360 })))
361 .await
362 .unwrap(),
363 )
364 .await;
365 assert_eq!(status, StatusCode::OK, "{body}");
366 assert_eq!(body["data"], json!({}));
367}
368
369#[tokio::test]
370async fn inner_record_miss_passes_through_as_404() {
371 let h = Harness::new().await;
372 let app = router(h.state.clone());
373 let (status, body) = json_response(
374 app.oneshot(enrich_request(json!({
375 "xrpc": "sh.tangled.repo.getRepo",
376 "params": { "repo": "at://did:plc:nel/sh.tangled.repo/absent" },
377 "enrich": [{ "source": "sh.tangled.feed.star:subject", "type": COUNT }]
378 })))
379 .await
380 .unwrap(),
381 )
382 .await;
383 assert_eq!(status, StatusCode::NOT_FOUND, "{body}");
384 assert_eq!(body["error"], json!("RecordNotFound"));
385}
386
387#[tokio::test]
388async fn rejects_bad_requests() {
389 let h = Harness::new().await;
390 // semantic rejections: the descriptor parses, the handler refuses it
391 let handler_cases = [
392 json!({ "xrpc": "sh.tangled.nope.nope", "enrich": [] }),
393 json!({
394 "xrpc": "sh.tangled.repo.countRepos",
395 "params": { "subject": "did:plc:nel" },
396 "enrich": [{ "source": "sh.tangled.nope:subject", "type": COUNT }]
397 }),
398 json!({
399 "xrpc": "sh.tangled.repo.countRepos",
400 "params": { "subject": "did:plc:nel" },
401 "enrich": [{ "source": "sh.tangled.feed.star:subject", "type": "sh.tangled.query.enrichResponse#bogus" }]
402 }),
403 json!({
404 "xrpc": "sh.tangled.repo.countRepos",
405 "params": { "subject": "did:plc:nel" },
406 "enrich": [{ "source": "sh.tangled.feed.star:subject", "type": COUNT }],
407 "sources": ["items["]
408 }),
409 ];
410 for case in handler_cases {
411 let app = router(h.state.clone());
412 let (status, body) = json_response(app.oneshot(enrich_request(case)).await.unwrap()).await;
413 assert_eq!(status, StatusCode::BAD_REQUEST, "{body}");
414 assert_eq!(body["error"], json!("InvalidRequest"), "{body}");
415 }
416
417 // structural rejections: serde refuses the descriptor before the handler
418 // sees it, which is plain-text 422 rather than our 400 json body
419 let serde_cases = [
420 json!({
421 "xrpc": "sh.tangled.repo.countRepos",
422 "params": { "subject": "did:plc:nel" },
423 "enrich": [{ "source": "sh.tangled.feed.star:subject" }]
424 }),
425 json!({
426 "xrpc": "sh.tangled.repo.countRepos",
427 "params": { "subject": "did:plc:nel" },
428 "enrich": [{ "source": "sh.tangled.feed.star", "type": COUNT }]
429 }),
430 json!({
431 "xrpc": "sh.tangled.repo.countRepos",
432 "params": { "subject": "did:plc:nel" },
433 "enrich": [{ "source": "sh.tangled.feed.star:.rkey", "type": COUNT }]
434 }),
435 json!({
436 "xrpc": "sh.tangled.repo.countRepos",
437 "params": { "subject": "did:plc:nel" },
438 "enrich": [{ "source": "sh.tangled.feed.star:subject.uri", "type": COUNT }]
439 }),
440 ];
441 for case in serde_cases {
442 let app = router(h.state.clone());
443 let response = app.oneshot(enrich_request(case)).await.unwrap();
444 assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY);
445 }
446}
447
448#[tokio::test]
449async fn viewer_aggregation_uses_explicit_viewer_param() {
450 let h = Harness::new().await;
451 let owner = did("did:plc:abc");
452 let repo_did = did("did:plc:limpet");
453 repo_fixture(&h, &owner, &repo_did).await;
454
455 // the viewer already starred this repo, for the checks below
456 let subject = SubjectRef::Did(repo_did.clone());
457 h.state.edges.add(Edge {
458 kind: nsid("sh.tangled.feed.star"),
459 subject,
460 source: at("at://did:plc:nel/sh.tangled.feed.star/r99"),
461 sort_micros: 99,
462 });
463
464 let app = router(h.state.clone());
465
466 // missing viewer param is a 400, viewer descriptors require it
467 let no_viewer = json!({
468 "xrpc": "sh.tangled.repo.listRepos",
469 "params": { "subject": owner.as_ref() },
470 "enrich": [{ "source": "sh.tangled.feed.star:subject", "type": VIEWER }]
471 });
472 let (status, _) = json_response(
473 app.clone()
474 .oneshot(enrich_request(no_viewer))
475 .await
476 .unwrap(),
477 )
478 .await;
479 assert_eq!(status, StatusCode::BAD_REQUEST);
480
481 // viewer who starred it gets their own star uri back
482 let starred_viewer = json!({
483 "xrpc": "sh.tangled.repo.listRepos",
484 "params": { "subject": owner.as_ref() },
485 "enrich": [{ "source": "sh.tangled.feed.star:subject", "type": VIEWER }],
486 "viewer": "did:plc:nel"
487 });
488 let (status, resp) = json_response(
489 app.clone()
490 .oneshot(enrich_request(starred_viewer))
491 .await
492 .unwrap(),
493 )
494 .await;
495 assert_eq!(status, StatusCode::OK);
496 let stats = &resp["data"][repo_did.as_str()]["sh.tangled.feed.star:subject"];
497 assert_eq!(
498 stats[VIEWER],
499 json!("at://did:plc:nel/sh.tangled.feed.star/r99")
500 );
501
502 // a viewer who never starred it gets an explicit null, not absent
503 let other_viewer = json!({
504 "xrpc": "sh.tangled.repo.listRepos",
505 "params": { "subject": owner.as_ref() },
506 "enrich": [{ "source": "sh.tangled.feed.star:subject", "type": VIEWER }],
507 "viewer": "did:plc:someoneelse"
508 });
509 let (status, resp) =
510 json_response(app.oneshot(enrich_request(other_viewer)).await.unwrap()).await;
511 assert_eq!(status, StatusCode::OK);
512 let stats = &resp["data"][repo_did.as_str()]["sh.tangled.feed.star:subject"];
513 assert_eq!(stats[VIEWER], Value::Null);
514}
515
516#[tokio::test]
517async fn minidoc_payloads_resolve_record_authors() {
518 let h = Harness::new().await;
519 let owner = did("did:plc:nel");
520 for (i, fan) in ["did:plc:a", "did:plc:b"].iter().enumerate() {
521 h.add_edge(
522 "sh.tangled.graph.follow",
523 SubjectRef::Did(owner.clone()),
524 &at(&format!("at://{fan}/sh.tangled.graph.follow/f{i}")),
525 );
526 h.mount(
527 &did(fan),
528 "sh.tangled.graph.follow",
529 &format!("f{i}"),
530 follow_body(&owner),
531 )
532 .await;
533 }
534 Mock::given(method("GET"))
535 .and(path("/xrpc/com.bad-example.identity.resolveMiniDoc"))
536 .and(query_param("identifier", "did:plc:a"))
537 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
538 "did": "did:plc:a",
539 "handle": "a.example.com",
540 "pds": "https://pds.example.com"
541 })))
542 .expect(1)
543 .mount(&h.server)
544 .await;
545 Mock::given(method("GET"))
546 .and(path("/xrpc/com.bad-example.identity.resolveMiniDoc"))
547 .and(query_param("identifier", "did:plc:b"))
548 .respond_with(ResponseTemplate::new(404))
549 .expect(1)
550 .mount(&h.server)
551 .await;
552
553 let app = router(h.state.clone());
554 let (status, body) = json_response(
555 app.oneshot(enrich_request(json!({
556 "xrpc": "sh.tangled.graph.listFollows",
557 "params": { "subject": owner.as_ref() },
558 "enrich": [
559 { "source": "sh.tangled.graph.follow:.repo", "type": MINIDOC },
560 { "source": "sh.tangled.graph.follow:.repo", "type": MINIDOC },
561 { "source": "sh.tangled.feed.star:.repo", "type": MINIDOC }
562 ]
563 })))
564 .await
565 .unwrap(),
566 )
567 .await;
568
569 assert_eq!(status, StatusCode::OK, "{body}");
570 assert_eq!(
571 body["data"]["did:plc:a"]["sh.tangled.graph.follow:.repo"][MINIDOC]["handle"],
572 json!("a.example.com")
573 );
574 assert_eq!(
575 body["data"]["did:plc:a"]["sh.tangled.feed.star:.repo"][MINIDOC]["handle"],
576 json!("a.example.com")
577 );
578 // resolution failures are dropped, the client falls back for misses
579 assert!(body["data"]["did:plc:b"].is_null(), "{body}");
580 // the profile owner authored nothing here, so it earns no minidoc
581 assert!(body["data"]["did:plc:nel"].is_null(), "{body}");
582}
583
584#[tokio::test]
585async fn minidoc_repo_sources_skip_the_author_index() {
586 let h = Harness::new().await;
587 let owner = did("did:plc:nel");
588 let repo_did = did("did:plc:limpet");
589 repo_fixture(&h, &owner, &repo_did).await;
590 Mock::given(method("GET"))
591 .and(path("/xrpc/com.bad-example.identity.resolveMiniDoc"))
592 .and(query_param("identifier", "did:plc:nel"))
593 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
594 "did": "did:plc:nel",
595 "handle": "nel.example.com"
596 })))
597 .mount(&h.server)
598 .await;
599
600 let app = router(h.state.clone());
601 // sh.tangled.repo has no author mirror; stats would 400, minidocs must not
602 let (status, body) = json_response(
603 app.oneshot(enrich_request(json!({
604 "xrpc": "sh.tangled.repo.listRepos",
605 "params": { "subject": owner.as_ref() },
606 "enrich": [{ "source": "sh.tangled.repo:.repo", "type": MINIDOC }]
607 })))
608 .await
609 .unwrap(),
610 )
611 .await;
612
613 assert_eq!(status, StatusCode::OK, "{body}");
614 assert_eq!(
615 body["data"]["did:plc:nel"]["sh.tangled.repo:.repo"][MINIDOC]["handle"],
616 json!("nel.example.com")
617 );
618
619 let app = router(h.state.clone());
620 let (status, body) = json_response(
621 app.oneshot(enrich_request(json!({
622 "xrpc": "sh.tangled.repo.listRepos",
623 "params": { "subject": owner.as_ref() },
624 "enrich": [{ "source": "sh.tangled.repo:.repo", "type": COUNT }]
625 })))
626 .await
627 .unwrap(),
628 )
629 .await;
630 assert_eq!(status, StatusCode::BAD_REQUEST, "{body}");
631}