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_record_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!("{}/xrpc/com.atproto.repo.putRecord", base_url().await))
30 .bearer_auth(&jwt)
31 .json(&create_payload)
32 .send()
33 .await
34 .expect("Failed to send create request");
35 assert_eq!(create_res.status(), StatusCode::OK, "Failed to create record");
36 let create_body: Value = create_res.json().await.expect("create response was not JSON");
37 let uri = create_body["uri"].as_str().unwrap();
38 let initial_cid = create_body["cid"].as_str().unwrap().to_string();
39 let params = [("repo", did.as_str()), ("collection", collection), ("rkey", &rkey)];
40 let get_res = client
41 .get(format!("{}/xrpc/com.atproto.repo.getRecord", base_url().await))
42 .query(¶ms)
43 .send()
44 .await
45 .expect("Failed to send get request");
46 assert_eq!(get_res.status(), StatusCode::OK, "Failed to get record after create");
47 let get_body: Value = get_res.json().await.expect("get response was not JSON");
48 assert_eq!(get_body["uri"], uri);
49 assert_eq!(get_body["value"]["text"], original_text);
50 let updated_text = "This post has been updated.";
51 let update_payload = json!({
52 "repo": did,
53 "collection": collection,
54 "rkey": rkey,
55 "record": { "$type": collection, "text": updated_text, "createdAt": now },
56 "swapRecord": initial_cid
57 });
58 let update_res = client
59 .post(format!("{}/xrpc/com.atproto.repo.putRecord", base_url().await))
60 .bearer_auth(&jwt)
61 .json(&update_payload)
62 .send()
63 .await
64 .expect("Failed to send update request");
65 assert_eq!(update_res.status(), StatusCode::OK, "Failed to update record");
66 let update_body: Value = update_res.json().await.expect("update response was not JSON");
67 let updated_cid = update_body["cid"].as_str().unwrap().to_string();
68 let get_updated_res = client
69 .get(format!("{}/xrpc/com.atproto.repo.getRecord", base_url().await))
70 .query(¶ms)
71 .send()
72 .await
73 .expect("Failed to send get-after-update request");
74 let get_updated_body: Value = get_updated_res.json().await.expect("get-updated response was not JSON");
75 assert_eq!(get_updated_body["value"]["text"], updated_text, "Text was not updated");
76 let stale_update_payload = json!({
77 "repo": did,
78 "collection": collection,
79 "rkey": rkey,
80 "record": { "$type": collection, "text": "Stale update", "createdAt": now },
81 "swapRecord": initial_cid
82 });
83 let stale_res = client
84 .post(format!("{}/xrpc/com.atproto.repo.putRecord", base_url().await))
85 .bearer_auth(&jwt)
86 .json(&stale_update_payload)
87 .send()
88 .await
89 .expect("Failed to send stale update");
90 assert_eq!(stale_res.status(), StatusCode::CONFLICT, "Stale update should cause 409");
91 let good_update_payload = json!({
92 "repo": did,
93 "collection": collection,
94 "rkey": rkey,
95 "record": { "$type": collection, "text": "Good update", "createdAt": now },
96 "swapRecord": updated_cid
97 });
98 let good_res = client
99 .post(format!("{}/xrpc/com.atproto.repo.putRecord", base_url().await))
100 .bearer_auth(&jwt)
101 .json(&good_update_payload)
102 .send()
103 .await
104 .expect("Failed to send good update");
105 assert_eq!(good_res.status(), StatusCode::OK, "Good update should succeed");
106 let delete_payload = json!({ "repo": did, "collection": collection, "rkey": rkey });
107 let delete_res = client
108 .post(format!("{}/xrpc/com.atproto.repo.deleteRecord", base_url().await))
109 .bearer_auth(&jwt)
110 .json(&delete_payload)
111 .send()
112 .await
113 .expect("Failed to send delete request");
114 assert_eq!(delete_res.status(), StatusCode::OK, "Failed to delete record");
115 let get_deleted_res = client
116 .get(format!("{}/xrpc/com.atproto.repo.getRecord", base_url().await))
117 .query(¶ms)
118 .send()
119 .await
120 .expect("Failed to send get-after-delete request");
121 assert_eq!(get_deleted_res.status(), StatusCode::NOT_FOUND, "Record should be deleted");
122}
123
124#[tokio::test]
125async fn test_profile_with_blob_lifecycle() {
126 let client = client();
127 let (did, jwt) = setup_new_user("profile-blob").await;
128 let blob_data = b"This is test blob data for a profile avatar";
129 let upload_res = client
130 .post(format!("{}/xrpc/com.atproto.repo.uploadBlob", base_url().await))
131 .header(header::CONTENT_TYPE, "text/plain")
132 .bearer_auth(&jwt)
133 .body(blob_data.to_vec())
134 .send()
135 .await
136 .expect("Failed to upload blob");
137 assert_eq!(upload_res.status(), StatusCode::OK);
138 let upload_body: Value = upload_res.json().await.unwrap();
139 let blob_ref = upload_body["blob"].clone();
140 let profile_payload = json!({
141 "repo": did,
142 "collection": "app.bsky.actor.profile",
143 "rkey": "self",
144 "record": {
145 "$type": "app.bsky.actor.profile",
146 "displayName": "Test User",
147 "description": "A test profile for lifecycle testing",
148 "avatar": blob_ref
149 }
150 });
151 let create_res = client
152 .post(format!("{}/xrpc/com.atproto.repo.putRecord", base_url().await))
153 .bearer_auth(&jwt)
154 .json(&profile_payload)
155 .send()
156 .await
157 .expect("Failed to create profile");
158 assert_eq!(create_res.status(), StatusCode::OK, "Failed to create profile");
159 let create_body: Value = create_res.json().await.unwrap();
160 let initial_cid = create_body["cid"].as_str().unwrap().to_string();
161 let get_res = client
162 .get(format!("{}/xrpc/com.atproto.repo.getRecord", base_url().await))
163 .query(&[("repo", did.as_str()), ("collection", "app.bsky.actor.profile"), ("rkey", "self")])
164 .send()
165 .await
166 .expect("Failed to get profile");
167 assert_eq!(get_res.status(), StatusCode::OK);
168 let get_body: Value = get_res.json().await.unwrap();
169 assert_eq!(get_body["value"]["displayName"], "Test User");
170 assert!(get_body["value"]["avatar"]["ref"]["$link"].is_string());
171 let update_payload = json!({
172 "repo": did,
173 "collection": "app.bsky.actor.profile",
174 "rkey": "self",
175 "record": { "$type": "app.bsky.actor.profile", "displayName": "Updated User", "description": "Profile updated" },
176 "swapRecord": initial_cid
177 });
178 let update_res = client
179 .post(format!("{}/xrpc/com.atproto.repo.putRecord", base_url().await))
180 .bearer_auth(&jwt)
181 .json(&update_payload)
182 .send()
183 .await
184 .expect("Failed to update profile");
185 assert_eq!(update_res.status(), StatusCode::OK, "Failed to update profile");
186 let get_updated_res = client
187 .get(format!("{}/xrpc/com.atproto.repo.getRecord", base_url().await))
188 .query(&[("repo", did.as_str()), ("collection", "app.bsky.actor.profile"), ("rkey", "self")])
189 .send()
190 .await
191 .expect("Failed to get updated profile");
192 let updated_body: Value = get_updated_res.json().await.unwrap();
193 assert_eq!(updated_body["value"]["displayName"], "Updated User");
194}
195
196#[tokio::test]
197async fn test_reply_thread_lifecycle() {
198 let client = client();
199 let (alice_did, alice_jwt) = setup_new_user("alice-thread").await;
200 let (bob_did, bob_jwt) = setup_new_user("bob-thread").await;
201 let (root_uri, root_cid) = create_post(&client, &alice_did, &alice_jwt, "This is the root post").await;
202 tokio::time::sleep(Duration::from_millis(100)).await;
203 let reply_collection = "app.bsky.feed.post";
204 let reply_rkey = format!("e2e_reply_{}", Utc::now().timestamp_millis());
205 let reply_payload = json!({
206 "repo": bob_did,
207 "collection": reply_collection,
208 "rkey": reply_rkey,
209 "record": {
210 "$type": reply_collection,
211 "text": "This is Bob's reply to Alice",
212 "createdAt": Utc::now().to_rfc3339(),
213 "reply": {
214 "root": { "uri": root_uri, "cid": root_cid },
215 "parent": { "uri": root_uri, "cid": root_cid }
216 }
217 }
218 });
219 let reply_res = client
220 .post(format!("{}/xrpc/com.atproto.repo.putRecord", base_url().await))
221 .bearer_auth(&bob_jwt)
222 .json(&reply_payload)
223 .send()
224 .await
225 .expect("Failed to create reply");
226 assert_eq!(reply_res.status(), StatusCode::OK, "Failed to create reply");
227 let reply_body: Value = reply_res.json().await.unwrap();
228 let reply_uri = reply_body["uri"].as_str().unwrap();
229 let reply_cid = reply_body["cid"].as_str().unwrap();
230 let get_reply_res = client
231 .get(format!("{}/xrpc/com.atproto.repo.getRecord", base_url().await))
232 .query(&[("repo", bob_did.as_str()), ("collection", reply_collection), ("rkey", reply_rkey.as_str())])
233 .send()
234 .await
235 .expect("Failed to get reply");
236 assert_eq!(get_reply_res.status(), StatusCode::OK);
237 let reply_record: Value = get_reply_res.json().await.unwrap();
238 assert_eq!(reply_record["value"]["reply"]["root"]["uri"], root_uri);
239 tokio::time::sleep(Duration::from_millis(100)).await;
240 let nested_reply_rkey = format!("e2e_nested_reply_{}", Utc::now().timestamp_millis());
241 let nested_payload = json!({
242 "repo": alice_did,
243 "collection": reply_collection,
244 "rkey": nested_reply_rkey,
245 "record": {
246 "$type": reply_collection,
247 "text": "Alice replies to Bob's reply",
248 "createdAt": Utc::now().to_rfc3339(),
249 "reply": {
250 "root": { "uri": root_uri, "cid": root_cid },
251 "parent": { "uri": reply_uri, "cid": reply_cid }
252 }
253 }
254 });
255 let nested_res = client
256 .post(format!("{}/xrpc/com.atproto.repo.putRecord", base_url().await))
257 .bearer_auth(&alice_jwt)
258 .json(&nested_payload)
259 .send()
260 .await
261 .expect("Failed to create nested reply");
262 assert_eq!(nested_res.status(), StatusCode::OK, "Failed to create nested reply");
263}
264
265#[tokio::test]
266async fn test_authorization_protects_repos() {
267 let client = client();
268 let (alice_did, alice_jwt) = setup_new_user("alice-auth").await;
269 let (_bob_did, bob_jwt) = setup_new_user("bob-auth").await;
270 let (post_uri, _) = create_post(&client, &alice_did, &alice_jwt, "Alice's post").await;
271 let post_rkey = post_uri.split('/').last().unwrap();
272 let post_payload = json!({
273 "repo": alice_did,
274 "collection": "app.bsky.feed.post",
275 "rkey": "unauthorized-post",
276 "record": { "$type": "app.bsky.feed.post", "text": "Bob trying to post as Alice", "createdAt": Utc::now().to_rfc3339() }
277 });
278 let write_res = client
279 .post(format!("{}/xrpc/com.atproto.repo.putRecord", base_url().await))
280 .bearer_auth(&bob_jwt)
281 .json(&post_payload)
282 .send()
283 .await
284 .expect("Failed to send request");
285 assert!(write_res.status() == StatusCode::FORBIDDEN || write_res.status() == StatusCode::UNAUTHORIZED,
286 "Expected 403/401 for writing to another user's repo, got {}", write_res.status());
287 let delete_payload = json!({ "repo": alice_did, "collection": "app.bsky.feed.post", "rkey": post_rkey });
288 let delete_res = client
289 .post(format!("{}/xrpc/com.atproto.repo.deleteRecord", base_url().await))
290 .bearer_auth(&bob_jwt)
291 .json(&delete_payload)
292 .send()
293 .await
294 .expect("Failed to send request");
295 assert!(delete_res.status() == StatusCode::FORBIDDEN || delete_res.status() == StatusCode::UNAUTHORIZED,
296 "Expected 403/401 for deleting another user's record, got {}", delete_res.status());
297 let get_res = client
298 .get(format!("{}/xrpc/com.atproto.repo.getRecord", base_url().await))
299 .query(&[("repo", alice_did.as_str()), ("collection", "app.bsky.feed.post"), ("rkey", post_rkey)])
300 .send()
301 .await
302 .expect("Failed to verify record exists");
303 assert_eq!(get_res.status(), StatusCode::OK, "Record should still exist");
304}
305
306#[tokio::test]
307async fn test_apply_writes_batch() {
308 let client = client();
309 let (did, jwt) = setup_new_user("apply-writes-batch").await;
310 let now = Utc::now().to_rfc3339();
311 let writes_payload = json!({
312 "repo": did,
313 "writes": [
314 { "$type": "com.atproto.repo.applyWrites#create", "collection": "app.bsky.feed.post", "rkey": "batch-post-1", "value": { "$type": "app.bsky.feed.post", "text": "First batch post", "createdAt": now } },
315 { "$type": "com.atproto.repo.applyWrites#create", "collection": "app.bsky.feed.post", "rkey": "batch-post-2", "value": { "$type": "app.bsky.feed.post", "text": "Second batch post", "createdAt": now } },
316 { "$type": "com.atproto.repo.applyWrites#create", "collection": "app.bsky.actor.profile", "rkey": "self", "value": { "$type": "app.bsky.actor.profile", "displayName": "Batch User" } }
317 ]
318 });
319 let apply_res = client
320 .post(format!("{}/xrpc/com.atproto.repo.applyWrites", base_url().await))
321 .bearer_auth(&jwt)
322 .json(&writes_payload)
323 .send()
324 .await
325 .expect("Failed to apply writes");
326 assert_eq!(apply_res.status(), StatusCode::OK);
327 let get_post1 = client
328 .get(format!("{}/xrpc/com.atproto.repo.getRecord", base_url().await))
329 .query(&[("repo", did.as_str()), ("collection", "app.bsky.feed.post"), ("rkey", "batch-post-1")])
330 .send().await.expect("Failed to get post 1");
331 assert_eq!(get_post1.status(), StatusCode::OK);
332 let post1_body: Value = get_post1.json().await.unwrap();
333 assert_eq!(post1_body["value"]["text"], "First batch post");
334 let get_post2 = client
335 .get(format!("{}/xrpc/com.atproto.repo.getRecord", base_url().await))
336 .query(&[("repo", did.as_str()), ("collection", "app.bsky.feed.post"), ("rkey", "batch-post-2")])
337 .send().await.expect("Failed to get post 2");
338 assert_eq!(get_post2.status(), StatusCode::OK);
339 let get_profile = client
340 .get(format!("{}/xrpc/com.atproto.repo.getRecord", base_url().await))
341 .query(&[("repo", did.as_str()), ("collection", "app.bsky.actor.profile"), ("rkey", "self")])
342 .send().await.expect("Failed to get profile");
343 let profile_body: Value = get_profile.json().await.unwrap();
344 assert_eq!(profile_body["value"]["displayName"], "Batch User");
345 let update_writes = json!({
346 "repo": did,
347 "writes": [
348 { "$type": "com.atproto.repo.applyWrites#update", "collection": "app.bsky.actor.profile", "rkey": "self", "value": { "$type": "app.bsky.actor.profile", "displayName": "Updated Batch User" } },
349 { "$type": "com.atproto.repo.applyWrites#delete", "collection": "app.bsky.feed.post", "rkey": "batch-post-1" }
350 ]
351 });
352 let update_res = client
353 .post(format!("{}/xrpc/com.atproto.repo.applyWrites", base_url().await))
354 .bearer_auth(&jwt)
355 .json(&update_writes)
356 .send()
357 .await
358 .expect("Failed to apply update writes");
359 assert_eq!(update_res.status(), StatusCode::OK);
360 let get_updated_profile = client
361 .get(format!("{}/xrpc/com.atproto.repo.getRecord", base_url().await))
362 .query(&[("repo", did.as_str()), ("collection", "app.bsky.actor.profile"), ("rkey", "self")])
363 .send().await.expect("Failed to get updated profile");
364 let updated_profile: Value = get_updated_profile.json().await.unwrap();
365 assert_eq!(updated_profile["value"]["displayName"], "Updated Batch User");
366 let get_deleted_post = client
367 .get(format!("{}/xrpc/com.atproto.repo.getRecord", base_url().await))
368 .query(&[("repo", did.as_str()), ("collection", "app.bsky.feed.post"), ("rkey", "batch-post-1")])
369 .send().await.expect("Failed to check deleted post");
370 assert_eq!(get_deleted_post.status(), StatusCode::NOT_FOUND, "Batch-deleted post should be gone");
371}
372
373async fn create_post_with_rkey(client: &reqwest::Client, did: &str, jwt: &str, rkey: &str, text: &str) -> (String, String) {
374 let payload = json!({
375 "repo": did, "collection": "app.bsky.feed.post", "rkey": rkey,
376 "record": { "$type": "app.bsky.feed.post", "text": text, "createdAt": Utc::now().to_rfc3339() }
377 });
378 let res = client
379 .post(format!("{}/xrpc/com.atproto.repo.putRecord", base_url().await))
380 .bearer_auth(jwt)
381 .json(&payload)
382 .send()
383 .await
384 .expect("Failed to create record");
385 assert_eq!(res.status(), StatusCode::OK);
386 let body: Value = res.json().await.unwrap();
387 (body["uri"].as_str().unwrap().to_string(), body["cid"].as_str().unwrap().to_string())
388}
389
390#[tokio::test]
391async fn test_list_records_comprehensive() {
392 let client = client();
393 let (did, jwt) = setup_new_user("list-records-test").await;
394 for i in 0..5 {
395 create_post_with_rkey(&client, &did, &jwt, &format!("post{:02}", i), &format!("Post {}", i)).await;
396 tokio::time::sleep(Duration::from_millis(50)).await;
397 }
398 let res = client
399 .get(format!("{}/xrpc/com.atproto.repo.listRecords", base_url().await))
400 .query(&[("repo", did.as_str()), ("collection", "app.bsky.feed.post")])
401 .send().await.expect("Failed to list records");
402 assert_eq!(res.status(), StatusCode::OK);
403 let body: Value = res.json().await.unwrap();
404 let records = body["records"].as_array().unwrap();
405 assert_eq!(records.len(), 5);
406 let rkeys: Vec<&str> = records.iter().map(|r| r["uri"].as_str().unwrap().split('/').last().unwrap()).collect();
407 assert_eq!(rkeys, vec!["post04", "post03", "post02", "post01", "post00"], "Default order should be DESC");
408 for record in records {
409 assert!(record["uri"].is_string());
410 assert!(record["cid"].is_string());
411 assert!(record["cid"].as_str().unwrap().starts_with("bafy"));
412 assert!(record["value"].is_object());
413 }
414 let rev_res = client
415 .get(format!("{}/xrpc/com.atproto.repo.listRecords", base_url().await))
416 .query(&[("repo", did.as_str()), ("collection", "app.bsky.feed.post"), ("reverse", "true")])
417 .send().await.expect("Failed to list records reverse");
418 let rev_body: Value = rev_res.json().await.unwrap();
419 let rev_rkeys: Vec<&str> = rev_body["records"].as_array().unwrap().iter()
420 .map(|r| r["uri"].as_str().unwrap().split('/').last().unwrap()).collect();
421 assert_eq!(rev_rkeys, vec!["post00", "post01", "post02", "post03", "post04"], "reverse=true should give ASC");
422 let page1 = client
423 .get(format!("{}/xrpc/com.atproto.repo.listRecords", base_url().await))
424 .query(&[("repo", did.as_str()), ("collection", "app.bsky.feed.post"), ("limit", "2")])
425 .send().await.expect("Failed to list page 1");
426 let page1_body: Value = page1.json().await.unwrap();
427 let page1_records = page1_body["records"].as_array().unwrap();
428 assert_eq!(page1_records.len(), 2);
429 let cursor = page1_body["cursor"].as_str().expect("Should have cursor");
430 let page2 = client
431 .get(format!("{}/xrpc/com.atproto.repo.listRecords", base_url().await))
432 .query(&[("repo", did.as_str()), ("collection", "app.bsky.feed.post"), ("limit", "2"), ("cursor", cursor)])
433 .send().await.expect("Failed to list page 2");
434 let page2_body: Value = page2.json().await.unwrap();
435 let page2_records = page2_body["records"].as_array().unwrap();
436 assert_eq!(page2_records.len(), 2);
437 let all_uris: Vec<&str> = page1_records.iter().chain(page2_records.iter())
438 .map(|r| r["uri"].as_str().unwrap()).collect();
439 let unique_uris: std::collections::HashSet<&str> = all_uris.iter().copied().collect();
440 assert_eq!(all_uris.len(), unique_uris.len(), "Cursor pagination should not repeat records");
441 let range_res = client
442 .get(format!("{}/xrpc/com.atproto.repo.listRecords", base_url().await))
443 .query(&[("repo", did.as_str()), ("collection", "app.bsky.feed.post"),
444 ("rkeyStart", "post01"), ("rkeyEnd", "post03"), ("reverse", "true")])
445 .send().await.expect("Failed to list range");
446 let range_body: Value = range_res.json().await.unwrap();
447 let range_rkeys: Vec<&str> = range_body["records"].as_array().unwrap().iter()
448 .map(|r| r["uri"].as_str().unwrap().split('/').last().unwrap()).collect();
449 for rkey in &range_rkeys {
450 assert!(*rkey >= "post01" && *rkey <= "post03", "Range should be inclusive");
451 }
452 let limit_res = client
453 .get(format!("{}/xrpc/com.atproto.repo.listRecords", base_url().await))
454 .query(&[("repo", did.as_str()), ("collection", "app.bsky.feed.post"), ("limit", "1000")])
455 .send().await.expect("Failed with high limit");
456 let limit_body: Value = limit_res.json().await.unwrap();
457 assert!(limit_body["records"].as_array().unwrap().len() <= 100, "Limit should be clamped to max 100");
458 let not_found_res = client
459 .get(format!("{}/xrpc/com.atproto.repo.listRecords", base_url().await))
460 .query(&[("repo", "did:plc:nonexistent12345"), ("collection", "app.bsky.feed.post")])
461 .send().await.expect("Failed with nonexistent repo");
462 assert_eq!(not_found_res.status(), StatusCode::NOT_FOUND);
463}