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