this repo has no description
1mod common;
2mod helpers;
3use chrono::Utc;
4use common::*;
5use helpers::*;
6use reqwest::{StatusCode, header};
7use serde_json::{Value, json};
8use std::time::Duration;
9
10#[tokio::test]
11async fn test_post_crud_lifecycle() {
12 let client = client();
13 let (did, jwt) = setup_new_user("lifecycle-crud").await;
14 let collection = "app.bsky.feed.post";
15 let rkey = format!("e2e_lifecycle_{}", Utc::now().timestamp_millis());
16 let now = Utc::now().to_rfc3339();
17 let original_text = "Hello from the lifecycle test!";
18 let create_payload = json!({
19 "repo": did,
20 "collection": collection,
21 "rkey": rkey,
22 "record": {
23 "$type": collection,
24 "text": original_text,
25 "createdAt": now
26 }
27 });
28 let create_res = client
29 .post(format!(
30 "{}/xrpc/com.atproto.repo.putRecord",
31 base_url().await
32 ))
33 .bearer_auth(&jwt)
34 .json(&create_payload)
35 .send()
36 .await
37 .expect("Failed to send create request");
38 if create_res.status() != reqwest::StatusCode::OK {
39 let status = create_res.status();
40 let body = create_res
41 .text()
42 .await
43 .unwrap_or_else(|_| "Could not get body".to_string());
44 panic!(
45 "Failed to create record. Status: {}, Body: {}",
46 status, body
47 );
48 }
49 let create_body: Value = create_res
50 .json()
51 .await
52 .expect("create response was not JSON");
53 let uri = create_body["uri"].as_str().unwrap();
54 let params = [
55 ("repo", did.as_str()),
56 ("collection", collection),
57 ("rkey", &rkey),
58 ];
59 let get_res = client
60 .get(format!(
61 "{}/xrpc/com.atproto.repo.getRecord",
62 base_url().await
63 ))
64 .query(¶ms)
65 .send()
66 .await
67 .expect("Failed to send get request");
68 assert_eq!(
69 get_res.status(),
70 reqwest::StatusCode::OK,
71 "Failed to get record after create"
72 );
73 let get_body: Value = get_res.json().await.expect("get response was not JSON");
74 assert_eq!(get_body["uri"], uri);
75 assert_eq!(get_body["value"]["text"], original_text);
76 let updated_text = "This post has been updated.";
77 let update_payload = json!({
78 "repo": did,
79 "collection": collection,
80 "rkey": rkey,
81 "record": {
82 "$type": collection,
83 "text": updated_text,
84 "createdAt": now
85 }
86 });
87 let update_res = client
88 .post(format!(
89 "{}/xrpc/com.atproto.repo.putRecord",
90 base_url().await
91 ))
92 .bearer_auth(&jwt)
93 .json(&update_payload)
94 .send()
95 .await
96 .expect("Failed to send update request");
97 assert_eq!(
98 update_res.status(),
99 reqwest::StatusCode::OK,
100 "Failed to update record"
101 );
102 let get_updated_res = client
103 .get(format!(
104 "{}/xrpc/com.atproto.repo.getRecord",
105 base_url().await
106 ))
107 .query(¶ms)
108 .send()
109 .await
110 .expect("Failed to send get-after-update request");
111 assert_eq!(
112 get_updated_res.status(),
113 reqwest::StatusCode::OK,
114 "Failed to get record after update"
115 );
116 let get_updated_body: Value = get_updated_res
117 .json()
118 .await
119 .expect("get-updated response was not JSON");
120 assert_eq!(
121 get_updated_body["value"]["text"], updated_text,
122 "Text was not updated"
123 );
124 let delete_payload = json!({
125 "repo": did,
126 "collection": collection,
127 "rkey": rkey
128 });
129 let delete_res = client
130 .post(format!(
131 "{}/xrpc/com.atproto.repo.deleteRecord",
132 base_url().await
133 ))
134 .bearer_auth(&jwt)
135 .json(&delete_payload)
136 .send()
137 .await
138 .expect("Failed to send delete request");
139 assert_eq!(
140 delete_res.status(),
141 reqwest::StatusCode::OK,
142 "Failed to delete record"
143 );
144 let get_deleted_res = client
145 .get(format!(
146 "{}/xrpc/com.atproto.repo.getRecord",
147 base_url().await
148 ))
149 .query(¶ms)
150 .send()
151 .await
152 .expect("Failed to send get-after-delete request");
153 assert_eq!(
154 get_deleted_res.status(),
155 reqwest::StatusCode::NOT_FOUND,
156 "Record was found, but it should be deleted"
157 );
158}
159
160#[tokio::test]
161async fn test_record_update_conflict_lifecycle() {
162 let client = client();
163 let (user_did, user_jwt) = setup_new_user("user-conflict").await;
164 let profile_payload = json!({
165 "repo": user_did,
166 "collection": "app.bsky.actor.profile",
167 "rkey": "self",
168 "record": {
169 "$type": "app.bsky.actor.profile",
170 "displayName": "Original Name"
171 }
172 });
173 let create_res = client
174 .post(format!(
175 "{}/xrpc/com.atproto.repo.putRecord",
176 base_url().await
177 ))
178 .bearer_auth(&user_jwt)
179 .json(&profile_payload)
180 .send()
181 .await
182 .expect("create profile failed");
183 if create_res.status() != reqwest::StatusCode::OK {
184 return;
185 }
186 let get_res = client
187 .get(format!(
188 "{}/xrpc/com.atproto.repo.getRecord",
189 base_url().await
190 ))
191 .query(&[
192 ("repo", &user_did),
193 ("collection", &"app.bsky.actor.profile".to_string()),
194 ("rkey", &"self".to_string()),
195 ])
196 .send()
197 .await
198 .expect("getRecord failed");
199 let get_body: Value = get_res.json().await.expect("getRecord not json");
200 let cid_v1 = get_body["cid"]
201 .as_str()
202 .expect("Profile v1 had no CID")
203 .to_string();
204 let update_payload_v2 = json!({
205 "repo": user_did,
206 "collection": "app.bsky.actor.profile",
207 "rkey": "self",
208 "record": {
209 "$type": "app.bsky.actor.profile",
210 "displayName": "Updated Name (v2)"
211 },
212 "swapRecord": cid_v1
213 });
214 let update_res_v2 = client
215 .post(format!(
216 "{}/xrpc/com.atproto.repo.putRecord",
217 base_url().await
218 ))
219 .bearer_auth(&user_jwt)
220 .json(&update_payload_v2)
221 .send()
222 .await
223 .expect("putRecord v2 failed");
224 assert_eq!(
225 update_res_v2.status(),
226 reqwest::StatusCode::OK,
227 "v2 update failed"
228 );
229 let update_body_v2: Value = update_res_v2.json().await.expect("v2 body not json");
230 let cid_v2 = update_body_v2["cid"]
231 .as_str()
232 .expect("v2 response had no CID")
233 .to_string();
234 let update_payload_v3_stale = json!({
235 "repo": user_did,
236 "collection": "app.bsky.actor.profile",
237 "rkey": "self",
238 "record": {
239 "$type": "app.bsky.actor.profile",
240 "displayName": "Stale Update (v3)"
241 },
242 "swapRecord": cid_v1
243 });
244 let update_res_v3_stale = client
245 .post(format!(
246 "{}/xrpc/com.atproto.repo.putRecord",
247 base_url().await
248 ))
249 .bearer_auth(&user_jwt)
250 .json(&update_payload_v3_stale)
251 .send()
252 .await
253 .expect("putRecord v3 (stale) failed");
254 assert_eq!(
255 update_res_v3_stale.status(),
256 reqwest::StatusCode::CONFLICT,
257 "Stale update did not cause a 409 Conflict"
258 );
259 let update_payload_v3_good = json!({
260 "repo": user_did,
261 "collection": "app.bsky.actor.profile",
262 "rkey": "self",
263 "record": {
264 "$type": "app.bsky.actor.profile",
265 "displayName": "Good Update (v3)"
266 },
267 "swapRecord": cid_v2
268 });
269 let update_res_v3_good = client
270 .post(format!(
271 "{}/xrpc/com.atproto.repo.putRecord",
272 base_url().await
273 ))
274 .bearer_auth(&user_jwt)
275 .json(&update_payload_v3_good)
276 .send()
277 .await
278 .expect("putRecord v3 (good) failed");
279 assert_eq!(
280 update_res_v3_good.status(),
281 reqwest::StatusCode::OK,
282 "v3 (good) update failed"
283 );
284}
285
286#[tokio::test]
287async fn test_profile_lifecycle() {
288 let client = client();
289 let (did, jwt) = setup_new_user("profile-lifecycle").await;
290 let profile_payload = json!({
291 "repo": did,
292 "collection": "app.bsky.actor.profile",
293 "rkey": "self",
294 "record": {
295 "$type": "app.bsky.actor.profile",
296 "displayName": "Test User",
297 "description": "A test profile for lifecycle testing"
298 }
299 });
300 let create_res = client
301 .post(format!(
302 "{}/xrpc/com.atproto.repo.putRecord",
303 base_url().await
304 ))
305 .bearer_auth(&jwt)
306 .json(&profile_payload)
307 .send()
308 .await
309 .expect("Failed to create profile");
310 assert_eq!(
311 create_res.status(),
312 StatusCode::OK,
313 "Failed to create profile"
314 );
315 let create_body: Value = create_res.json().await.unwrap();
316 let initial_cid = create_body["cid"].as_str().unwrap().to_string();
317 let get_res = client
318 .get(format!(
319 "{}/xrpc/com.atproto.repo.getRecord",
320 base_url().await
321 ))
322 .query(&[
323 ("repo", did.as_str()),
324 ("collection", "app.bsky.actor.profile"),
325 ("rkey", "self"),
326 ])
327 .send()
328 .await
329 .expect("Failed to get profile");
330 assert_eq!(get_res.status(), StatusCode::OK);
331 let get_body: Value = get_res.json().await.unwrap();
332 assert_eq!(get_body["value"]["displayName"], "Test User");
333 assert_eq!(
334 get_body["value"]["description"],
335 "A test profile for lifecycle testing"
336 );
337 let update_payload = json!({
338 "repo": did,
339 "collection": "app.bsky.actor.profile",
340 "rkey": "self",
341 "record": {
342 "$type": "app.bsky.actor.profile",
343 "displayName": "Updated User",
344 "description": "Profile has been updated"
345 },
346 "swapRecord": initial_cid
347 });
348 let update_res = client
349 .post(format!(
350 "{}/xrpc/com.atproto.repo.putRecord",
351 base_url().await
352 ))
353 .bearer_auth(&jwt)
354 .json(&update_payload)
355 .send()
356 .await
357 .expect("Failed to update profile");
358 assert_eq!(
359 update_res.status(),
360 StatusCode::OK,
361 "Failed to update profile"
362 );
363 let get_updated_res = client
364 .get(format!(
365 "{}/xrpc/com.atproto.repo.getRecord",
366 base_url().await
367 ))
368 .query(&[
369 ("repo", did.as_str()),
370 ("collection", "app.bsky.actor.profile"),
371 ("rkey", "self"),
372 ])
373 .send()
374 .await
375 .expect("Failed to get updated profile");
376 let updated_body: Value = get_updated_res.json().await.unwrap();
377 assert_eq!(updated_body["value"]["displayName"], "Updated User");
378}
379
380#[tokio::test]
381async fn test_reply_thread_lifecycle() {
382 let client = client();
383 let (alice_did, alice_jwt) = setup_new_user("alice-thread").await;
384 let (bob_did, bob_jwt) = setup_new_user("bob-thread").await;
385 let (root_uri, root_cid) =
386 create_post(&client, &alice_did, &alice_jwt, "This is the root post").await;
387 tokio::time::sleep(Duration::from_millis(100)).await;
388 let reply_collection = "app.bsky.feed.post";
389 let reply_rkey = format!("e2e_reply_{}", Utc::now().timestamp_millis());
390 let now = Utc::now().to_rfc3339();
391 let reply_payload = json!({
392 "repo": bob_did,
393 "collection": reply_collection,
394 "rkey": reply_rkey,
395 "record": {
396 "$type": reply_collection,
397 "text": "This is Bob's reply to Alice",
398 "createdAt": now,
399 "reply": {
400 "root": {
401 "uri": root_uri,
402 "cid": root_cid
403 },
404 "parent": {
405 "uri": root_uri,
406 "cid": root_cid
407 }
408 }
409 }
410 });
411 let reply_res = client
412 .post(format!(
413 "{}/xrpc/com.atproto.repo.putRecord",
414 base_url().await
415 ))
416 .bearer_auth(&bob_jwt)
417 .json(&reply_payload)
418 .send()
419 .await
420 .expect("Failed to create reply");
421 assert_eq!(reply_res.status(), StatusCode::OK, "Failed to create reply");
422 let reply_body: Value = reply_res.json().await.unwrap();
423 let reply_uri = reply_body["uri"].as_str().unwrap();
424 let reply_cid = reply_body["cid"].as_str().unwrap();
425 let get_reply_res = client
426 .get(format!(
427 "{}/xrpc/com.atproto.repo.getRecord",
428 base_url().await
429 ))
430 .query(&[
431 ("repo", bob_did.as_str()),
432 ("collection", reply_collection),
433 ("rkey", reply_rkey.as_str()),
434 ])
435 .send()
436 .await
437 .expect("Failed to get reply");
438 assert_eq!(get_reply_res.status(), StatusCode::OK);
439 let reply_record: Value = get_reply_res.json().await.unwrap();
440 assert_eq!(reply_record["value"]["reply"]["root"]["uri"], root_uri);
441 assert_eq!(reply_record["value"]["reply"]["parent"]["uri"], root_uri);
442 tokio::time::sleep(Duration::from_millis(100)).await;
443 let nested_reply_rkey = format!("e2e_nested_reply_{}", Utc::now().timestamp_millis());
444 let nested_payload = json!({
445 "repo": alice_did,
446 "collection": reply_collection,
447 "rkey": nested_reply_rkey,
448 "record": {
449 "$type": reply_collection,
450 "text": "Alice replies to Bob's reply",
451 "createdAt": Utc::now().to_rfc3339(),
452 "reply": {
453 "root": {
454 "uri": root_uri,
455 "cid": root_cid
456 },
457 "parent": {
458 "uri": reply_uri,
459 "cid": reply_cid
460 }
461 }
462 }
463 });
464 let nested_res = client
465 .post(format!(
466 "{}/xrpc/com.atproto.repo.putRecord",
467 base_url().await
468 ))
469 .bearer_auth(&alice_jwt)
470 .json(&nested_payload)
471 .send()
472 .await
473 .expect("Failed to create nested reply");
474 assert_eq!(
475 nested_res.status(),
476 StatusCode::OK,
477 "Failed to create nested reply"
478 );
479}
480
481#[tokio::test]
482async fn test_blob_in_record_lifecycle() {
483 let client = client();
484 let (did, jwt) = setup_new_user("blob-record").await;
485 let blob_data = b"This is test blob data for a profile avatar";
486 let upload_res = client
487 .post(format!(
488 "{}/xrpc/com.atproto.repo.uploadBlob",
489 base_url().await
490 ))
491 .header(header::CONTENT_TYPE, "text/plain")
492 .bearer_auth(&jwt)
493 .body(blob_data.to_vec())
494 .send()
495 .await
496 .expect("Failed to upload blob");
497 assert_eq!(upload_res.status(), StatusCode::OK);
498 let upload_body: Value = upload_res.json().await.unwrap();
499 let blob_ref = upload_body["blob"].clone();
500 let profile_payload = json!({
501 "repo": did,
502 "collection": "app.bsky.actor.profile",
503 "rkey": "self",
504 "record": {
505 "$type": "app.bsky.actor.profile",
506 "displayName": "User With Avatar",
507 "avatar": blob_ref
508 }
509 });
510 let create_res = client
511 .post(format!(
512 "{}/xrpc/com.atproto.repo.putRecord",
513 base_url().await
514 ))
515 .bearer_auth(&jwt)
516 .json(&profile_payload)
517 .send()
518 .await
519 .expect("Failed to create profile with blob");
520 assert_eq!(
521 create_res.status(),
522 StatusCode::OK,
523 "Failed to create profile with blob"
524 );
525 let get_res = client
526 .get(format!(
527 "{}/xrpc/com.atproto.repo.getRecord",
528 base_url().await
529 ))
530 .query(&[
531 ("repo", did.as_str()),
532 ("collection", "app.bsky.actor.profile"),
533 ("rkey", "self"),
534 ])
535 .send()
536 .await
537 .expect("Failed to get profile");
538 assert_eq!(get_res.status(), StatusCode::OK);
539 let profile: Value = get_res.json().await.unwrap();
540 assert!(profile["value"]["avatar"]["ref"]["$link"].is_string());
541}
542
543#[tokio::test]
544async fn test_authorization_cannot_modify_other_repo() {
545 let client = client();
546 let (alice_did, _alice_jwt) = setup_new_user("alice-auth").await;
547 let (_bob_did, bob_jwt) = setup_new_user("bob-auth").await;
548 let post_payload = json!({
549 "repo": alice_did,
550 "collection": "app.bsky.feed.post",
551 "rkey": "unauthorized-post",
552 "record": {
553 "$type": "app.bsky.feed.post",
554 "text": "Bob trying to post as Alice",
555 "createdAt": Utc::now().to_rfc3339()
556 }
557 });
558 let res = client
559 .post(format!(
560 "{}/xrpc/com.atproto.repo.putRecord",
561 base_url().await
562 ))
563 .bearer_auth(&bob_jwt)
564 .json(&post_payload)
565 .send()
566 .await
567 .expect("Failed to send request");
568 assert!(
569 res.status() == StatusCode::FORBIDDEN || res.status() == StatusCode::UNAUTHORIZED,
570 "Expected 403 or 401 when writing to another user's repo, got {}",
571 res.status()
572 );
573}
574
575#[tokio::test]
576async fn test_authorization_cannot_delete_other_record() {
577 let client = client();
578 let (alice_did, alice_jwt) = setup_new_user("alice-del-auth").await;
579 let (_bob_did, bob_jwt) = setup_new_user("bob-del-auth").await;
580 let (post_uri, _) = create_post(&client, &alice_did, &alice_jwt, "Alice's post").await;
581 let post_rkey = post_uri.split('/').last().unwrap();
582 let delete_payload = json!({
583 "repo": alice_did,
584 "collection": "app.bsky.feed.post",
585 "rkey": post_rkey
586 });
587 let res = client
588 .post(format!(
589 "{}/xrpc/com.atproto.repo.deleteRecord",
590 base_url().await
591 ))
592 .bearer_auth(&bob_jwt)
593 .json(&delete_payload)
594 .send()
595 .await
596 .expect("Failed to send request");
597 assert!(
598 res.status() == StatusCode::FORBIDDEN || res.status() == StatusCode::UNAUTHORIZED,
599 "Expected 403 or 401 when deleting another user's record, got {}",
600 res.status()
601 );
602 let get_res = client
603 .get(format!(
604 "{}/xrpc/com.atproto.repo.getRecord",
605 base_url().await
606 ))
607 .query(&[
608 ("repo", alice_did.as_str()),
609 ("collection", "app.bsky.feed.post"),
610 ("rkey", post_rkey),
611 ])
612 .send()
613 .await
614 .expect("Failed to verify record exists");
615 assert_eq!(
616 get_res.status(),
617 StatusCode::OK,
618 "Record should still exist"
619 );
620}
621
622#[tokio::test]
623async fn test_apply_writes_batch_lifecycle() {
624 let client = client();
625 let (did, jwt) = setup_new_user("apply-writes-batch").await;
626 let now = Utc::now().to_rfc3339();
627 let writes_payload = json!({
628 "repo": did,
629 "writes": [
630 {
631 "$type": "com.atproto.repo.applyWrites#create",
632 "collection": "app.bsky.feed.post",
633 "rkey": "batch-post-1",
634 "value": {
635 "$type": "app.bsky.feed.post",
636 "text": "First batch post",
637 "createdAt": now
638 }
639 },
640 {
641 "$type": "com.atproto.repo.applyWrites#create",
642 "collection": "app.bsky.feed.post",
643 "rkey": "batch-post-2",
644 "value": {
645 "$type": "app.bsky.feed.post",
646 "text": "Second batch post",
647 "createdAt": now
648 }
649 },
650 {
651 "$type": "com.atproto.repo.applyWrites#create",
652 "collection": "app.bsky.actor.profile",
653 "rkey": "self",
654 "value": {
655 "$type": "app.bsky.actor.profile",
656 "displayName": "Batch User"
657 }
658 }
659 ]
660 });
661 let apply_res = client
662 .post(format!(
663 "{}/xrpc/com.atproto.repo.applyWrites",
664 base_url().await
665 ))
666 .bearer_auth(&jwt)
667 .json(&writes_payload)
668 .send()
669 .await
670 .expect("Failed to apply writes");
671 assert_eq!(apply_res.status(), StatusCode::OK);
672 let get_post1 = client
673 .get(format!(
674 "{}/xrpc/com.atproto.repo.getRecord",
675 base_url().await
676 ))
677 .query(&[
678 ("repo", did.as_str()),
679 ("collection", "app.bsky.feed.post"),
680 ("rkey", "batch-post-1"),
681 ])
682 .send()
683 .await
684 .expect("Failed to get post 1");
685 assert_eq!(get_post1.status(), StatusCode::OK);
686 let post1_body: Value = get_post1.json().await.unwrap();
687 assert_eq!(post1_body["value"]["text"], "First batch post");
688 let get_post2 = client
689 .get(format!(
690 "{}/xrpc/com.atproto.repo.getRecord",
691 base_url().await
692 ))
693 .query(&[
694 ("repo", did.as_str()),
695 ("collection", "app.bsky.feed.post"),
696 ("rkey", "batch-post-2"),
697 ])
698 .send()
699 .await
700 .expect("Failed to get post 2");
701 assert_eq!(get_post2.status(), StatusCode::OK);
702 let get_profile = client
703 .get(format!(
704 "{}/xrpc/com.atproto.repo.getRecord",
705 base_url().await
706 ))
707 .query(&[
708 ("repo", did.as_str()),
709 ("collection", "app.bsky.actor.profile"),
710 ("rkey", "self"),
711 ])
712 .send()
713 .await
714 .expect("Failed to get profile");
715 assert_eq!(get_profile.status(), StatusCode::OK);
716 let profile_body: Value = get_profile.json().await.unwrap();
717 assert_eq!(profile_body["value"]["displayName"], "Batch User");
718 let update_writes = json!({
719 "repo": did,
720 "writes": [
721 {
722 "$type": "com.atproto.repo.applyWrites#update",
723 "collection": "app.bsky.actor.profile",
724 "rkey": "self",
725 "value": {
726 "$type": "app.bsky.actor.profile",
727 "displayName": "Updated Batch User"
728 }
729 },
730 {
731 "$type": "com.atproto.repo.applyWrites#delete",
732 "collection": "app.bsky.feed.post",
733 "rkey": "batch-post-1"
734 }
735 ]
736 });
737 let update_res = client
738 .post(format!(
739 "{}/xrpc/com.atproto.repo.applyWrites",
740 base_url().await
741 ))
742 .bearer_auth(&jwt)
743 .json(&update_writes)
744 .send()
745 .await
746 .expect("Failed to apply update writes");
747 assert_eq!(update_res.status(), StatusCode::OK);
748 let get_updated_profile = client
749 .get(format!(
750 "{}/xrpc/com.atproto.repo.getRecord",
751 base_url().await
752 ))
753 .query(&[
754 ("repo", did.as_str()),
755 ("collection", "app.bsky.actor.profile"),
756 ("rkey", "self"),
757 ])
758 .send()
759 .await
760 .expect("Failed to get updated profile");
761 let updated_profile: Value = get_updated_profile.json().await.unwrap();
762 assert_eq!(
763 updated_profile["value"]["displayName"],
764 "Updated Batch User"
765 );
766 let get_deleted_post = client
767 .get(format!(
768 "{}/xrpc/com.atproto.repo.getRecord",
769 base_url().await
770 ))
771 .query(&[
772 ("repo", did.as_str()),
773 ("collection", "app.bsky.feed.post"),
774 ("rkey", "batch-post-1"),
775 ])
776 .send()
777 .await
778 .expect("Failed to check deleted post");
779 assert_eq!(
780 get_deleted_post.status(),
781 StatusCode::NOT_FOUND,
782 "Batch-deleted post should be gone"
783 );
784}
785
786async fn create_post_with_rkey(
787 client: &reqwest::Client,
788 did: &str,
789 jwt: &str,
790 rkey: &str,
791 text: &str,
792) -> (String, String) {
793 let payload = json!({
794 "repo": did,
795 "collection": "app.bsky.feed.post",
796 "rkey": rkey,
797 "record": {
798 "$type": "app.bsky.feed.post",
799 "text": text,
800 "createdAt": Utc::now().to_rfc3339()
801 }
802 });
803 let res = client
804 .post(format!(
805 "{}/xrpc/com.atproto.repo.putRecord",
806 base_url().await
807 ))
808 .bearer_auth(jwt)
809 .json(&payload)
810 .send()
811 .await
812 .expect("Failed to create record");
813 assert_eq!(res.status(), StatusCode::OK);
814 let body: Value = res.json().await.unwrap();
815 (
816 body["uri"].as_str().unwrap().to_string(),
817 body["cid"].as_str().unwrap().to_string(),
818 )
819}
820
821#[tokio::test]
822async fn test_list_records_default_order() {
823 let client = client();
824 let (did, jwt) = setup_new_user("list-default-order").await;
825 create_post_with_rkey(&client, &did, &jwt, "aaaa", "First post").await;
826 tokio::time::sleep(Duration::from_millis(50)).await;
827 create_post_with_rkey(&client, &did, &jwt, "bbbb", "Second post").await;
828 tokio::time::sleep(Duration::from_millis(50)).await;
829 create_post_with_rkey(&client, &did, &jwt, "cccc", "Third post").await;
830 let res = client
831 .get(format!(
832 "{}/xrpc/com.atproto.repo.listRecords",
833 base_url().await
834 ))
835 .query(&[("repo", did.as_str()), ("collection", "app.bsky.feed.post")])
836 .send()
837 .await
838 .expect("Failed to list records");
839 assert_eq!(res.status(), StatusCode::OK);
840 let body: Value = res.json().await.unwrap();
841 let records = body["records"].as_array().unwrap();
842 assert_eq!(records.len(), 3);
843 let rkeys: Vec<&str> = records
844 .iter()
845 .map(|r| r["uri"].as_str().unwrap().split('/').last().unwrap())
846 .collect();
847 assert_eq!(
848 rkeys,
849 vec!["cccc", "bbbb", "aaaa"],
850 "Default order should be DESC (newest first)"
851 );
852}
853
854#[tokio::test]
855async fn test_list_records_reverse_true() {
856 let client = client();
857 let (did, jwt) = setup_new_user("list-reverse").await;
858 create_post_with_rkey(&client, &did, &jwt, "aaaa", "First post").await;
859 tokio::time::sleep(Duration::from_millis(50)).await;
860 create_post_with_rkey(&client, &did, &jwt, "bbbb", "Second post").await;
861 tokio::time::sleep(Duration::from_millis(50)).await;
862 create_post_with_rkey(&client, &did, &jwt, "cccc", "Third post").await;
863 let res = client
864 .get(format!(
865 "{}/xrpc/com.atproto.repo.listRecords",
866 base_url().await
867 ))
868 .query(&[
869 ("repo", did.as_str()),
870 ("collection", "app.bsky.feed.post"),
871 ("reverse", "true"),
872 ])
873 .send()
874 .await
875 .expect("Failed to list records");
876 assert_eq!(res.status(), StatusCode::OK);
877 let body: Value = res.json().await.unwrap();
878 let records = body["records"].as_array().unwrap();
879 let rkeys: Vec<&str> = records
880 .iter()
881 .map(|r| r["uri"].as_str().unwrap().split('/').last().unwrap())
882 .collect();
883 assert_eq!(
884 rkeys,
885 vec!["aaaa", "bbbb", "cccc"],
886 "reverse=true should give ASC order (oldest first)"
887 );
888}
889
890#[tokio::test]
891async fn test_list_records_cursor_pagination() {
892 let client = client();
893 let (did, jwt) = setup_new_user("list-cursor").await;
894 for i in 0..5 {
895 create_post_with_rkey(
896 &client,
897 &did,
898 &jwt,
899 &format!("post{:02}", i),
900 &format!("Post {}", i),
901 )
902 .await;
903 tokio::time::sleep(Duration::from_millis(50)).await;
904 }
905 let res = client
906 .get(format!(
907 "{}/xrpc/com.atproto.repo.listRecords",
908 base_url().await
909 ))
910 .query(&[
911 ("repo", did.as_str()),
912 ("collection", "app.bsky.feed.post"),
913 ("limit", "2"),
914 ])
915 .send()
916 .await
917 .expect("Failed to list records");
918 assert_eq!(res.status(), StatusCode::OK);
919 let body: Value = res.json().await.unwrap();
920 let records = body["records"].as_array().unwrap();
921 assert_eq!(records.len(), 2);
922 let cursor = body["cursor"]
923 .as_str()
924 .expect("Should have cursor with more records");
925 let res2 = client
926 .get(format!(
927 "{}/xrpc/com.atproto.repo.listRecords",
928 base_url().await
929 ))
930 .query(&[
931 ("repo", did.as_str()),
932 ("collection", "app.bsky.feed.post"),
933 ("limit", "2"),
934 ("cursor", cursor),
935 ])
936 .send()
937 .await
938 .expect("Failed to list records with cursor");
939 assert_eq!(res2.status(), StatusCode::OK);
940 let body2: Value = res2.json().await.unwrap();
941 let records2 = body2["records"].as_array().unwrap();
942 assert_eq!(records2.len(), 2);
943 let all_uris: Vec<&str> = records
944 .iter()
945 .chain(records2.iter())
946 .map(|r| r["uri"].as_str().unwrap())
947 .collect();
948 let unique_uris: std::collections::HashSet<&str> = all_uris.iter().copied().collect();
949 assert_eq!(
950 all_uris.len(),
951 unique_uris.len(),
952 "Cursor pagination should not repeat records"
953 );
954}
955
956#[tokio::test]
957async fn test_list_records_rkey_start() {
958 let client = client();
959 let (did, jwt) = setup_new_user("list-rkey-start").await;
960 create_post_with_rkey(&client, &did, &jwt, "aaaa", "First").await;
961 create_post_with_rkey(&client, &did, &jwt, "bbbb", "Second").await;
962 create_post_with_rkey(&client, &did, &jwt, "cccc", "Third").await;
963 create_post_with_rkey(&client, &did, &jwt, "dddd", "Fourth").await;
964 let res = client
965 .get(format!(
966 "{}/xrpc/com.atproto.repo.listRecords",
967 base_url().await
968 ))
969 .query(&[
970 ("repo", did.as_str()),
971 ("collection", "app.bsky.feed.post"),
972 ("rkeyStart", "bbbb"),
973 ("reverse", "true"),
974 ])
975 .send()
976 .await
977 .expect("Failed to list records");
978 assert_eq!(res.status(), StatusCode::OK);
979 let body: Value = res.json().await.unwrap();
980 let records = body["records"].as_array().unwrap();
981 let rkeys: Vec<&str> = records
982 .iter()
983 .map(|r| r["uri"].as_str().unwrap().split('/').last().unwrap())
984 .collect();
985 for rkey in &rkeys {
986 assert!(*rkey >= "bbbb", "rkeyStart should filter records >= start");
987 }
988}
989
990#[tokio::test]
991async fn test_list_records_rkey_end() {
992 let client = client();
993 let (did, jwt) = setup_new_user("list-rkey-end").await;
994 create_post_with_rkey(&client, &did, &jwt, "aaaa", "First").await;
995 create_post_with_rkey(&client, &did, &jwt, "bbbb", "Second").await;
996 create_post_with_rkey(&client, &did, &jwt, "cccc", "Third").await;
997 create_post_with_rkey(&client, &did, &jwt, "dddd", "Fourth").await;
998 let res = client
999 .get(format!(
1000 "{}/xrpc/com.atproto.repo.listRecords",
1001 base_url().await
1002 ))
1003 .query(&[
1004 ("repo", did.as_str()),
1005 ("collection", "app.bsky.feed.post"),
1006 ("rkeyEnd", "cccc"),
1007 ("reverse", "true"),
1008 ])
1009 .send()
1010 .await
1011 .expect("Failed to list records");
1012 assert_eq!(res.status(), StatusCode::OK);
1013 let body: Value = res.json().await.unwrap();
1014 let records = body["records"].as_array().unwrap();
1015 let rkeys: Vec<&str> = records
1016 .iter()
1017 .map(|r| r["uri"].as_str().unwrap().split('/').last().unwrap())
1018 .collect();
1019 for rkey in &rkeys {
1020 assert!(*rkey <= "cccc", "rkeyEnd should filter records <= end");
1021 }
1022}
1023
1024#[tokio::test]
1025async fn test_list_records_rkey_range() {
1026 let client = client();
1027 let (did, jwt) = setup_new_user("list-rkey-range").await;
1028 create_post_with_rkey(&client, &did, &jwt, "aaaa", "First").await;
1029 create_post_with_rkey(&client, &did, &jwt, "bbbb", "Second").await;
1030 create_post_with_rkey(&client, &did, &jwt, "cccc", "Third").await;
1031 create_post_with_rkey(&client, &did, &jwt, "dddd", "Fourth").await;
1032 create_post_with_rkey(&client, &did, &jwt, "eeee", "Fifth").await;
1033 let res = client
1034 .get(format!(
1035 "{}/xrpc/com.atproto.repo.listRecords",
1036 base_url().await
1037 ))
1038 .query(&[
1039 ("repo", did.as_str()),
1040 ("collection", "app.bsky.feed.post"),
1041 ("rkeyStart", "bbbb"),
1042 ("rkeyEnd", "dddd"),
1043 ("reverse", "true"),
1044 ])
1045 .send()
1046 .await
1047 .expect("Failed to list records");
1048 assert_eq!(res.status(), StatusCode::OK);
1049 let body: Value = res.json().await.unwrap();
1050 let records = body["records"].as_array().unwrap();
1051 let rkeys: Vec<&str> = records
1052 .iter()
1053 .map(|r| r["uri"].as_str().unwrap().split('/').last().unwrap())
1054 .collect();
1055 for rkey in &rkeys {
1056 assert!(
1057 *rkey >= "bbbb" && *rkey <= "dddd",
1058 "Range should be inclusive, got {}",
1059 rkey
1060 );
1061 }
1062 assert!(
1063 !rkeys.is_empty(),
1064 "Should have at least some records in range"
1065 );
1066}
1067
1068#[tokio::test]
1069async fn test_list_records_limit_clamping_max() {
1070 let client = client();
1071 let (did, jwt) = setup_new_user("list-limit-max").await;
1072 for i in 0..5 {
1073 create_post_with_rkey(
1074 &client,
1075 &did,
1076 &jwt,
1077 &format!("post{:02}", i),
1078 &format!("Post {}", i),
1079 )
1080 .await;
1081 }
1082 let res = client
1083 .get(format!(
1084 "{}/xrpc/com.atproto.repo.listRecords",
1085 base_url().await
1086 ))
1087 .query(&[
1088 ("repo", did.as_str()),
1089 ("collection", "app.bsky.feed.post"),
1090 ("limit", "1000"),
1091 ])
1092 .send()
1093 .await
1094 .expect("Failed to list records");
1095 assert_eq!(res.status(), StatusCode::OK);
1096 let body: Value = res.json().await.unwrap();
1097 let records = body["records"].as_array().unwrap();
1098 assert!(records.len() <= 100, "Limit should be clamped to max 100");
1099}
1100
1101#[tokio::test]
1102async fn test_list_records_limit_clamping_min() {
1103 let client = client();
1104 let (did, jwt) = setup_new_user("list-limit-min").await;
1105 create_post_with_rkey(&client, &did, &jwt, "aaaa", "Post").await;
1106 let res = client
1107 .get(format!(
1108 "{}/xrpc/com.atproto.repo.listRecords",
1109 base_url().await
1110 ))
1111 .query(&[
1112 ("repo", did.as_str()),
1113 ("collection", "app.bsky.feed.post"),
1114 ("limit", "0"),
1115 ])
1116 .send()
1117 .await
1118 .expect("Failed to list records");
1119 assert_eq!(res.status(), StatusCode::OK);
1120 let body: Value = res.json().await.unwrap();
1121 let records = body["records"].as_array().unwrap();
1122 assert!(records.len() >= 1, "Limit should be clamped to min 1");
1123}
1124
1125#[tokio::test]
1126async fn test_list_records_empty_collection() {
1127 let client = client();
1128 let (did, _jwt) = setup_new_user("list-empty").await;
1129 let res = client
1130 .get(format!(
1131 "{}/xrpc/com.atproto.repo.listRecords",
1132 base_url().await
1133 ))
1134 .query(&[("repo", did.as_str()), ("collection", "app.bsky.feed.post")])
1135 .send()
1136 .await
1137 .expect("Failed to list records");
1138 assert_eq!(res.status(), StatusCode::OK);
1139 let body: Value = res.json().await.unwrap();
1140 let records = body["records"].as_array().unwrap();
1141 assert!(
1142 records.is_empty(),
1143 "Empty collection should return empty array"
1144 );
1145 assert!(
1146 body["cursor"].is_null(),
1147 "Empty collection should have no cursor"
1148 );
1149}
1150
1151#[tokio::test]
1152async fn test_list_records_exact_limit() {
1153 let client = client();
1154 let (did, jwt) = setup_new_user("list-exact-limit").await;
1155 for i in 0..10 {
1156 create_post_with_rkey(
1157 &client,
1158 &did,
1159 &jwt,
1160 &format!("post{:02}", i),
1161 &format!("Post {}", i),
1162 )
1163 .await;
1164 }
1165 let res = client
1166 .get(format!(
1167 "{}/xrpc/com.atproto.repo.listRecords",
1168 base_url().await
1169 ))
1170 .query(&[
1171 ("repo", did.as_str()),
1172 ("collection", "app.bsky.feed.post"),
1173 ("limit", "5"),
1174 ])
1175 .send()
1176 .await
1177 .expect("Failed to list records");
1178 assert_eq!(res.status(), StatusCode::OK);
1179 let body: Value = res.json().await.unwrap();
1180 let records = body["records"].as_array().unwrap();
1181 assert_eq!(
1182 records.len(),
1183 5,
1184 "Should return exactly 5 records when limit=5"
1185 );
1186}
1187
1188#[tokio::test]
1189async fn test_list_records_cursor_exhaustion() {
1190 let client = client();
1191 let (did, jwt) = setup_new_user("list-cursor-exhaust").await;
1192 for i in 0..3 {
1193 create_post_with_rkey(
1194 &client,
1195 &did,
1196 &jwt,
1197 &format!("post{:02}", i),
1198 &format!("Post {}", i),
1199 )
1200 .await;
1201 }
1202 let res = client
1203 .get(format!(
1204 "{}/xrpc/com.atproto.repo.listRecords",
1205 base_url().await
1206 ))
1207 .query(&[
1208 ("repo", did.as_str()),
1209 ("collection", "app.bsky.feed.post"),
1210 ("limit", "10"),
1211 ])
1212 .send()
1213 .await
1214 .expect("Failed to list records");
1215 assert_eq!(res.status(), StatusCode::OK);
1216 let body: Value = res.json().await.unwrap();
1217 let records = body["records"].as_array().unwrap();
1218 assert_eq!(records.len(), 3);
1219}
1220
1221#[tokio::test]
1222async fn test_list_records_repo_not_found() {
1223 let client = client();
1224 let res = client
1225 .get(format!(
1226 "{}/xrpc/com.atproto.repo.listRecords",
1227 base_url().await
1228 ))
1229 .query(&[
1230 ("repo", "did:plc:nonexistent12345"),
1231 ("collection", "app.bsky.feed.post"),
1232 ])
1233 .send()
1234 .await
1235 .expect("Failed to list records");
1236 assert_eq!(res.status(), StatusCode::NOT_FOUND);
1237}
1238
1239#[tokio::test]
1240async fn test_list_records_includes_cid() {
1241 let client = client();
1242 let (did, jwt) = setup_new_user("list-includes-cid").await;
1243 create_post_with_rkey(&client, &did, &jwt, "test", "Test post").await;
1244 let res = client
1245 .get(format!(
1246 "{}/xrpc/com.atproto.repo.listRecords",
1247 base_url().await
1248 ))
1249 .query(&[("repo", did.as_str()), ("collection", "app.bsky.feed.post")])
1250 .send()
1251 .await
1252 .expect("Failed to list records");
1253 assert_eq!(res.status(), StatusCode::OK);
1254 let body: Value = res.json().await.unwrap();
1255 let records = body["records"].as_array().unwrap();
1256 for record in records {
1257 assert!(record["uri"].is_string(), "Record should have uri");
1258 assert!(record["cid"].is_string(), "Record should have cid");
1259 assert!(record["value"].is_object(), "Record should have value");
1260 let cid = record["cid"].as_str().unwrap();
1261 assert!(cid.starts_with("bafy"), "CID should be valid");
1262 }
1263}
1264
1265#[tokio::test]
1266async fn test_list_records_cursor_with_reverse() {
1267 let client = client();
1268 let (did, jwt) = setup_new_user("list-cursor-reverse").await;
1269 for i in 0..5 {
1270 create_post_with_rkey(
1271 &client,
1272 &did,
1273 &jwt,
1274 &format!("post{:02}", i),
1275 &format!("Post {}", i),
1276 )
1277 .await;
1278 }
1279 let res = client
1280 .get(format!(
1281 "{}/xrpc/com.atproto.repo.listRecords",
1282 base_url().await
1283 ))
1284 .query(&[
1285 ("repo", did.as_str()),
1286 ("collection", "app.bsky.feed.post"),
1287 ("limit", "2"),
1288 ("reverse", "true"),
1289 ])
1290 .send()
1291 .await
1292 .expect("Failed to list records");
1293 assert_eq!(res.status(), StatusCode::OK);
1294 let body: Value = res.json().await.unwrap();
1295 let records = body["records"].as_array().unwrap();
1296 let first_rkeys: Vec<&str> = records
1297 .iter()
1298 .map(|r| r["uri"].as_str().unwrap().split('/').last().unwrap())
1299 .collect();
1300 assert_eq!(
1301 first_rkeys,
1302 vec!["post00", "post01"],
1303 "First page with reverse should start from oldest"
1304 );
1305 if let Some(cursor) = body["cursor"].as_str() {
1306 let res2 = client
1307 .get(format!(
1308 "{}/xrpc/com.atproto.repo.listRecords",
1309 base_url().await
1310 ))
1311 .query(&[
1312 ("repo", did.as_str()),
1313 ("collection", "app.bsky.feed.post"),
1314 ("limit", "2"),
1315 ("reverse", "true"),
1316 ("cursor", cursor),
1317 ])
1318 .send()
1319 .await
1320 .expect("Failed to list records with cursor");
1321 let body2: Value = res2.json().await.unwrap();
1322 let records2 = body2["records"].as_array().unwrap();
1323 let second_rkeys: Vec<&str> = records2
1324 .iter()
1325 .map(|r| r["uri"].as_str().unwrap().split('/').last().unwrap())
1326 .collect();
1327 assert_eq!(
1328 second_rkeys,
1329 vec!["post02", "post03"],
1330 "Second page should continue in ASC order"
1331 );
1332 }
1333}