Repo conf. vs ref

This commit is contained in:
lewis
2025-12-29 22:29:23 +02:00
parent 98b4a33447
commit 1c35e1ce37
6 changed files with 749 additions and 81 deletions
+52 -15
View File
@@ -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<String>,
},
#[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<String>,
},
#[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(),
+33 -10
View File
@@ -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<String>,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct DeleteRecordOutput {
#[serde(skip_serializing_if = "Option::is_none")]
pub commit: Option<CommitInfo>,
}
pub async fn delete_record(
State(state): State<AppState>,
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::<Vec<_>>();
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()
}
+2 -2
View File
@@ -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();
}
+82 -29
View File
@@ -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<Response>> {
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<ValidationStatus, Box<Response>> {
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<ValidationStatus, ValidationError>,
) -> Result<(), Box<Response>> {
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<Response> {
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(),
),
}
}
+96 -25
View File
@@ -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<String>,
}
#[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<String>,
}
pub async fn create_record(
State(state): State<AppState>,
@@ -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::<Vec<_>>();
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<CommitInfo>,
#[serde(skip_serializing_if = "Option::is_none")]
pub validation_status: Option<String>,
}
pub async fn put_record(
State(state): State<AppState>,
@@ -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::<Vec<_>>();
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()
+484
View File
@@ -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");
}