test(lexicon): test schemas and resolution integration tests

This commit is contained in:
Lewis
2026-03-13 19:53:18 +00:00
committed by Tangled
parent dab347cc83
commit 83fda8b57a
2 changed files with 661 additions and 0 deletions
+291
View File
@@ -0,0 +1,291 @@
use crate::registry::LexiconRegistry;
use crate::schema::LexiconDoc;
pub(crate) fn test_registry() -> LexiconRegistry {
let mut registry = LexiconRegistry::new();
all().into_iter().for_each(|doc| registry.register(doc));
registry
}
fn parse(json: serde_json::Value) -> LexiconDoc {
serde_json::from_value(json).expect("invalid test schema JSON")
}
fn all() -> Vec<LexiconDoc> {
[
basic_schema(),
profile_schema(),
with_ref_schema(),
strong_ref_schema(),
with_reply_schema(),
images_schema(),
external_schema(),
with_gate_schema(),
with_did_schema(),
nullable_schema(),
required_nullable_schema(),
]
.into()
}
fn basic_schema() -> LexiconDoc {
parse(serde_json::json!({
"lexicon": 1,
"id": "com.test.basic",
"defs": {
"main": {
"type": "record",
"record": {
"type": "object",
"required": ["text", "createdAt"],
"properties": {
"text": {"type": "string", "maxLength": 100, "maxGraphemes": 50},
"createdAt": {"type": "string", "format": "datetime"},
"count": {"type": "integer", "minimum": 0, "maximum": 100},
"active": {"type": "boolean"},
"tags": {
"type": "array", "maxLength": 3,
"items": {"type": "string", "maxLength": 50}
},
"langs": {
"type": "array", "maxLength": 2,
"items": {"type": "string", "format": "language"}
}
}
}
}
}
}))
}
fn profile_schema() -> LexiconDoc {
parse(serde_json::json!({
"lexicon": 1,
"id": "com.test.profile",
"defs": {
"main": {
"type": "record",
"record": {
"type": "object",
"properties": {
"displayName": {"type": "string", "maxGraphemes": 10, "maxLength": 100},
"description": {"type": "string", "maxGraphemes": 50, "maxLength": 500},
"avatar": {"type": "blob", "accept": ["image/png", "image/jpeg"], "maxSize": 1000000}
}
}
}
}
}))
}
fn with_ref_schema() -> LexiconDoc {
parse(serde_json::json!({
"lexicon": 1,
"id": "com.test.withref",
"defs": {
"main": {
"type": "record",
"record": {
"type": "object",
"required": ["subject", "createdAt"],
"properties": {
"subject": {"type": "ref", "ref": "com.test.strongref"},
"createdAt": {"type": "string", "format": "datetime"}
}
}
}
}
}))
}
fn strong_ref_schema() -> LexiconDoc {
parse(serde_json::json!({
"lexicon": 1,
"id": "com.test.strongref",
"defs": {
"main": {
"type": "object",
"required": ["uri", "cid"],
"properties": {
"uri": {"type": "string", "format": "at-uri"},
"cid": {"type": "string", "format": "cid"}
}
}
}
}))
}
fn with_reply_schema() -> LexiconDoc {
parse(serde_json::json!({
"lexicon": 1,
"id": "com.test.withreply",
"defs": {
"main": {
"type": "record",
"record": {
"type": "object",
"required": ["text", "createdAt"],
"properties": {
"text": {"type": "string"},
"createdAt": {"type": "string", "format": "datetime"},
"reply": {"type": "ref", "ref": "#replyRef"},
"embed": {
"type": "union",
"refs": ["com.test.images", "com.test.external"]
}
}
}
},
"replyRef": {
"type": "object",
"required": ["root", "parent"],
"properties": {
"root": {"type": "ref", "ref": "com.test.strongref"},
"parent": {"type": "ref", "ref": "com.test.strongref"}
}
}
}
}))
}
fn images_schema() -> LexiconDoc {
parse(serde_json::json!({
"lexicon": 1,
"id": "com.test.images",
"defs": {
"main": {
"type": "object",
"required": ["images"],
"properties": {
"images": {
"type": "array", "maxLength": 4,
"items": {"type": "ref", "ref": "#image"}
}
}
},
"image": {
"type": "object",
"required": ["image", "alt"],
"properties": {
"image": {"type": "blob", "accept": ["image/*"], "maxSize": 1000000},
"alt": {"type": "string"}
}
}
}
}))
}
fn external_schema() -> LexiconDoc {
parse(serde_json::json!({
"lexicon": 1,
"id": "com.test.external",
"defs": {
"main": {
"type": "object",
"required": ["external"],
"properties": {
"external": {"type": "ref", "ref": "#external"}
}
},
"external": {
"type": "object",
"required": ["uri", "title", "description"],
"properties": {
"uri": {"type": "string", "format": "uri"},
"title": {"type": "string"},
"description": {"type": "string"}
}
}
}
}))
}
fn with_gate_schema() -> LexiconDoc {
parse(serde_json::json!({
"lexicon": 1,
"id": "com.test.withgate",
"defs": {
"main": {
"type": "record",
"record": {
"type": "object",
"required": ["post", "createdAt"],
"properties": {
"post": {"type": "string", "format": "at-uri"},
"createdAt": {"type": "string", "format": "datetime"},
"rules": {
"type": "array", "maxLength": 5,
"items": {"type": "union", "refs": ["#disableRule"]}
}
}
}
},
"disableRule": {
"type": "object",
"properties": {}
}
}
}))
}
fn with_did_schema() -> LexiconDoc {
parse(serde_json::json!({
"lexicon": 1,
"id": "com.test.withdid",
"defs": {
"main": {
"type": "record",
"record": {
"type": "object",
"required": ["subject", "createdAt"],
"properties": {
"subject": {"type": "string", "format": "did"},
"createdAt": {"type": "string", "format": "datetime"}
}
}
}
}
}))
}
fn nullable_schema() -> LexiconDoc {
parse(serde_json::json!({
"lexicon": 1,
"id": "com.test.nullable",
"defs": {
"main": {
"type": "record",
"record": {
"type": "object",
"required": ["name"],
"nullable": ["value"],
"properties": {
"name": {"type": "string"},
"value": {"type": "string"}
}
}
}
}
}))
}
fn required_nullable_schema() -> LexiconDoc {
parse(serde_json::json!({
"lexicon": 1,
"id": "com.test.requirednullable",
"defs": {
"main": {
"type": "record",
"record": {
"type": "object",
"required": ["name", "value"],
"nullable": ["value"],
"properties": {
"name": {"type": "string"},
"value": {"type": "string"}
}
}
}
}
}))
}
@@ -0,0 +1,370 @@
#![cfg(feature = "resolve")]
use std::time::Duration;
use serde_json::json;
use tranquil_lexicon::{
ResolveError, fetch_schema_from_pds, resolve_lexicon_from_did, resolve_pds_endpoint,
};
use wiremock::matchers::{method, path, query_param};
use wiremock::{Mock, MockServer, ResponseTemplate};
fn mock_did_document(did: &str, pds_endpoint: &str) -> serde_json::Value {
json!({
"@context": ["https://www.w3.org/ns/did/v1"],
"id": did,
"service": [{
"id": "#atproto_pds",
"type": "AtprotoPersonalDataServer",
"serviceEndpoint": pds_endpoint
}]
})
}
fn mock_lexicon_schema(nsid: &str) -> serde_json::Value {
json!({
"lexicon": 1,
"id": nsid,
"defs": {
"main": {
"type": "record",
"key": "tid",
"record": {
"type": "object",
"required": ["text", "createdAt"],
"properties": {
"text": {
"type": "string",
"maxLength": 1000,
"maxGraphemes": 100
},
"createdAt": {
"type": "string",
"format": "datetime"
}
}
}
}
}
})
}
fn mock_get_record_response(nsid: &str) -> serde_json::Value {
json!({
"uri": format!("at://did:plc:test123/com.atproto.lexicon.schema/{}", nsid),
"cid": "bafyreiabcdef",
"value": mock_lexicon_schema(nsid)
})
}
#[tokio::test]
async fn test_resolve_pds_endpoint_from_plc() {
let plc_server = MockServer::start().await;
let did = "did:plc:testabcdef123";
Mock::given(method("GET"))
.and(path(format!("/{}", did)))
.respond_with(
ResponseTemplate::new(200)
.set_body_json(mock_did_document(did, "https://pds.example.com")),
)
.mount(&plc_server)
.await;
let endpoint = resolve_pds_endpoint(did, Some(&plc_server.uri()))
.await
.unwrap();
assert_eq!(endpoint, "https://pds.example.com");
}
#[tokio::test]
async fn test_resolve_pds_endpoint_no_pds_service() {
let plc_server = MockServer::start().await;
let did = "did:plc:nopds123";
Mock::given(method("GET"))
.and(path(format!("/{}", did)))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"id": did,
"service": [{
"type": "AtprotoLabeler",
"serviceEndpoint": "https://labeler.example.com"
}]
})))
.mount(&plc_server)
.await;
let result = resolve_pds_endpoint(did, Some(&plc_server.uri())).await;
assert!(matches!(result, Err(ResolveError::NoPdsEndpoint { .. })));
}
#[tokio::test]
async fn test_resolve_pds_endpoint_plc_not_found() {
let plc_server = MockServer::start().await;
let did = "did:plc:missing123";
Mock::given(method("GET"))
.and(path(format!("/{}", did)))
.respond_with(ResponseTemplate::new(404).set_body_string("not found"))
.mount(&plc_server)
.await;
let result = resolve_pds_endpoint(did, Some(&plc_server.uri())).await;
assert!(result.is_err());
}
#[tokio::test]
async fn test_resolve_pds_endpoint_unsupported_did_method() {
let result = resolve_pds_endpoint("did:key:z6MkTest", None).await;
assert!(matches!(result, Err(ResolveError::DidResolution { .. })));
}
#[tokio::test]
async fn test_resolve_pds_endpoint_multiple_services_picks_pds() {
let plc_server = MockServer::start().await;
let did = "did:plc:multi123";
Mock::given(method("GET"))
.and(path(format!("/{}", did)))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"id": did,
"service": [
{
"type": "AtprotoLabeler",
"serviceEndpoint": "https://labeler.example.com"
},
{
"type": "BskyNotificationService",
"serviceEndpoint": "https://notify.example.com"
},
{
"type": "AtprotoPersonalDataServer",
"serviceEndpoint": "https://pds.example.com"
}
]
})))
.mount(&plc_server)
.await;
let endpoint = resolve_pds_endpoint(did, Some(&plc_server.uri()))
.await
.unwrap();
assert_eq!(endpoint, "https://pds.example.com");
}
#[tokio::test]
async fn test_fetch_schema_from_pds_success() {
let pds_server = MockServer::start().await;
let did = "did:plc:schemahost123";
let nsid = "com.example.custom.post";
Mock::given(method("GET"))
.and(path("/xrpc/com.atproto.repo.getRecord"))
.and(query_param("repo", did))
.and(query_param("collection", "com.atproto.lexicon.schema"))
.and(query_param("rkey", nsid))
.respond_with(ResponseTemplate::new(200).set_body_json(mock_get_record_response(nsid)))
.mount(&pds_server)
.await;
let doc = fetch_schema_from_pds(&pds_server.uri(), did, nsid)
.await
.unwrap();
assert_eq!(doc.id, nsid);
assert_eq!(doc.lexicon, 1);
assert!(doc.defs.contains_key("main"));
}
#[tokio::test]
async fn test_fetch_schema_missing_value_field() {
let pds_server = MockServer::start().await;
let did = "did:plc:test123";
let nsid = "com.example.missing";
Mock::given(method("GET"))
.and(path("/xrpc/com.atproto.repo.getRecord"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"uri": "at://did:plc:test123/com.atproto.lexicon.schema/com.example.missing",
"cid": "bafyreiabcdef"
})))
.mount(&pds_server)
.await;
let result = fetch_schema_from_pds(&pds_server.uri(), did, nsid).await;
assert!(matches!(result, Err(ResolveError::SchemaFetch { .. })));
}
#[tokio::test]
async fn test_fetch_schema_invalid_lexicon_json() {
let pds_server = MockServer::start().await;
let did = "did:plc:test123";
let nsid = "com.example.bad";
Mock::given(method("GET"))
.and(path("/xrpc/com.atproto.repo.getRecord"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"uri": "at://test",
"cid": "bafyreiabcdef",
"value": {
"not_a_lexicon": true
}
})))
.mount(&pds_server)
.await;
let result = fetch_schema_from_pds(&pds_server.uri(), did, nsid).await;
assert!(matches!(result, Err(ResolveError::InvalidSchema(_))));
}
#[tokio::test]
async fn test_full_chain_plc_to_schema() {
let plc_server = MockServer::start().await;
let pds_server = MockServer::start().await;
let did = "did:plc:fullchain123";
let nsid = "com.example.social.post";
Mock::given(method("GET"))
.and(path(format!("/{}", did)))
.respond_with(
ResponseTemplate::new(200).set_body_json(mock_did_document(did, &pds_server.uri())),
)
.mount(&plc_server)
.await;
Mock::given(method("GET"))
.and(path("/xrpc/com.atproto.repo.getRecord"))
.and(query_param("repo", did))
.and(query_param("collection", "com.atproto.lexicon.schema"))
.and(query_param("rkey", nsid))
.respond_with(ResponseTemplate::new(200).set_body_json(mock_get_record_response(nsid)))
.mount(&pds_server)
.await;
let doc = resolve_lexicon_from_did(nsid, did, Some(&plc_server.uri()))
.await
.unwrap();
assert_eq!(doc.id, nsid);
assert_eq!(doc.lexicon, 1);
}
#[tokio::test]
async fn test_full_chain_schema_id_mismatch_rejected() {
let plc_server = MockServer::start().await;
let pds_server = MockServer::start().await;
let did = "did:plc:mismatch123";
let nsid = "com.example.requested.type";
Mock::given(method("GET"))
.and(path(format!("/{}", did)))
.respond_with(
ResponseTemplate::new(200).set_body_json(mock_did_document(did, &pds_server.uri())),
)
.mount(&plc_server)
.await;
Mock::given(method("GET"))
.and(path("/xrpc/com.atproto.repo.getRecord"))
.respond_with(
ResponseTemplate::new(200)
.set_body_json(mock_get_record_response("com.example.different.type")),
)
.mount(&pds_server)
.await;
let result = resolve_lexicon_from_did(nsid, did, Some(&plc_server.uri())).await;
assert!(matches!(result, Err(ResolveError::InvalidSchema(_))));
}
#[tokio::test]
async fn test_full_chain_bad_lexicon_version_rejected() {
let plc_server = MockServer::start().await;
let pds_server = MockServer::start().await;
let did = "did:plc:badver123";
let nsid = "com.example.versioned.type";
Mock::given(method("GET"))
.and(path(format!("/{}", did)))
.respond_with(
ResponseTemplate::new(200).set_body_json(mock_did_document(did, &pds_server.uri())),
)
.mount(&plc_server)
.await;
Mock::given(method("GET"))
.and(path("/xrpc/com.atproto.repo.getRecord"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"uri": "at://test",
"cid": "bafyreiabcdef",
"value": {
"lexicon": 2,
"id": nsid,
"defs": {}
}
})))
.mount(&pds_server)
.await;
let result = resolve_lexicon_from_did(nsid, did, Some(&plc_server.uri())).await;
assert!(matches!(result, Err(ResolveError::InvalidSchema(_))));
}
#[tokio::test]
async fn test_pds_trailing_slash_handled() {
let pds_server = MockServer::start().await;
let did = "did:plc:slash123";
let nsid = "com.example.slash.test";
Mock::given(method("GET"))
.and(path("/xrpc/com.atproto.repo.getRecord"))
.and(query_param("repo", did))
.and(query_param("rkey", nsid))
.respond_with(ResponseTemplate::new(200).set_body_json(mock_get_record_response(nsid)))
.mount(&pds_server)
.await;
let pds_url_with_slash = format!("{}/", pds_server.uri());
let doc = fetch_schema_from_pds(&pds_url_with_slash, did, nsid)
.await
.unwrap();
assert_eq!(doc.id, nsid);
}
#[tokio::test]
async fn test_fetch_schema_error_status_gives_meaningful_error() {
let pds_server = MockServer::start().await;
let did = "did:plc:test123";
let nsid = "com.example.notfound";
Mock::given(method("GET"))
.and(path("/xrpc/com.atproto.repo.getRecord"))
.respond_with(ResponseTemplate::new(400).set_body_json(json!({
"error": "RecordNotFound",
"message": "record not found"
})))
.mount(&pds_server)
.await;
let result = fetch_schema_from_pds(&pds_server.uri(), did, nsid).await;
let err = result.unwrap_err();
let err_msg = err.to_string();
assert!(
!err_msg.contains("missing 'value' field"),
"a 400 response should report the HTTP status, not a parse error. got: {}",
err_msg
);
}
#[tokio::test]
async fn test_plc_server_timeout() {
let plc_server = MockServer::start().await;
let did = "did:plc:timeout123";
Mock::given(method("GET"))
.and(path(format!("/{}", did)))
.respond_with(ResponseTemplate::new(200).set_delay(Duration::from_secs(30)))
.mount(&plc_server)
.await;
let result = resolve_pds_endpoint(did, Some(&plc_server.uri())).await;
assert!(result.is_err());
}