PDS software with bells & whistles you didn’t even know you needed. will move this to its own account when ready.

Repo conf. vs ref

Changed files
+749 -81
src
tests
+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 + }