use std::collections::HashMap;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use crate::lexicon::{
TypedBlob, com::atproto::repo::StrongRef, community::lexicon::attestation::Signatures,
};
use crate::typed::{LexiconType, TypedLexicon};
pub const DEFINITION_NSID: &str = "community.lexicon.badge.definition";
pub const AWARD_NSID: &str = "community.lexicon.badge.award";
#[derive(Serialize, Deserialize, Clone, PartialEq)]
#[cfg_attr(any(debug_assertions, test), derive(Debug))]
pub struct Definition {
pub name: String,
pub description: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub image: Option<TypedBlob>,
#[serde(flatten)]
pub extra: HashMap<String, serde_json::Value>,
}
impl LexiconType for Definition {
fn lexicon_type() -> &'static str {
DEFINITION_NSID
}
}
#[allow(dead_code)]
pub type TypedDefinition = TypedLexicon<Definition>;
#[derive(Serialize, Deserialize, Clone, PartialEq)]
#[cfg_attr(any(debug_assertions, test), derive(Debug))]
pub struct Award {
pub badge: StrongRef,
pub did: String,
pub issued: DateTime<Utc>,
#[serde(skip_serializing_if = "Vec::is_empty", default)]
pub signatures: Signatures,
#[serde(flatten)]
pub extra: HashMap<String, serde_json::Value>,
}
impl LexiconType for Award {
fn lexicon_type() -> &'static str {
AWARD_NSID
}
}
#[allow(dead_code)]
pub type TypedAward = TypedLexicon<Award>;
#[cfg(test)]
mod tests {
use crate::lexicon::com_atproto_repo::StrongRef;
use crate::lexicon::{Blob, Link};
use super::*;
use anyhow::Result;
#[test]
fn test_deserialize_badge_definition() -> Result<()> {
let json = r#"{
"name": "Bug Squasher",
"$type": "community.lexicon.badge.definition",
"image": {
"$type": "blob",
"ref": {
"$link": "bafkreigb3vxlgckp66o2bffnwapagshpt2xdcgdltuzjymapobvgunxmfi"
},
"mimeType": "image/png",
"size": 177111
},
"description": "You've helped squash Smoke Signal bugs."
}"#;
let typed_def: TypedDefinition = serde_json::from_str(json)?;
let definition = typed_def.inner;
assert_eq!(definition.name, "Bug Squasher");
assert_eq!(
definition.description,
"You've helped squash Smoke Signal bugs."
);
assert!(definition.image.is_some());
if let Some(typed_blob) = definition.image {
let img = typed_blob.inner;
assert_eq!(img.mime_type, "image/png");
assert_eq!(img.size, 177111);
assert_eq!(
img.ref_.link,
"bafkreigb3vxlgckp66o2bffnwapagshpt2xdcgdltuzjymapobvgunxmfi"
);
}
Ok(())
}
#[test]
fn test_deserialize_badge_definition_without_image() -> Result<()> {
let json = r#"{
"name": "Text Badge",
"$type": "community.lexicon.badge.definition",
"description": "A badge without an image."
}"#;
let typed_def: TypedDefinition = serde_json::from_str(json)?;
let definition = typed_def.inner;
assert_eq!(definition.name, "Text Badge");
assert_eq!(definition.description, "A badge without an image.");
assert!(definition.image.is_none());
Ok(())
}
#[test]
fn test_serialize_badge_definition() -> Result<()> {
let definition = Definition {
name: "Test Badge".to_string(),
description: "A test badge".to_string(),
image: Some(TypedLexicon::new(Blob {
ref_: Link {
link: "bafkreitest123".to_string(),
},
mime_type: "image/png".to_string(),
size: 12345,
})),
extra: HashMap::new(),
};
let typed_def = TypedLexicon::new(definition);
let json = serde_json::to_string_pretty(&typed_def)?;
assert!(json.contains("\"$type\": \"community.lexicon.badge.definition\""));
assert!(json.contains("\"name\": \"Test Badge\""));
assert!(json.contains("\"description\": \"A test badge\""));
assert!(json.contains("\"$link\": \"bafkreitest123\""));
assert!(json.contains("\"mimeType\": \"image/png\""));
assert!(json.contains("\"size\": 12345"));
Ok(())
}
#[test]
fn test_deserialize_badge_award() -> Result<()> {
let json = r#"{
"$type": "community.lexicon.badge.award",
"badge": {
"cid": "bafyreiansi5jdnsam57ouyzk7zf5vatvzo7narb5o5z3zm7e5lhd4iw5d4",
"uri": "at://did:plc:tgudj2fjm77pzkuawquqhsxm/community.lexicon.badge.definition/3lqt67gc2i32c"
},
"did": "did:plc:cbkjy5n7bk3ax2wplmtjofq2",
"issued": "2025-06-08T22:10:55.000Z",
"signatures": []
}"#;
let typed_award: TypedAward = serde_json::from_str(json)?;
let award = typed_award.inner;
assert_eq!(award.did, "did:plc:cbkjy5n7bk3ax2wplmtjofq2");
assert_eq!(award.issued.to_rfc3339(), "2025-06-08T22:10:55+00:00");
assert!(award.signatures.is_empty());
let badge_ref = &award.badge;
assert_eq!(
badge_ref.cid,
"bafyreiansi5jdnsam57ouyzk7zf5vatvzo7narb5o5z3zm7e5lhd4iw5d4"
);
assert_eq!(
badge_ref.uri,
"at://did:plc:tgudj2fjm77pzkuawquqhsxm/community.lexicon.badge.definition/3lqt67gc2i32c"
);
Ok(())
}
#[test]
fn test_serialize_badge_award() -> Result<()> {
use chrono::TimeZone;
let badge = StrongRef {
uri: "at://did:plc:test/community.lexicon.badge.definition/abc123".to_string(),
cid: "bafyreicidtest123".to_string(),
};
let award = Award {
badge,
did: "did:plc:recipient123".to_string(),
issued: Utc.with_ymd_and_hms(2025, 6, 8, 22, 10, 55).unwrap(),
signatures: vec![],
extra: HashMap::new(),
};
let typed_award = TypedLexicon::new(award);
let json = serde_json::to_string_pretty(&typed_award)?;
assert!(json.contains("\"$type\": \"community.lexicon.badge.award\""));
assert!(json.contains("\"did\": \"did:plc:recipient123\""));
assert!(json.contains("\"issued\": \"2025-06-08T22:10:55Z\""));
assert!(!json.contains("\"signatures\""));
Ok(())
}
#[test]
fn test_badge_award_with_signatures() -> Result<()> {
let json = r#"{
"$type": "community.lexicon.badge.award",
"badge": {
"$type": "com.atproto.repo.strongRef",
"cid": "bafyreicid123",
"uri": "at://did:plc:issuer/community.lexicon.badge.definition/badge123"
},
"did": "did:plc:recipient",
"issued": "2025-06-08T12:00:00.000Z",
"signatures": [
{
"$type": "community.lexicon.attestation.signature",
"issuer": "did:plc:issuer",
"signature": {"$bytes": "dGVzdCBzaWduYXR1cmU="}
}
]
}"#;
let typed_award: TypedAward = serde_json::from_str(json)?;
let award = typed_award.inner;
assert_eq!(award.did, "did:plc:recipient");
assert_eq!(award.signatures.len(), 1);
match award.signatures.first() {
Some(sig_or_ref) => {
match sig_or_ref {
crate::lexicon::community_lexicon_attestation::SignatureOrRef::Inline(sig) => {
assert_eq!(sig.inner.signature.bytes, b"test signature".to_vec());
}
_ => panic!("Expected inline signature"),
}
}
None => panic!("Expected signature data"),
}
Ok(())
}
#[test]
fn test_typed_patterns() -> Result<()> {
let badge = StrongRef {
uri: "at://example".to_string(),
cid: "bafytest".to_string(),
};
let definition = Definition {
name: "Test".to_string(),
description: "Test desc".to_string(),
image: None,
extra: HashMap::new(),
};
let typed_def = TypedLexicon::new(definition);
let json = serde_json::to_value(&typed_def)?;
assert_eq!(json["$type"], "community.lexicon.badge.definition");
let award = Award {
badge,
did: "did:plc:test".to_string(),
issued: Utc::now(),
signatures: vec![],
extra: HashMap::new(),
};
let typed_award = TypedLexicon::new(award);
let json = serde_json::to_value(&typed_award)?;
assert_eq!(json["$type"], "community.lexicon.badge.award");
Ok(())
}
}