Our Personal Data Server from scratch! tranquil.farm
pds rust database fun oauth atproto
238
fork

Configure Feed

Select the types of activity you want to include in your feed.

Repo conf. vs ref

+749 -81
+52 -15
src/api/repo/record/batch.rs
··· 1 - use super::validation::validate_record_with_rkey; 1 + use super::validation::validate_record_with_status; 2 2 use super::write::has_verified_comms_channel; 3 + use crate::validation::ValidationStatus; 3 4 use crate::api::repo::record::utils::{CommitParams, RecordOp, commit_and_log, extract_blob_cids}; 4 5 use crate::delegation::{self, DelegationActionType}; 5 6 use crate::repo::tracking::TrackingBlockStore; ··· 56 57 #[serde(tag = "$type")] 57 58 pub enum WriteResult { 58 59 #[serde(rename = "com.atproto.repo.applyWrites#createResult")] 59 - CreateResult { uri: String, cid: String }, 60 + CreateResult { 61 + uri: String, 62 + cid: String, 63 + #[serde(rename = "validationStatus", skip_serializing_if = "Option::is_none")] 64 + validation_status: Option<String>, 65 + }, 60 66 #[serde(rename = "com.atproto.repo.applyWrites#updateResult")] 61 - UpdateResult { uri: String, cid: String }, 67 + UpdateResult { 68 + uri: String, 69 + cid: String, 70 + #[serde(rename = "validationStatus", skip_serializing_if = "Option::is_none")] 71 + validation_status: Option<String>, 72 + }, 62 73 #[serde(rename = "com.atproto.repo.applyWrites#deleteResult")] 63 74 DeleteResult {}, 64 75 } ··· 303 314 rkey, 304 315 value, 305 316 } => { 306 - if input.validate.unwrap_or(true) 307 - && let Err(err_response) = 308 - validate_record_with_rkey(value, collection, rkey.as_deref()) 309 - { 310 - return *err_response; 311 - } 317 + let validation_status = if input.validate == Some(false) { 318 + None 319 + } else { 320 + let require_lexicon = input.validate == Some(true); 321 + match validate_record_with_status( 322 + value, 323 + collection, 324 + rkey.as_deref(), 325 + require_lexicon, 326 + ) { 327 + Ok(status) => Some(status), 328 + Err(err_response) => return *err_response, 329 + } 330 + }; 312 331 all_blob_cids.extend(extract_blob_cids(value)); 313 332 let rkey = rkey 314 333 .clone() ··· 345 364 results.push(WriteResult::CreateResult { 346 365 uri, 347 366 cid: record_cid.to_string(), 367 + validation_status: validation_status.map(|s| match s { 368 + ValidationStatus::Valid => "valid".to_string(), 369 + ValidationStatus::Unknown => "unknown".to_string(), 370 + ValidationStatus::Invalid => "invalid".to_string(), 371 + }), 348 372 }); 349 373 ops.push(RecordOp::Create { 350 374 collection: collection.clone(), ··· 357 381 rkey, 358 382 value, 359 383 } => { 360 - if input.validate.unwrap_or(true) 361 - && let Err(err_response) = 362 - validate_record_with_rkey(value, collection, Some(rkey)) 363 - { 364 - return *err_response; 365 - } 384 + let validation_status = if input.validate == Some(false) { 385 + None 386 + } else { 387 + let require_lexicon = input.validate == Some(true); 388 + match validate_record_with_status( 389 + value, 390 + collection, 391 + Some(rkey), 392 + require_lexicon, 393 + ) { 394 + Ok(status) => Some(status), 395 + Err(err_response) => return *err_response, 396 + } 397 + }; 366 398 all_blob_cids.extend(extract_blob_cids(value)); 367 399 let mut record_bytes = Vec::new(); 368 400 if serde_ipld_dagcbor::to_writer(&mut record_bytes, value).is_err() { ··· 397 429 results.push(WriteResult::UpdateResult { 398 430 uri, 399 431 cid: record_cid.to_string(), 432 + validation_status: validation_status.map(|s| match s { 433 + ValidationStatus::Valid => "valid".to_string(), 434 + ValidationStatus::Unknown => "unknown".to_string(), 435 + ValidationStatus::Invalid => "invalid".to_string(), 436 + }), 400 437 }); 401 438 ops.push(RecordOp::Update { 402 439 collection: collection.clone(),
+33 -10
src/api/repo/record/delete.rs
··· 1 1 use crate::api::repo::record::utils::{CommitParams, RecordOp, commit_and_log}; 2 - use crate::api::repo::record::write::prepare_repo_write; 2 + use crate::api::repo::record::write::{CommitInfo, prepare_repo_write}; 3 3 use crate::delegation::{self, DelegationActionType}; 4 4 use crate::repo::tracking::TrackingBlockStore; 5 5 use crate::state::AppState; ··· 12 12 use cid::Cid; 13 13 use jacquard::types::string::Nsid; 14 14 use jacquard_repo::{commit::Commit, mst::Mst, storage::BlockStore}; 15 - use serde::Deserialize; 15 + use serde::{Deserialize, Serialize}; 16 16 use serde_json::json; 17 17 use std::str::FromStr; 18 18 use std::sync::Arc; ··· 27 27 pub swap_record: Option<String>, 28 28 #[serde(rename = "swapCommit")] 29 29 pub swap_commit: Option<String>, 30 + } 31 + 32 + #[derive(Serialize)] 33 + #[serde(rename_all = "camelCase")] 34 + pub struct DeleteRecordOutput { 35 + #[serde(skip_serializing_if = "Option::is_none")] 36 + pub commit: Option<CommitInfo>, 30 37 } 31 38 32 39 pub async fn delete_record( ··· 106 113 } 107 114 let prev_record_cid = mst.get(&key).await.ok().flatten(); 108 115 if prev_record_cid.is_none() { 109 - return (StatusCode::OK, Json(json!({}))).into_response(); 116 + return ( 117 + StatusCode::OK, 118 + Json(DeleteRecordOutput { commit: None }), 119 + ) 120 + .into_response(); 110 121 } 111 122 let new_mst = match mst.delete(&key).await { 112 123 Ok(m) => m, ··· 158 169 .iter() 159 170 .map(|c| c.to_string()) 160 171 .collect::<Vec<_>>(); 161 - if let Err(e) = commit_and_log( 172 + let commit_result = match commit_and_log( 162 173 &state, 163 174 CommitParams { 164 175 did: &did, ··· 173 184 ) 174 185 .await 175 186 { 176 - return ( 177 - StatusCode::INTERNAL_SERVER_ERROR, 178 - Json(json!({"error": "InternalError", "message": e})), 179 - ) 180 - .into_response(); 187 + Ok(res) => res, 188 + Err(e) => { 189 + return ( 190 + StatusCode::INTERNAL_SERVER_ERROR, 191 + Json(json!({"error": "InternalError", "message": e})), 192 + ) 193 + .into_response(); 194 + } 181 195 }; 182 196 183 197 if let Some(ref controller) = controller_did { ··· 198 212 .await; 199 213 } 200 214 201 - (StatusCode::OK, Json(json!({}))).into_response() 215 + ( 216 + StatusCode::OK, 217 + Json(DeleteRecordOutput { 218 + commit: Some(CommitInfo { 219 + cid: commit_result.commit_cid.to_string(), 220 + rev: commit_result.rev, 221 + }), 222 + }), 223 + ) 224 + .into_response() 202 225 }
+2 -2
src/api/repo/record/read.rs
··· 160 160 _ => { 161 161 return ( 162 162 StatusCode::NOT_FOUND, 163 - Json(json!({"error": "NotFound", "message": "Record not found"})), 163 + Json(json!({"error": "RecordNotFound", "message": "Record not found"})), 164 164 ) 165 165 .into_response(); 166 166 } ··· 170 170 { 171 171 return ( 172 172 StatusCode::NOT_FOUND, 173 - Json(json!({"error": "NotFound", "message": "Record CID mismatch"})), 173 + Json(json!({"error": "RecordNotFound", "message": "Record CID mismatch"})), 174 174 ) 175 175 .into_response(); 176 176 }
+82 -29
src/api/repo/record/validation.rs
··· 1 - use crate::validation::{RecordValidator, ValidationError}; 1 + use crate::validation::{RecordValidator, ValidationError, ValidationStatus}; 2 2 use axum::{ 3 3 Json, 4 4 http::StatusCode, ··· 16 16 rkey: Option<&str>, 17 17 ) -> Result<(), Box<Response>> { 18 18 let validator = RecordValidator::new(); 19 + validation_error_to_response(validator.validate_with_rkey(record, collection, rkey)) 20 + } 21 + 22 + pub fn validate_record_with_status( 23 + record: &serde_json::Value, 24 + collection: &str, 25 + rkey: Option<&str>, 26 + require_lexicon: bool, 27 + ) -> Result<ValidationStatus, Box<Response>> { 28 + let validator = RecordValidator::new().require_lexicon(require_lexicon); 19 29 match validator.validate_with_rkey(record, collection, rkey) { 30 + Ok(status) => Ok(status), 31 + Err(e) => Err(validation_error_to_box_response(e)), 32 + } 33 + } 34 + 35 + fn validation_error_to_response( 36 + result: Result<ValidationStatus, ValidationError>, 37 + ) -> Result<(), Box<Response>> { 38 + match result { 20 39 Ok(_) => Ok(()), 21 - Err(ValidationError::MissingType) => Err(Box::new(( 22 - StatusCode::BAD_REQUEST, 23 - Json(json!({"error": "InvalidRecord", "message": "Record must have a $type field"})), 24 - ).into_response())), 25 - Err(ValidationError::TypeMismatch { expected, actual }) => Err(Box::new(( 26 - StatusCode::BAD_REQUEST, 27 - Json(json!({"error": "InvalidRecord", "message": format!("Record $type '{}' does not match collection '{}'", actual, expected)})), 28 - ).into_response())), 29 - Err(ValidationError::MissingField(field)) => Err(Box::new(( 30 - StatusCode::BAD_REQUEST, 31 - Json(json!({"error": "InvalidRecord", "message": format!("Missing required field: {}", field)})), 32 - ).into_response())), 33 - Err(ValidationError::InvalidField { path, message }) => Err(Box::new(( 34 - StatusCode::BAD_REQUEST, 35 - Json(json!({"error": "InvalidRecord", "message": format!("Invalid field '{}': {}", path, message)})), 36 - ).into_response())), 37 - Err(ValidationError::InvalidDatetime { path }) => Err(Box::new(( 38 - StatusCode::BAD_REQUEST, 39 - Json(json!({"error": "InvalidRecord", "message": format!("Invalid datetime format at '{}'", path)})), 40 - ).into_response())), 41 - Err(ValidationError::BannedContent { path }) => Err(Box::new(( 42 - StatusCode::BAD_REQUEST, 43 - Json(json!({"error": "InvalidRecord", "message": format!("Unacceptable slur in record at '{}'", path)})), 44 - ).into_response())), 45 - Err(e) => Err(Box::new(( 46 - StatusCode::BAD_REQUEST, 47 - Json(json!({"error": "InvalidRecord", "message": e.to_string()})), 48 - ).into_response())), 40 + Err(e) => Err(validation_error_to_box_response(e)), 41 + } 42 + } 43 + 44 + fn validation_error_to_box_response(e: ValidationError) -> Box<Response> { 45 + match e { 46 + ValidationError::MissingType => Box::new( 47 + ( 48 + StatusCode::BAD_REQUEST, 49 + Json(json!({"error": "InvalidRecord", "message": "Record must have a $type field"})), 50 + ) 51 + .into_response(), 52 + ), 53 + ValidationError::TypeMismatch { expected, actual } => Box::new( 54 + ( 55 + StatusCode::BAD_REQUEST, 56 + Json(json!({"error": "InvalidRecord", "message": format!("Record $type '{}' does not match collection '{}'", actual, expected)})), 57 + ) 58 + .into_response(), 59 + ), 60 + ValidationError::MissingField(field) => Box::new( 61 + ( 62 + StatusCode::BAD_REQUEST, 63 + Json(json!({"error": "InvalidRecord", "message": format!("Missing required field: {}", field)})), 64 + ) 65 + .into_response(), 66 + ), 67 + ValidationError::InvalidField { path, message } => Box::new( 68 + ( 69 + StatusCode::BAD_REQUEST, 70 + Json(json!({"error": "InvalidRecord", "message": format!("Invalid field '{}': {}", path, message)})), 71 + ) 72 + .into_response(), 73 + ), 74 + ValidationError::InvalidDatetime { path } => Box::new( 75 + ( 76 + StatusCode::BAD_REQUEST, 77 + Json(json!({"error": "InvalidRecord", "message": format!("Invalid datetime format at '{}'", path)})), 78 + ) 79 + .into_response(), 80 + ), 81 + ValidationError::BannedContent { path } => Box::new( 82 + ( 83 + StatusCode::BAD_REQUEST, 84 + Json(json!({"error": "InvalidRecord", "message": format!("Unacceptable slur in record at '{}'", path)})), 85 + ) 86 + .into_response(), 87 + ), 88 + ValidationError::UnknownType(type_name) => Box::new( 89 + ( 90 + StatusCode::BAD_REQUEST, 91 + Json(json!({"error": "InvalidRecord", "message": format!("Lexicon not found: lex:{}", type_name)})), 92 + ) 93 + .into_response(), 94 + ), 95 + e => Box::new( 96 + ( 97 + StatusCode::BAD_REQUEST, 98 + Json(json!({"error": "InvalidRecord", "message": e.to_string()})), 99 + ) 100 + .into_response(), 101 + ), 49 102 } 50 103 }
+96 -25
src/api/repo/record/write.rs
··· 1 - use super::validation::validate_record_with_rkey; 1 + use super::validation::validate_record_with_status; 2 + use crate::validation::ValidationStatus; 2 3 use crate::api::repo::record::utils::{CommitParams, RecordOp, commit_and_log, extract_blob_cids}; 3 4 use crate::delegation::{self, DelegationActionType}; 4 5 use crate::repo::tracking::TrackingBlockStore; ··· 185 186 } 186 187 #[derive(Serialize)] 187 188 #[serde(rename_all = "camelCase")] 189 + pub struct CommitInfo { 190 + pub cid: String, 191 + pub rev: String, 192 + } 193 + 194 + #[derive(Serialize)] 195 + #[serde(rename_all = "camelCase")] 188 196 pub struct CreateRecordOutput { 189 197 pub uri: String, 190 198 pub cid: String, 199 + pub commit: CommitInfo, 200 + #[serde(skip_serializing_if = "Option::is_none")] 201 + pub validation_status: Option<String>, 191 202 } 192 203 pub async fn create_record( 193 204 State(state): State<AppState>, ··· 256 267 .into_response(); 257 268 } 258 269 }; 259 - if input.validate.unwrap_or(true) 260 - && let Err(err_response) = 261 - validate_record_with_rkey(&input.record, &input.collection, input.rkey.as_deref()) 262 - { 263 - return *err_response; 264 - } 270 + let validation_status = if input.validate == Some(false) { 271 + None 272 + } else { 273 + let require_lexicon = input.validate == Some(true); 274 + match validate_record_with_status( 275 + &input.record, 276 + &input.collection, 277 + input.rkey.as_deref(), 278 + require_lexicon, 279 + ) { 280 + Ok(status) => Some(status), 281 + Err(err_response) => return *err_response, 282 + } 283 + }; 265 284 let rkey = input 266 285 .rkey 267 286 .unwrap_or_else(|| Tid::now(LimitedU32::MIN).to_string()); ··· 336 355 .map(|c| c.to_string()) 337 356 .collect::<Vec<_>>(); 338 357 let blob_cids = extract_blob_cids(&input.record); 339 - if let Err(e) = commit_and_log( 358 + let commit_result = match commit_and_log( 340 359 &state, 341 360 CommitParams { 342 361 did: &did, ··· 351 370 ) 352 371 .await 353 372 { 354 - return ( 355 - StatusCode::INTERNAL_SERVER_ERROR, 356 - Json(json!({"error": "InternalError", "message": e})), 357 - ) 358 - .into_response(); 373 + Ok(res) => res, 374 + Err(e) => { 375 + return ( 376 + StatusCode::INTERNAL_SERVER_ERROR, 377 + Json(json!({"error": "InternalError", "message": e})), 378 + ) 379 + .into_response(); 380 + } 359 381 }; 360 382 361 383 if let Some(ref controller) = controller_did { ··· 381 403 Json(CreateRecordOutput { 382 404 uri: format!("at://{}/{}/{}", did, input.collection, rkey), 383 405 cid: record_cid.to_string(), 406 + commit: CommitInfo { 407 + cid: commit_result.commit_cid.to_string(), 408 + rev: commit_result.rev, 409 + }, 410 + validation_status: validation_status.map(|s| match s { 411 + ValidationStatus::Valid => "valid".to_string(), 412 + ValidationStatus::Unknown => "unknown".to_string(), 413 + ValidationStatus::Invalid => "invalid".to_string(), 414 + }), 384 415 }), 385 416 ) 386 417 .into_response() ··· 403 434 pub struct PutRecordOutput { 404 435 pub uri: String, 405 436 pub cid: String, 437 + #[serde(skip_serializing_if = "Option::is_none")] 438 + pub commit: Option<CommitInfo>, 439 + #[serde(skip_serializing_if = "Option::is_none")] 440 + pub validation_status: Option<String>, 406 441 } 407 442 pub async fn put_record( 408 443 State(state): State<AppState>, ··· 480 515 } 481 516 }; 482 517 let key = format!("{}/{}", collection_nsid, input.rkey); 483 - if input.validate.unwrap_or(true) 484 - && let Err(err_response) = 485 - validate_record_with_rkey(&input.record, &input.collection, Some(&input.rkey)) 486 - { 487 - return *err_response; 488 - } 518 + let validation_status = if input.validate == Some(false) { 519 + None 520 + } else { 521 + let require_lexicon = input.validate == Some(true); 522 + match validate_record_with_status( 523 + &input.record, 524 + &input.collection, 525 + Some(&input.rkey), 526 + require_lexicon, 527 + ) { 528 + Ok(status) => Some(status), 529 + Err(err_response) => return *err_response, 530 + } 531 + }; 489 532 if let Some(swap_record_str) = &input.swap_record { 490 533 let expected_cid = Cid::from_str(swap_record_str).ok(); 491 534 let actual_cid = mst.get(&key).await.ok().flatten(); ··· 512 555 .into_response(); 513 556 } 514 557 }; 558 + if existing_cid == Some(record_cid) { 559 + return ( 560 + StatusCode::OK, 561 + Json(PutRecordOutput { 562 + uri: format!("at://{}/{}/{}", did, input.collection, input.rkey), 563 + cid: record_cid.to_string(), 564 + commit: None, 565 + validation_status: validation_status.map(|s| match s { 566 + ValidationStatus::Valid => "valid".to_string(), 567 + ValidationStatus::Unknown => "unknown".to_string(), 568 + ValidationStatus::Invalid => "invalid".to_string(), 569 + }), 570 + }), 571 + ) 572 + .into_response(); 573 + } 515 574 let new_mst = if existing_cid.is_some() { 516 575 match mst.update(&key, record_cid).await { 517 576 Ok(m) => m, ··· 587 646 .collect::<Vec<_>>(); 588 647 let is_update = existing_cid.is_some(); 589 648 let blob_cids = extract_blob_cids(&input.record); 590 - if let Err(e) = commit_and_log( 649 + let commit_result = match commit_and_log( 591 650 &state, 592 651 CommitParams { 593 652 did: &did, ··· 602 661 ) 603 662 .await 604 663 { 605 - return ( 606 - StatusCode::INTERNAL_SERVER_ERROR, 607 - Json(json!({"error": "InternalError", "message": e})), 608 - ) 609 - .into_response(); 664 + Ok(res) => res, 665 + Err(e) => { 666 + return ( 667 + StatusCode::INTERNAL_SERVER_ERROR, 668 + Json(json!({"error": "InternalError", "message": e})), 669 + ) 670 + .into_response(); 671 + } 610 672 }; 611 673 612 674 if let Some(ref controller) = controller_did { ··· 632 694 Json(PutRecordOutput { 633 695 uri: format!("at://{}/{}/{}", did, input.collection, input.rkey), 634 696 cid: record_cid.to_string(), 697 + commit: Some(CommitInfo { 698 + cid: commit_result.commit_cid.to_string(), 699 + rev: commit_result.rev, 700 + }), 701 + validation_status: validation_status.map(|s| match s { 702 + ValidationStatus::Valid => "valid".to_string(), 703 + ValidationStatus::Unknown => "unknown".to_string(), 704 + ValidationStatus::Invalid => "invalid".to_string(), 705 + }), 635 706 }), 636 707 ) 637 708 .into_response()
+484
tests/repo_conformance.rs
··· 1 + mod common; 2 + mod helpers; 3 + use chrono::Utc; 4 + use common::*; 5 + use helpers::*; 6 + use reqwest::StatusCode; 7 + use serde_json::{Value, json}; 8 + 9 + #[tokio::test] 10 + async fn test_create_record_response_schema() { 11 + let client = client(); 12 + let (did, jwt) = setup_new_user("conform-create").await; 13 + let now = Utc::now().to_rfc3339(); 14 + 15 + let payload = json!({ 16 + "repo": did, 17 + "collection": "app.bsky.feed.post", 18 + "record": { 19 + "$type": "app.bsky.feed.post", 20 + "text": "Testing conformance", 21 + "createdAt": now 22 + } 23 + }); 24 + 25 + let res = client 26 + .post(format!("{}/xrpc/com.atproto.repo.createRecord", base_url().await)) 27 + .bearer_auth(&jwt) 28 + .json(&payload) 29 + .send() 30 + .await 31 + .expect("Failed to create record"); 32 + 33 + assert_eq!(res.status(), StatusCode::OK); 34 + let body: Value = res.json().await.unwrap(); 35 + 36 + assert!(body["uri"].is_string(), "response must have uri"); 37 + assert!(body["cid"].is_string(), "response must have cid"); 38 + assert!(body["cid"].as_str().unwrap().starts_with("bafy"), "cid must be valid"); 39 + 40 + assert!(body["commit"].is_object(), "response must have commit object"); 41 + let commit = &body["commit"]; 42 + assert!(commit["cid"].is_string(), "commit must have cid"); 43 + assert!(commit["cid"].as_str().unwrap().starts_with("bafy"), "commit.cid must be valid"); 44 + assert!(commit["rev"].is_string(), "commit must have rev"); 45 + 46 + assert!(body["validationStatus"].is_string(), "response must have validationStatus when validate defaults to true"); 47 + assert_eq!(body["validationStatus"], "valid", "validationStatus should be 'valid'"); 48 + } 49 + 50 + #[tokio::test] 51 + async fn test_create_record_no_validation_status_when_validate_false() { 52 + let client = client(); 53 + let (did, jwt) = setup_new_user("conform-create-noval").await; 54 + let now = Utc::now().to_rfc3339(); 55 + 56 + let payload = json!({ 57 + "repo": did, 58 + "collection": "app.bsky.feed.post", 59 + "validate": false, 60 + "record": { 61 + "$type": "app.bsky.feed.post", 62 + "text": "Testing without validation", 63 + "createdAt": now 64 + } 65 + }); 66 + 67 + let res = client 68 + .post(format!("{}/xrpc/com.atproto.repo.createRecord", base_url().await)) 69 + .bearer_auth(&jwt) 70 + .json(&payload) 71 + .send() 72 + .await 73 + .expect("Failed to create record"); 74 + 75 + assert_eq!(res.status(), StatusCode::OK); 76 + let body: Value = res.json().await.unwrap(); 77 + 78 + assert!(body["uri"].is_string()); 79 + assert!(body["commit"].is_object()); 80 + assert!(body["validationStatus"].is_null(), "validationStatus should be omitted when validate=false"); 81 + } 82 + 83 + #[tokio::test] 84 + async fn test_put_record_response_schema() { 85 + let client = client(); 86 + let (did, jwt) = setup_new_user("conform-put").await; 87 + let now = Utc::now().to_rfc3339(); 88 + 89 + let payload = json!({ 90 + "repo": did, 91 + "collection": "app.bsky.feed.post", 92 + "rkey": "conformance-put", 93 + "record": { 94 + "$type": "app.bsky.feed.post", 95 + "text": "Testing putRecord conformance", 96 + "createdAt": now 97 + } 98 + }); 99 + 100 + let res = client 101 + .post(format!("{}/xrpc/com.atproto.repo.putRecord", base_url().await)) 102 + .bearer_auth(&jwt) 103 + .json(&payload) 104 + .send() 105 + .await 106 + .expect("Failed to put record"); 107 + 108 + assert_eq!(res.status(), StatusCode::OK); 109 + let body: Value = res.json().await.unwrap(); 110 + 111 + assert!(body["uri"].is_string(), "response must have uri"); 112 + assert!(body["cid"].is_string(), "response must have cid"); 113 + 114 + assert!(body["commit"].is_object(), "response must have commit object"); 115 + let commit = &body["commit"]; 116 + assert!(commit["cid"].is_string(), "commit must have cid"); 117 + assert!(commit["rev"].is_string(), "commit must have rev"); 118 + 119 + assert_eq!(body["validationStatus"], "valid", "validationStatus should be 'valid'"); 120 + } 121 + 122 + #[tokio::test] 123 + async fn test_delete_record_response_schema() { 124 + let client = client(); 125 + let (did, jwt) = setup_new_user("conform-delete").await; 126 + let now = Utc::now().to_rfc3339(); 127 + 128 + let create_payload = json!({ 129 + "repo": did, 130 + "collection": "app.bsky.feed.post", 131 + "rkey": "to-delete", 132 + "record": { 133 + "$type": "app.bsky.feed.post", 134 + "text": "This will be deleted", 135 + "createdAt": now 136 + } 137 + }); 138 + let create_res = client 139 + .post(format!("{}/xrpc/com.atproto.repo.putRecord", base_url().await)) 140 + .bearer_auth(&jwt) 141 + .json(&create_payload) 142 + .send() 143 + .await 144 + .expect("Failed to create record"); 145 + assert_eq!(create_res.status(), StatusCode::OK); 146 + 147 + let delete_payload = json!({ 148 + "repo": did, 149 + "collection": "app.bsky.feed.post", 150 + "rkey": "to-delete" 151 + }); 152 + let delete_res = client 153 + .post(format!("{}/xrpc/com.atproto.repo.deleteRecord", base_url().await)) 154 + .bearer_auth(&jwt) 155 + .json(&delete_payload) 156 + .send() 157 + .await 158 + .expect("Failed to delete record"); 159 + 160 + assert_eq!(delete_res.status(), StatusCode::OK); 161 + let body: Value = delete_res.json().await.unwrap(); 162 + 163 + assert!(body["commit"].is_object(), "response must have commit object when record was deleted"); 164 + let commit = &body["commit"]; 165 + assert!(commit["cid"].is_string(), "commit must have cid"); 166 + assert!(commit["rev"].is_string(), "commit must have rev"); 167 + } 168 + 169 + #[tokio::test] 170 + async fn test_delete_record_noop_response() { 171 + let client = client(); 172 + let (did, jwt) = setup_new_user("conform-delete-noop").await; 173 + 174 + let delete_payload = json!({ 175 + "repo": did, 176 + "collection": "app.bsky.feed.post", 177 + "rkey": "nonexistent-record" 178 + }); 179 + let delete_res = client 180 + .post(format!("{}/xrpc/com.atproto.repo.deleteRecord", base_url().await)) 181 + .bearer_auth(&jwt) 182 + .json(&delete_payload) 183 + .send() 184 + .await 185 + .expect("Failed to delete record"); 186 + 187 + assert_eq!(delete_res.status(), StatusCode::OK); 188 + let body: Value = delete_res.json().await.unwrap(); 189 + 190 + assert!(body["commit"].is_null(), "commit should be omitted on no-op delete"); 191 + } 192 + 193 + #[tokio::test] 194 + async fn test_apply_writes_response_schema() { 195 + let client = client(); 196 + let (did, jwt) = setup_new_user("conform-apply").await; 197 + let now = Utc::now().to_rfc3339(); 198 + 199 + let payload = json!({ 200 + "repo": did, 201 + "writes": [ 202 + { 203 + "$type": "com.atproto.repo.applyWrites#create", 204 + "collection": "app.bsky.feed.post", 205 + "rkey": "apply-test-1", 206 + "value": { 207 + "$type": "app.bsky.feed.post", 208 + "text": "First post", 209 + "createdAt": now 210 + } 211 + }, 212 + { 213 + "$type": "com.atproto.repo.applyWrites#create", 214 + "collection": "app.bsky.feed.post", 215 + "rkey": "apply-test-2", 216 + "value": { 217 + "$type": "app.bsky.feed.post", 218 + "text": "Second post", 219 + "createdAt": now 220 + } 221 + } 222 + ] 223 + }); 224 + 225 + let res = client 226 + .post(format!("{}/xrpc/com.atproto.repo.applyWrites", base_url().await)) 227 + .bearer_auth(&jwt) 228 + .json(&payload) 229 + .send() 230 + .await 231 + .expect("Failed to apply writes"); 232 + 233 + assert_eq!(res.status(), StatusCode::OK); 234 + let body: Value = res.json().await.unwrap(); 235 + 236 + assert!(body["commit"].is_object(), "response must have commit object"); 237 + let commit = &body["commit"]; 238 + assert!(commit["cid"].is_string(), "commit must have cid"); 239 + assert!(commit["rev"].is_string(), "commit must have rev"); 240 + 241 + assert!(body["results"].is_array(), "response must have results array"); 242 + let results = body["results"].as_array().unwrap(); 243 + assert_eq!(results.len(), 2, "should have 2 results"); 244 + 245 + for result in results { 246 + assert!(result["uri"].is_string(), "result must have uri"); 247 + assert!(result["cid"].is_string(), "result must have cid"); 248 + assert_eq!(result["validationStatus"], "valid", "result must have validationStatus"); 249 + assert_eq!(result["$type"], "com.atproto.repo.applyWrites#createResult"); 250 + } 251 + } 252 + 253 + #[tokio::test] 254 + async fn test_apply_writes_update_and_delete_results() { 255 + let client = client(); 256 + let (did, jwt) = setup_new_user("conform-apply-upd").await; 257 + let now = Utc::now().to_rfc3339(); 258 + 259 + let create_payload = json!({ 260 + "repo": did, 261 + "collection": "app.bsky.feed.post", 262 + "rkey": "to-update", 263 + "record": { 264 + "$type": "app.bsky.feed.post", 265 + "text": "Original", 266 + "createdAt": now 267 + } 268 + }); 269 + client 270 + .post(format!("{}/xrpc/com.atproto.repo.putRecord", base_url().await)) 271 + .bearer_auth(&jwt) 272 + .json(&create_payload) 273 + .send() 274 + .await 275 + .expect("setup failed"); 276 + 277 + let payload = json!({ 278 + "repo": did, 279 + "writes": [ 280 + { 281 + "$type": "com.atproto.repo.applyWrites#update", 282 + "collection": "app.bsky.feed.post", 283 + "rkey": "to-update", 284 + "value": { 285 + "$type": "app.bsky.feed.post", 286 + "text": "Updated", 287 + "createdAt": now 288 + } 289 + }, 290 + { 291 + "$type": "com.atproto.repo.applyWrites#delete", 292 + "collection": "app.bsky.feed.post", 293 + "rkey": "to-update" 294 + } 295 + ] 296 + }); 297 + 298 + let res = client 299 + .post(format!("{}/xrpc/com.atproto.repo.applyWrites", base_url().await)) 300 + .bearer_auth(&jwt) 301 + .json(&payload) 302 + .send() 303 + .await 304 + .expect("Failed to apply writes"); 305 + 306 + assert_eq!(res.status(), StatusCode::OK); 307 + let body: Value = res.json().await.unwrap(); 308 + 309 + let results = body["results"].as_array().unwrap(); 310 + assert_eq!(results.len(), 2); 311 + 312 + let update_result = &results[0]; 313 + assert_eq!(update_result["$type"], "com.atproto.repo.applyWrites#updateResult"); 314 + assert!(update_result["uri"].is_string()); 315 + assert!(update_result["cid"].is_string()); 316 + assert_eq!(update_result["validationStatus"], "valid"); 317 + 318 + let delete_result = &results[1]; 319 + assert_eq!(delete_result["$type"], "com.atproto.repo.applyWrites#deleteResult"); 320 + assert!(delete_result["uri"].is_null(), "delete result should not have uri"); 321 + assert!(delete_result["cid"].is_null(), "delete result should not have cid"); 322 + assert!(delete_result["validationStatus"].is_null(), "delete result should not have validationStatus"); 323 + } 324 + 325 + #[tokio::test] 326 + async fn test_get_record_error_code() { 327 + let client = client(); 328 + let (did, _jwt) = setup_new_user("conform-get-err").await; 329 + 330 + let res = client 331 + .get(format!("{}/xrpc/com.atproto.repo.getRecord", base_url().await)) 332 + .query(&[ 333 + ("repo", did.as_str()), 334 + ("collection", "app.bsky.feed.post"), 335 + ("rkey", "nonexistent"), 336 + ]) 337 + .send() 338 + .await 339 + .expect("Failed to get record"); 340 + 341 + assert_eq!(res.status(), StatusCode::NOT_FOUND); 342 + let body: Value = res.json().await.unwrap(); 343 + assert_eq!(body["error"], "RecordNotFound", "error code should be RecordNotFound per atproto spec"); 344 + } 345 + 346 + #[tokio::test] 347 + async fn test_create_record_unknown_lexicon_default_validation() { 348 + let client = client(); 349 + let (did, jwt) = setup_new_user("conform-unknown-lex").await; 350 + 351 + let payload = json!({ 352 + "repo": did, 353 + "collection": "com.example.custom", 354 + "record": { 355 + "$type": "com.example.custom", 356 + "data": "some custom data" 357 + } 358 + }); 359 + 360 + let res = client 361 + .post(format!("{}/xrpc/com.atproto.repo.createRecord", base_url().await)) 362 + .bearer_auth(&jwt) 363 + .json(&payload) 364 + .send() 365 + .await 366 + .expect("Failed to create record"); 367 + 368 + assert_eq!(res.status(), StatusCode::OK, "unknown lexicon should be allowed with default validation"); 369 + let body: Value = res.json().await.unwrap(); 370 + 371 + assert!(body["uri"].is_string()); 372 + assert!(body["cid"].is_string()); 373 + assert!(body["commit"].is_object()); 374 + assert_eq!(body["validationStatus"], "unknown", "validationStatus should be 'unknown' for unknown lexicons"); 375 + } 376 + 377 + #[tokio::test] 378 + async fn test_create_record_unknown_lexicon_strict_validation() { 379 + let client = client(); 380 + let (did, jwt) = setup_new_user("conform-unknown-strict").await; 381 + 382 + let payload = json!({ 383 + "repo": did, 384 + "collection": "com.example.custom", 385 + "validate": true, 386 + "record": { 387 + "$type": "com.example.custom", 388 + "data": "some custom data" 389 + } 390 + }); 391 + 392 + let res = client 393 + .post(format!("{}/xrpc/com.atproto.repo.createRecord", base_url().await)) 394 + .bearer_auth(&jwt) 395 + .json(&payload) 396 + .send() 397 + .await 398 + .expect("Failed to send request"); 399 + 400 + assert_eq!(res.status(), StatusCode::BAD_REQUEST, "unknown lexicon should fail with validate=true"); 401 + let body: Value = res.json().await.unwrap(); 402 + assert_eq!(body["error"], "InvalidRecord"); 403 + assert!(body["message"].as_str().unwrap().contains("Lexicon not found"), "error should mention lexicon not found"); 404 + } 405 + 406 + #[tokio::test] 407 + async fn test_put_record_noop_same_content() { 408 + let client = client(); 409 + let (did, jwt) = setup_new_user("conform-put-noop").await; 410 + let now = Utc::now().to_rfc3339(); 411 + 412 + let record = json!({ 413 + "$type": "app.bsky.feed.post", 414 + "text": "This content will not change", 415 + "createdAt": now 416 + }); 417 + 418 + let payload = json!({ 419 + "repo": did, 420 + "collection": "app.bsky.feed.post", 421 + "rkey": "noop-test", 422 + "record": record.clone() 423 + }); 424 + 425 + let first_res = client 426 + .post(format!("{}/xrpc/com.atproto.repo.putRecord", base_url().await)) 427 + .bearer_auth(&jwt) 428 + .json(&payload) 429 + .send() 430 + .await 431 + .expect("Failed to put record"); 432 + assert_eq!(first_res.status(), StatusCode::OK); 433 + let first_body: Value = first_res.json().await.unwrap(); 434 + assert!(first_body["commit"].is_object(), "first put should have commit"); 435 + 436 + let second_res = client 437 + .post(format!("{}/xrpc/com.atproto.repo.putRecord", base_url().await)) 438 + .bearer_auth(&jwt) 439 + .json(&payload) 440 + .send() 441 + .await 442 + .expect("Failed to put record"); 443 + assert_eq!(second_res.status(), StatusCode::OK); 444 + let second_body: Value = second_res.json().await.unwrap(); 445 + 446 + assert!(second_body["commit"].is_null(), "second put with same content should have no commit (no-op)"); 447 + assert_eq!(first_body["cid"], second_body["cid"], "CID should be the same for identical content"); 448 + } 449 + 450 + #[tokio::test] 451 + async fn test_apply_writes_unknown_lexicon() { 452 + let client = client(); 453 + let (did, jwt) = setup_new_user("conform-apply-unknown").await; 454 + 455 + let payload = json!({ 456 + "repo": did, 457 + "writes": [ 458 + { 459 + "$type": "com.atproto.repo.applyWrites#create", 460 + "collection": "com.example.custom", 461 + "rkey": "custom-1", 462 + "value": { 463 + "$type": "com.example.custom", 464 + "data": "custom data" 465 + } 466 + } 467 + ] 468 + }); 469 + 470 + let res = client 471 + .post(format!("{}/xrpc/com.atproto.repo.applyWrites", base_url().await)) 472 + .bearer_auth(&jwt) 473 + .json(&payload) 474 + .send() 475 + .await 476 + .expect("Failed to apply writes"); 477 + 478 + assert_eq!(res.status(), StatusCode::OK); 479 + let body: Value = res.json().await.unwrap(); 480 + 481 + let results = body["results"].as_array().unwrap(); 482 + assert_eq!(results.len(), 1); 483 + assert_eq!(results[0]["validationStatus"], "unknown", "unknown lexicon should have 'unknown' status"); 484 + }