diff --git a/src/api/repo/record/batch.rs b/src/api/repo/record/batch.rs index 5eaa9af..e1db768 100644 --- a/src/api/repo/record/batch.rs +++ b/src/api/repo/record/batch.rs @@ -1,5 +1,6 @@ -use super::validation::validate_record_with_rkey; +use super::validation::validate_record_with_status; use super::write::has_verified_comms_channel; +use crate::validation::ValidationStatus; use crate::api::repo::record::utils::{CommitParams, RecordOp, commit_and_log, extract_blob_cids}; use crate::delegation::{self, DelegationActionType}; use crate::repo::tracking::TrackingBlockStore; @@ -56,9 +57,19 @@ pub struct ApplyWritesInput { #[serde(tag = "$type")] pub enum WriteResult { #[serde(rename = "com.atproto.repo.applyWrites#createResult")] - CreateResult { uri: String, cid: String }, + CreateResult { + uri: String, + cid: String, + #[serde(rename = "validationStatus", skip_serializing_if = "Option::is_none")] + validation_status: Option, + }, #[serde(rename = "com.atproto.repo.applyWrites#updateResult")] - UpdateResult { uri: String, cid: String }, + UpdateResult { + uri: String, + cid: String, + #[serde(rename = "validationStatus", skip_serializing_if = "Option::is_none")] + validation_status: Option, + }, #[serde(rename = "com.atproto.repo.applyWrites#deleteResult")] DeleteResult {}, } @@ -303,12 +314,20 @@ pub async fn apply_writes( rkey, value, } => { - if input.validate.unwrap_or(true) - && let Err(err_response) = - validate_record_with_rkey(value, collection, rkey.as_deref()) - { - return *err_response; - } + let validation_status = if input.validate == Some(false) { + None + } else { + let require_lexicon = input.validate == Some(true); + match validate_record_with_status( + value, + collection, + rkey.as_deref(), + require_lexicon, + ) { + Ok(status) => Some(status), + Err(err_response) => return *err_response, + } + }; all_blob_cids.extend(extract_blob_cids(value)); let rkey = rkey .clone() @@ -345,6 +364,11 @@ pub async fn apply_writes( results.push(WriteResult::CreateResult { uri, cid: record_cid.to_string(), + validation_status: validation_status.map(|s| match s { + ValidationStatus::Valid => "valid".to_string(), + ValidationStatus::Unknown => "unknown".to_string(), + ValidationStatus::Invalid => "invalid".to_string(), + }), }); ops.push(RecordOp::Create { collection: collection.clone(), @@ -357,12 +381,20 @@ pub async fn apply_writes( rkey, value, } => { - if input.validate.unwrap_or(true) - && let Err(err_response) = - validate_record_with_rkey(value, collection, Some(rkey)) - { - return *err_response; - } + let validation_status = if input.validate == Some(false) { + None + } else { + let require_lexicon = input.validate == Some(true); + match validate_record_with_status( + value, + collection, + Some(rkey), + require_lexicon, + ) { + Ok(status) => Some(status), + Err(err_response) => return *err_response, + } + }; all_blob_cids.extend(extract_blob_cids(value)); let mut record_bytes = Vec::new(); if serde_ipld_dagcbor::to_writer(&mut record_bytes, value).is_err() { @@ -397,6 +429,11 @@ pub async fn apply_writes( results.push(WriteResult::UpdateResult { uri, cid: record_cid.to_string(), + validation_status: validation_status.map(|s| match s { + ValidationStatus::Valid => "valid".to_string(), + ValidationStatus::Unknown => "unknown".to_string(), + ValidationStatus::Invalid => "invalid".to_string(), + }), }); ops.push(RecordOp::Update { collection: collection.clone(), diff --git a/src/api/repo/record/delete.rs b/src/api/repo/record/delete.rs index 2808bd7..28936a3 100644 --- a/src/api/repo/record/delete.rs +++ b/src/api/repo/record/delete.rs @@ -1,5 +1,5 @@ use crate::api::repo::record::utils::{CommitParams, RecordOp, commit_and_log}; -use crate::api::repo::record::write::prepare_repo_write; +use crate::api::repo::record::write::{CommitInfo, prepare_repo_write}; use crate::delegation::{self, DelegationActionType}; use crate::repo::tracking::TrackingBlockStore; use crate::state::AppState; @@ -12,7 +12,7 @@ use axum::{ use cid::Cid; use jacquard::types::string::Nsid; use jacquard_repo::{commit::Commit, mst::Mst, storage::BlockStore}; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; use serde_json::json; use std::str::FromStr; use std::sync::Arc; @@ -29,6 +29,13 @@ pub struct DeleteRecordInput { pub swap_commit: Option, } +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct DeleteRecordOutput { + #[serde(skip_serializing_if = "Option::is_none")] + pub commit: Option, +} + pub async fn delete_record( State(state): State, headers: HeaderMap, @@ -106,7 +113,11 @@ pub async fn delete_record( } let prev_record_cid = mst.get(&key).await.ok().flatten(); if prev_record_cid.is_none() { - return (StatusCode::OK, Json(json!({}))).into_response(); + return ( + StatusCode::OK, + Json(DeleteRecordOutput { commit: None }), + ) + .into_response(); } let new_mst = match mst.delete(&key).await { Ok(m) => m, @@ -158,7 +169,7 @@ pub async fn delete_record( .iter() .map(|c| c.to_string()) .collect::>(); - if let Err(e) = commit_and_log( + let commit_result = match commit_and_log( &state, CommitParams { did: &did, @@ -173,11 +184,14 @@ pub async fn delete_record( ) .await { - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError", "message": e})), - ) - .into_response(); + Ok(res) => res, + Err(e) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "InternalError", "message": e})), + ) + .into_response(); + } }; if let Some(ref controller) = controller_did { @@ -198,5 +212,14 @@ pub async fn delete_record( .await; } - (StatusCode::OK, Json(json!({}))).into_response() + ( + StatusCode::OK, + Json(DeleteRecordOutput { + commit: Some(CommitInfo { + cid: commit_result.commit_cid.to_string(), + rev: commit_result.rev, + }), + }), + ) + .into_response() } diff --git a/src/api/repo/record/read.rs b/src/api/repo/record/read.rs index 219114f..8e74520 100644 --- a/src/api/repo/record/read.rs +++ b/src/api/repo/record/read.rs @@ -160,7 +160,7 @@ pub async fn get_record( _ => { return ( StatusCode::NOT_FOUND, - Json(json!({"error": "NotFound", "message": "Record not found"})), + Json(json!({"error": "RecordNotFound", "message": "Record not found"})), ) .into_response(); } @@ -170,7 +170,7 @@ pub async fn get_record( { return ( StatusCode::NOT_FOUND, - Json(json!({"error": "NotFound", "message": "Record CID mismatch"})), + Json(json!({"error": "RecordNotFound", "message": "Record CID mismatch"})), ) .into_response(); } diff --git a/src/api/repo/record/validation.rs b/src/api/repo/record/validation.rs index c705abb..d00ad63 100644 --- a/src/api/repo/record/validation.rs +++ b/src/api/repo/record/validation.rs @@ -1,4 +1,4 @@ -use crate::validation::{RecordValidator, ValidationError}; +use crate::validation::{RecordValidator, ValidationError, ValidationStatus}; use axum::{ Json, http::StatusCode, @@ -16,35 +16,88 @@ pub fn validate_record_with_rkey( rkey: Option<&str>, ) -> Result<(), Box> { let validator = RecordValidator::new(); + validation_error_to_response(validator.validate_with_rkey(record, collection, rkey)) +} + +pub fn validate_record_with_status( + record: &serde_json::Value, + collection: &str, + rkey: Option<&str>, + require_lexicon: bool, +) -> Result> { + let validator = RecordValidator::new().require_lexicon(require_lexicon); match validator.validate_with_rkey(record, collection, rkey) { + Ok(status) => Ok(status), + Err(e) => Err(validation_error_to_box_response(e)), + } +} + +fn validation_error_to_response( + result: Result, +) -> Result<(), Box> { + match result { Ok(_) => Ok(()), - Err(ValidationError::MissingType) => Err(Box::new(( - StatusCode::BAD_REQUEST, - Json(json!({"error": "InvalidRecord", "message": "Record must have a $type field"})), - ).into_response())), - Err(ValidationError::TypeMismatch { expected, actual }) => Err(Box::new(( - StatusCode::BAD_REQUEST, - Json(json!({"error": "InvalidRecord", "message": format!("Record $type '{}' does not match collection '{}'", actual, expected)})), - ).into_response())), - Err(ValidationError::MissingField(field)) => Err(Box::new(( - StatusCode::BAD_REQUEST, - Json(json!({"error": "InvalidRecord", "message": format!("Missing required field: {}", field)})), - ).into_response())), - Err(ValidationError::InvalidField { path, message }) => Err(Box::new(( - StatusCode::BAD_REQUEST, - Json(json!({"error": "InvalidRecord", "message": format!("Invalid field '{}': {}", path, message)})), - ).into_response())), - Err(ValidationError::InvalidDatetime { path }) => Err(Box::new(( - StatusCode::BAD_REQUEST, - Json(json!({"error": "InvalidRecord", "message": format!("Invalid datetime format at '{}'", path)})), - ).into_response())), - Err(ValidationError::BannedContent { path }) => Err(Box::new(( - StatusCode::BAD_REQUEST, - Json(json!({"error": "InvalidRecord", "message": format!("Unacceptable slur in record at '{}'", path)})), - ).into_response())), - Err(e) => Err(Box::new(( - StatusCode::BAD_REQUEST, - Json(json!({"error": "InvalidRecord", "message": e.to_string()})), - ).into_response())), + Err(e) => Err(validation_error_to_box_response(e)), + } +} + +fn validation_error_to_box_response(e: ValidationError) -> Box { + match e { + ValidationError::MissingType => Box::new( + ( + StatusCode::BAD_REQUEST, + Json(json!({"error": "InvalidRecord", "message": "Record must have a $type field"})), + ) + .into_response(), + ), + ValidationError::TypeMismatch { expected, actual } => Box::new( + ( + StatusCode::BAD_REQUEST, + Json(json!({"error": "InvalidRecord", "message": format!("Record $type '{}' does not match collection '{}'", actual, expected)})), + ) + .into_response(), + ), + ValidationError::MissingField(field) => Box::new( + ( + StatusCode::BAD_REQUEST, + Json(json!({"error": "InvalidRecord", "message": format!("Missing required field: {}", field)})), + ) + .into_response(), + ), + ValidationError::InvalidField { path, message } => Box::new( + ( + StatusCode::BAD_REQUEST, + Json(json!({"error": "InvalidRecord", "message": format!("Invalid field '{}': {}", path, message)})), + ) + .into_response(), + ), + ValidationError::InvalidDatetime { path } => Box::new( + ( + StatusCode::BAD_REQUEST, + Json(json!({"error": "InvalidRecord", "message": format!("Invalid datetime format at '{}'", path)})), + ) + .into_response(), + ), + ValidationError::BannedContent { path } => Box::new( + ( + StatusCode::BAD_REQUEST, + Json(json!({"error": "InvalidRecord", "message": format!("Unacceptable slur in record at '{}'", path)})), + ) + .into_response(), + ), + ValidationError::UnknownType(type_name) => Box::new( + ( + StatusCode::BAD_REQUEST, + Json(json!({"error": "InvalidRecord", "message": format!("Lexicon not found: lex:{}", type_name)})), + ) + .into_response(), + ), + e => Box::new( + ( + StatusCode::BAD_REQUEST, + Json(json!({"error": "InvalidRecord", "message": e.to_string()})), + ) + .into_response(), + ), } } diff --git a/src/api/repo/record/write.rs b/src/api/repo/record/write.rs index 69140a7..783e063 100644 --- a/src/api/repo/record/write.rs +++ b/src/api/repo/record/write.rs @@ -1,4 +1,5 @@ -use super::validation::validate_record_with_rkey; +use super::validation::validate_record_with_status; +use crate::validation::ValidationStatus; use crate::api::repo::record::utils::{CommitParams, RecordOp, commit_and_log, extract_blob_cids}; use crate::delegation::{self, DelegationActionType}; use crate::repo::tracking::TrackingBlockStore; @@ -183,11 +184,21 @@ pub struct CreateRecordInput { #[serde(rename = "swapCommit")] pub swap_commit: Option, } +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CommitInfo { + pub cid: String, + pub rev: String, +} + #[derive(Serialize)] #[serde(rename_all = "camelCase")] pub struct CreateRecordOutput { pub uri: String, pub cid: String, + pub commit: CommitInfo, + #[serde(skip_serializing_if = "Option::is_none")] + pub validation_status: Option, } pub async fn create_record( State(state): State, @@ -256,12 +267,20 @@ pub async fn create_record( .into_response(); } }; - if input.validate.unwrap_or(true) - && let Err(err_response) = - validate_record_with_rkey(&input.record, &input.collection, input.rkey.as_deref()) - { - return *err_response; - } + let validation_status = if input.validate == Some(false) { + None + } else { + let require_lexicon = input.validate == Some(true); + match validate_record_with_status( + &input.record, + &input.collection, + input.rkey.as_deref(), + require_lexicon, + ) { + Ok(status) => Some(status), + Err(err_response) => return *err_response, + } + }; let rkey = input .rkey .unwrap_or_else(|| Tid::now(LimitedU32::MIN).to_string()); @@ -336,7 +355,7 @@ pub async fn create_record( .map(|c| c.to_string()) .collect::>(); let blob_cids = extract_blob_cids(&input.record); - if let Err(e) = commit_and_log( + let commit_result = match commit_and_log( &state, CommitParams { did: &did, @@ -351,11 +370,14 @@ pub async fn create_record( ) .await { - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError", "message": e})), - ) - .into_response(); + Ok(res) => res, + Err(e) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "InternalError", "message": e})), + ) + .into_response(); + } }; if let Some(ref controller) = controller_did { @@ -381,6 +403,15 @@ pub async fn create_record( Json(CreateRecordOutput { uri: format!("at://{}/{}/{}", did, input.collection, rkey), cid: record_cid.to_string(), + commit: CommitInfo { + cid: commit_result.commit_cid.to_string(), + rev: commit_result.rev, + }, + validation_status: validation_status.map(|s| match s { + ValidationStatus::Valid => "valid".to_string(), + ValidationStatus::Unknown => "unknown".to_string(), + ValidationStatus::Invalid => "invalid".to_string(), + }), }), ) .into_response() @@ -403,6 +434,10 @@ pub struct PutRecordInput { pub struct PutRecordOutput { pub uri: String, pub cid: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub commit: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub validation_status: Option, } pub async fn put_record( State(state): State, @@ -480,12 +515,20 @@ pub async fn put_record( } }; let key = format!("{}/{}", collection_nsid, input.rkey); - if input.validate.unwrap_or(true) - && let Err(err_response) = - validate_record_with_rkey(&input.record, &input.collection, Some(&input.rkey)) - { - return *err_response; - } + let validation_status = if input.validate == Some(false) { + None + } else { + let require_lexicon = input.validate == Some(true); + match validate_record_with_status( + &input.record, + &input.collection, + Some(&input.rkey), + require_lexicon, + ) { + Ok(status) => Some(status), + Err(err_response) => return *err_response, + } + }; if let Some(swap_record_str) = &input.swap_record { let expected_cid = Cid::from_str(swap_record_str).ok(); let actual_cid = mst.get(&key).await.ok().flatten(); @@ -512,6 +555,22 @@ pub async fn put_record( .into_response(); } }; + if existing_cid == Some(record_cid) { + return ( + StatusCode::OK, + Json(PutRecordOutput { + uri: format!("at://{}/{}/{}", did, input.collection, input.rkey), + cid: record_cid.to_string(), + commit: None, + validation_status: validation_status.map(|s| match s { + ValidationStatus::Valid => "valid".to_string(), + ValidationStatus::Unknown => "unknown".to_string(), + ValidationStatus::Invalid => "invalid".to_string(), + }), + }), + ) + .into_response(); + } let new_mst = if existing_cid.is_some() { match mst.update(&key, record_cid).await { Ok(m) => m, @@ -587,7 +646,7 @@ pub async fn put_record( .collect::>(); let is_update = existing_cid.is_some(); let blob_cids = extract_blob_cids(&input.record); - if let Err(e) = commit_and_log( + let commit_result = match commit_and_log( &state, CommitParams { did: &did, @@ -602,11 +661,14 @@ pub async fn put_record( ) .await { - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError", "message": e})), - ) - .into_response(); + Ok(res) => res, + Err(e) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "InternalError", "message": e})), + ) + .into_response(); + } }; if let Some(ref controller) = controller_did { @@ -632,6 +694,15 @@ pub async fn put_record( Json(PutRecordOutput { uri: format!("at://{}/{}/{}", did, input.collection, input.rkey), cid: record_cid.to_string(), + commit: Some(CommitInfo { + cid: commit_result.commit_cid.to_string(), + rev: commit_result.rev, + }), + validation_status: validation_status.map(|s| match s { + ValidationStatus::Valid => "valid".to_string(), + ValidationStatus::Unknown => "unknown".to_string(), + ValidationStatus::Invalid => "invalid".to_string(), + }), }), ) .into_response() diff --git a/tests/repo_conformance.rs b/tests/repo_conformance.rs new file mode 100644 index 0000000..dcac9ad --- /dev/null +++ b/tests/repo_conformance.rs @@ -0,0 +1,484 @@ +mod common; +mod helpers; +use chrono::Utc; +use common::*; +use helpers::*; +use reqwest::StatusCode; +use serde_json::{Value, json}; + +#[tokio::test] +async fn test_create_record_response_schema() { + let client = client(); + let (did, jwt) = setup_new_user("conform-create").await; + let now = Utc::now().to_rfc3339(); + + let payload = json!({ + "repo": did, + "collection": "app.bsky.feed.post", + "record": { + "$type": "app.bsky.feed.post", + "text": "Testing conformance", + "createdAt": now + } + }); + + let res = client + .post(format!("{}/xrpc/com.atproto.repo.createRecord", base_url().await)) + .bearer_auth(&jwt) + .json(&payload) + .send() + .await + .expect("Failed to create record"); + + assert_eq!(res.status(), StatusCode::OK); + let body: Value = res.json().await.unwrap(); + + assert!(body["uri"].is_string(), "response must have uri"); + assert!(body["cid"].is_string(), "response must have cid"); + assert!(body["cid"].as_str().unwrap().starts_with("bafy"), "cid must be valid"); + + assert!(body["commit"].is_object(), "response must have commit object"); + let commit = &body["commit"]; + assert!(commit["cid"].is_string(), "commit must have cid"); + assert!(commit["cid"].as_str().unwrap().starts_with("bafy"), "commit.cid must be valid"); + assert!(commit["rev"].is_string(), "commit must have rev"); + + assert!(body["validationStatus"].is_string(), "response must have validationStatus when validate defaults to true"); + assert_eq!(body["validationStatus"], "valid", "validationStatus should be 'valid'"); +} + +#[tokio::test] +async fn test_create_record_no_validation_status_when_validate_false() { + let client = client(); + let (did, jwt) = setup_new_user("conform-create-noval").await; + let now = Utc::now().to_rfc3339(); + + let payload = json!({ + "repo": did, + "collection": "app.bsky.feed.post", + "validate": false, + "record": { + "$type": "app.bsky.feed.post", + "text": "Testing without validation", + "createdAt": now + } + }); + + let res = client + .post(format!("{}/xrpc/com.atproto.repo.createRecord", base_url().await)) + .bearer_auth(&jwt) + .json(&payload) + .send() + .await + .expect("Failed to create record"); + + assert_eq!(res.status(), StatusCode::OK); + let body: Value = res.json().await.unwrap(); + + assert!(body["uri"].is_string()); + assert!(body["commit"].is_object()); + assert!(body["validationStatus"].is_null(), "validationStatus should be omitted when validate=false"); +} + +#[tokio::test] +async fn test_put_record_response_schema() { + let client = client(); + let (did, jwt) = setup_new_user("conform-put").await; + let now = Utc::now().to_rfc3339(); + + let payload = json!({ + "repo": did, + "collection": "app.bsky.feed.post", + "rkey": "conformance-put", + "record": { + "$type": "app.bsky.feed.post", + "text": "Testing putRecord conformance", + "createdAt": now + } + }); + + let res = client + .post(format!("{}/xrpc/com.atproto.repo.putRecord", base_url().await)) + .bearer_auth(&jwt) + .json(&payload) + .send() + .await + .expect("Failed to put record"); + + assert_eq!(res.status(), StatusCode::OK); + let body: Value = res.json().await.unwrap(); + + assert!(body["uri"].is_string(), "response must have uri"); + assert!(body["cid"].is_string(), "response must have cid"); + + assert!(body["commit"].is_object(), "response must have commit object"); + let commit = &body["commit"]; + assert!(commit["cid"].is_string(), "commit must have cid"); + assert!(commit["rev"].is_string(), "commit must have rev"); + + assert_eq!(body["validationStatus"], "valid", "validationStatus should be 'valid'"); +} + +#[tokio::test] +async fn test_delete_record_response_schema() { + let client = client(); + let (did, jwt) = setup_new_user("conform-delete").await; + let now = Utc::now().to_rfc3339(); + + let create_payload = json!({ + "repo": did, + "collection": "app.bsky.feed.post", + "rkey": "to-delete", + "record": { + "$type": "app.bsky.feed.post", + "text": "This will be deleted", + "createdAt": now + } + }); + let create_res = client + .post(format!("{}/xrpc/com.atproto.repo.putRecord", base_url().await)) + .bearer_auth(&jwt) + .json(&create_payload) + .send() + .await + .expect("Failed to create record"); + assert_eq!(create_res.status(), StatusCode::OK); + + let delete_payload = json!({ + "repo": did, + "collection": "app.bsky.feed.post", + "rkey": "to-delete" + }); + let delete_res = client + .post(format!("{}/xrpc/com.atproto.repo.deleteRecord", base_url().await)) + .bearer_auth(&jwt) + .json(&delete_payload) + .send() + .await + .expect("Failed to delete record"); + + assert_eq!(delete_res.status(), StatusCode::OK); + let body: Value = delete_res.json().await.unwrap(); + + assert!(body["commit"].is_object(), "response must have commit object when record was deleted"); + let commit = &body["commit"]; + assert!(commit["cid"].is_string(), "commit must have cid"); + assert!(commit["rev"].is_string(), "commit must have rev"); +} + +#[tokio::test] +async fn test_delete_record_noop_response() { + let client = client(); + let (did, jwt) = setup_new_user("conform-delete-noop").await; + + let delete_payload = json!({ + "repo": did, + "collection": "app.bsky.feed.post", + "rkey": "nonexistent-record" + }); + let delete_res = client + .post(format!("{}/xrpc/com.atproto.repo.deleteRecord", base_url().await)) + .bearer_auth(&jwt) + .json(&delete_payload) + .send() + .await + .expect("Failed to delete record"); + + assert_eq!(delete_res.status(), StatusCode::OK); + let body: Value = delete_res.json().await.unwrap(); + + assert!(body["commit"].is_null(), "commit should be omitted on no-op delete"); +} + +#[tokio::test] +async fn test_apply_writes_response_schema() { + let client = client(); + let (did, jwt) = setup_new_user("conform-apply").await; + let now = Utc::now().to_rfc3339(); + + let payload = json!({ + "repo": did, + "writes": [ + { + "$type": "com.atproto.repo.applyWrites#create", + "collection": "app.bsky.feed.post", + "rkey": "apply-test-1", + "value": { + "$type": "app.bsky.feed.post", + "text": "First post", + "createdAt": now + } + }, + { + "$type": "com.atproto.repo.applyWrites#create", + "collection": "app.bsky.feed.post", + "rkey": "apply-test-2", + "value": { + "$type": "app.bsky.feed.post", + "text": "Second post", + "createdAt": now + } + } + ] + }); + + let res = client + .post(format!("{}/xrpc/com.atproto.repo.applyWrites", base_url().await)) + .bearer_auth(&jwt) + .json(&payload) + .send() + .await + .expect("Failed to apply writes"); + + assert_eq!(res.status(), StatusCode::OK); + let body: Value = res.json().await.unwrap(); + + assert!(body["commit"].is_object(), "response must have commit object"); + let commit = &body["commit"]; + assert!(commit["cid"].is_string(), "commit must have cid"); + assert!(commit["rev"].is_string(), "commit must have rev"); + + assert!(body["results"].is_array(), "response must have results array"); + let results = body["results"].as_array().unwrap(); + assert_eq!(results.len(), 2, "should have 2 results"); + + for result in results { + assert!(result["uri"].is_string(), "result must have uri"); + assert!(result["cid"].is_string(), "result must have cid"); + assert_eq!(result["validationStatus"], "valid", "result must have validationStatus"); + assert_eq!(result["$type"], "com.atproto.repo.applyWrites#createResult"); + } +} + +#[tokio::test] +async fn test_apply_writes_update_and_delete_results() { + let client = client(); + let (did, jwt) = setup_new_user("conform-apply-upd").await; + let now = Utc::now().to_rfc3339(); + + let create_payload = json!({ + "repo": did, + "collection": "app.bsky.feed.post", + "rkey": "to-update", + "record": { + "$type": "app.bsky.feed.post", + "text": "Original", + "createdAt": now + } + }); + client + .post(format!("{}/xrpc/com.atproto.repo.putRecord", base_url().await)) + .bearer_auth(&jwt) + .json(&create_payload) + .send() + .await + .expect("setup failed"); + + let payload = json!({ + "repo": did, + "writes": [ + { + "$type": "com.atproto.repo.applyWrites#update", + "collection": "app.bsky.feed.post", + "rkey": "to-update", + "value": { + "$type": "app.bsky.feed.post", + "text": "Updated", + "createdAt": now + } + }, + { + "$type": "com.atproto.repo.applyWrites#delete", + "collection": "app.bsky.feed.post", + "rkey": "to-update" + } + ] + }); + + let res = client + .post(format!("{}/xrpc/com.atproto.repo.applyWrites", base_url().await)) + .bearer_auth(&jwt) + .json(&payload) + .send() + .await + .expect("Failed to apply writes"); + + assert_eq!(res.status(), StatusCode::OK); + let body: Value = res.json().await.unwrap(); + + let results = body["results"].as_array().unwrap(); + assert_eq!(results.len(), 2); + + let update_result = &results[0]; + assert_eq!(update_result["$type"], "com.atproto.repo.applyWrites#updateResult"); + assert!(update_result["uri"].is_string()); + assert!(update_result["cid"].is_string()); + assert_eq!(update_result["validationStatus"], "valid"); + + let delete_result = &results[1]; + assert_eq!(delete_result["$type"], "com.atproto.repo.applyWrites#deleteResult"); + assert!(delete_result["uri"].is_null(), "delete result should not have uri"); + assert!(delete_result["cid"].is_null(), "delete result should not have cid"); + assert!(delete_result["validationStatus"].is_null(), "delete result should not have validationStatus"); +} + +#[tokio::test] +async fn test_get_record_error_code() { + let client = client(); + let (did, _jwt) = setup_new_user("conform-get-err").await; + + let res = client + .get(format!("{}/xrpc/com.atproto.repo.getRecord", base_url().await)) + .query(&[ + ("repo", did.as_str()), + ("collection", "app.bsky.feed.post"), + ("rkey", "nonexistent"), + ]) + .send() + .await + .expect("Failed to get record"); + + assert_eq!(res.status(), StatusCode::NOT_FOUND); + let body: Value = res.json().await.unwrap(); + assert_eq!(body["error"], "RecordNotFound", "error code should be RecordNotFound per atproto spec"); +} + +#[tokio::test] +async fn test_create_record_unknown_lexicon_default_validation() { + let client = client(); + let (did, jwt) = setup_new_user("conform-unknown-lex").await; + + let payload = json!({ + "repo": did, + "collection": "com.example.custom", + "record": { + "$type": "com.example.custom", + "data": "some custom data" + } + }); + + let res = client + .post(format!("{}/xrpc/com.atproto.repo.createRecord", base_url().await)) + .bearer_auth(&jwt) + .json(&payload) + .send() + .await + .expect("Failed to create record"); + + assert_eq!(res.status(), StatusCode::OK, "unknown lexicon should be allowed with default validation"); + let body: Value = res.json().await.unwrap(); + + assert!(body["uri"].is_string()); + assert!(body["cid"].is_string()); + assert!(body["commit"].is_object()); + assert_eq!(body["validationStatus"], "unknown", "validationStatus should be 'unknown' for unknown lexicons"); +} + +#[tokio::test] +async fn test_create_record_unknown_lexicon_strict_validation() { + let client = client(); + let (did, jwt) = setup_new_user("conform-unknown-strict").await; + + let payload = json!({ + "repo": did, + "collection": "com.example.custom", + "validate": true, + "record": { + "$type": "com.example.custom", + "data": "some custom data" + } + }); + + let res = client + .post(format!("{}/xrpc/com.atproto.repo.createRecord", base_url().await)) + .bearer_auth(&jwt) + .json(&payload) + .send() + .await + .expect("Failed to send request"); + + assert_eq!(res.status(), StatusCode::BAD_REQUEST, "unknown lexicon should fail with validate=true"); + let body: Value = res.json().await.unwrap(); + assert_eq!(body["error"], "InvalidRecord"); + assert!(body["message"].as_str().unwrap().contains("Lexicon not found"), "error should mention lexicon not found"); +} + +#[tokio::test] +async fn test_put_record_noop_same_content() { + let client = client(); + let (did, jwt) = setup_new_user("conform-put-noop").await; + let now = Utc::now().to_rfc3339(); + + let record = json!({ + "$type": "app.bsky.feed.post", + "text": "This content will not change", + "createdAt": now + }); + + let payload = json!({ + "repo": did, + "collection": "app.bsky.feed.post", + "rkey": "noop-test", + "record": record.clone() + }); + + let first_res = client + .post(format!("{}/xrpc/com.atproto.repo.putRecord", base_url().await)) + .bearer_auth(&jwt) + .json(&payload) + .send() + .await + .expect("Failed to put record"); + assert_eq!(first_res.status(), StatusCode::OK); + let first_body: Value = first_res.json().await.unwrap(); + assert!(first_body["commit"].is_object(), "first put should have commit"); + + let second_res = client + .post(format!("{}/xrpc/com.atproto.repo.putRecord", base_url().await)) + .bearer_auth(&jwt) + .json(&payload) + .send() + .await + .expect("Failed to put record"); + assert_eq!(second_res.status(), StatusCode::OK); + let second_body: Value = second_res.json().await.unwrap(); + + assert!(second_body["commit"].is_null(), "second put with same content should have no commit (no-op)"); + assert_eq!(first_body["cid"], second_body["cid"], "CID should be the same for identical content"); +} + +#[tokio::test] +async fn test_apply_writes_unknown_lexicon() { + let client = client(); + let (did, jwt) = setup_new_user("conform-apply-unknown").await; + + let payload = json!({ + "repo": did, + "writes": [ + { + "$type": "com.atproto.repo.applyWrites#create", + "collection": "com.example.custom", + "rkey": "custom-1", + "value": { + "$type": "com.example.custom", + "data": "custom data" + } + } + ] + }); + + let res = client + .post(format!("{}/xrpc/com.atproto.repo.applyWrites", base_url().await)) + .bearer_auth(&jwt) + .json(&payload) + .send() + .await + .expect("Failed to apply writes"); + + assert_eq!(res.status(), StatusCode::OK); + let body: Value = res.json().await.unwrap(); + + let results = body["results"].as_array().unwrap(); + assert_eq!(results.len(), 1); + assert_eq!(results[0]["validationStatus"], "unknown", "unknown lexicon should have 'unknown' status"); +}