use std::sync::Arc;
use async_trait::async_trait;
use thiserror::Error;
use tokio::sync::Mutex;
use super::model::RegistryRecord;
#[derive(Debug, Clone, Error)]
pub enum RegistryError {
#[error("transient registry failure: {0}")]
Transient(String),
#[error("permanent registry failure: {0}")]
Permanent(String),
#[error("registry unreachable: {0}")]
Unreachable(String),
}
impl RegistryError {
pub fn is_retriable(&self) -> bool {
matches!(self, Self::Transient(_) | Self::Unreachable(_))
}
}
#[async_trait]
pub trait TrustRegistryClient: Send + Sync {
async fn publish_member(&self, record: &RegistryRecord) -> Result<(), RegistryError>;
async fn delete_member(&self, member_did: &str) -> Result<(), RegistryError>;
async fn read_member(&self, member_did: &str) -> Result<Option<RegistryRecord>, RegistryError>;
async fn health(&self) -> Result<(), RegistryError>;
async fn recognise(&self, foreign_issuer_did: &str) -> Result<bool, RegistryError>;
}
#[derive(Debug, Clone, Default)]
pub struct MockRegistryClient {
inner: Arc<Mutex<MockState>>,
}
#[derive(Debug, Default)]
struct MockState {
pub records: std::collections::HashMap<String, RegistryRecord>,
pub recognised_issuers: std::collections::HashSet<String>,
pub publish_calls: usize,
pub delete_calls: usize,
pub read_calls: usize,
pub health_calls: usize,
pub recognise_calls: usize,
pub next_publish_error: Option<RegistryError>,
pub next_delete_error: Option<RegistryError>,
pub next_read_error: Option<RegistryError>,
pub next_health_error: Option<RegistryError>,
pub next_recognise_error: Option<RegistryError>,
}
impl MockRegistryClient {
pub fn new() -> Self {
Self::default()
}
pub async fn call_counts(&self) -> MockCallCounts {
let s = self.inner.lock().await;
MockCallCounts {
publish: s.publish_calls,
delete: s.delete_calls,
read: s.read_calls,
health: s.health_calls,
recognise: s.recognise_calls,
}
}
pub async fn fail_next_publish(&self, err: RegistryError) {
self.inner.lock().await.next_publish_error = Some(err);
}
pub async fn fail_next_delete(&self, err: RegistryError) {
self.inner.lock().await.next_delete_error = Some(err);
}
pub async fn fail_next_read(&self, err: RegistryError) {
self.inner.lock().await.next_read_error = Some(err);
}
pub async fn fail_next_health(&self, err: RegistryError) {
self.inner.lock().await.next_health_error = Some(err);
}
pub async fn set_recognised(&self, foreign_issuer_did: impl Into<String>) {
self.inner
.lock()
.await
.recognised_issuers
.insert(foreign_issuer_did.into());
}
pub async fn fail_next_recognise(&self, err: RegistryError) {
self.inner.lock().await.next_recognise_error = Some(err);
}
pub async fn snapshot(&self) -> std::collections::HashMap<String, RegistryRecord> {
self.inner.lock().await.records.clone()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct MockCallCounts {
pub publish: usize,
pub delete: usize,
pub read: usize,
pub health: usize,
pub recognise: usize,
}
#[async_trait]
impl TrustRegistryClient for MockRegistryClient {
async fn publish_member(&self, record: &RegistryRecord) -> Result<(), RegistryError> {
let mut s = self.inner.lock().await;
s.publish_calls += 1;
if let Some(err) = s.next_publish_error.take() {
return Err(err);
}
s.records.insert(record.member_did.clone(), record.clone());
Ok(())
}
async fn delete_member(&self, member_did: &str) -> Result<(), RegistryError> {
let mut s = self.inner.lock().await;
s.delete_calls += 1;
if let Some(err) = s.next_delete_error.take() {
return Err(err);
}
s.records.remove(member_did);
Ok(())
}
async fn read_member(&self, member_did: &str) -> Result<Option<RegistryRecord>, RegistryError> {
let mut s = self.inner.lock().await;
s.read_calls += 1;
if let Some(err) = s.next_read_error.take() {
return Err(err);
}
Ok(s.records.get(member_did).cloned())
}
async fn health(&self) -> Result<(), RegistryError> {
let mut s = self.inner.lock().await;
s.health_calls += 1;
if let Some(err) = s.next_health_error.take() {
return Err(err);
}
Ok(())
}
async fn recognise(&self, foreign_issuer_did: &str) -> Result<bool, RegistryError> {
let mut s = self.inner.lock().await;
s.recognise_calls += 1;
if let Some(err) = s.next_recognise_error.take() {
return Err(err);
}
Ok(s.recognised_issuers.contains(foreign_issuer_did))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::registry::model::RegistryStatus;
use chrono::Utc;
fn fresh_record(did: &str) -> RegistryRecord {
RegistryRecord {
member_did: did.into(),
status: RegistryStatus::Active,
active_from: Utc::now(),
active_to: None,
last_synced_at: Utc::now(),
}
}
#[tokio::test]
async fn mock_tracks_call_counts() {
let m = MockRegistryClient::new();
m.publish_member(&fresh_record("did:key:zA")).await.unwrap();
m.publish_member(&fresh_record("did:key:zB")).await.unwrap();
m.read_member("did:key:zA").await.unwrap();
m.delete_member("did:key:zB").await.unwrap();
m.health().await.unwrap();
let counts = m.call_counts().await;
assert_eq!(counts.publish, 2);
assert_eq!(counts.read, 1);
assert_eq!(counts.delete, 1);
assert_eq!(counts.health, 1);
}
#[tokio::test]
async fn mock_persists_published_records() {
let m = MockRegistryClient::new();
m.publish_member(&fresh_record("did:key:zX")).await.unwrap();
let got = m.read_member("did:key:zX").await.unwrap().expect("present");
assert_eq!(got.member_did, "did:key:zX");
let none = m.read_member("did:key:zMissing").await.unwrap();
assert!(none.is_none());
}
#[tokio::test]
async fn fail_next_publish_consumes_a_single_call() {
let m = MockRegistryClient::new();
m.fail_next_publish(RegistryError::Transient("flaky".into()))
.await;
let err = m
.publish_member(&fresh_record("did:key:zA"))
.await
.expect_err("queued error must surface");
assert!(err.is_retriable());
m.publish_member(&fresh_record("did:key:zA")).await.unwrap();
}
#[tokio::test]
async fn delete_removes_from_snapshot() {
let m = MockRegistryClient::new();
m.publish_member(&fresh_record("did:key:zKeep"))
.await
.unwrap();
m.publish_member(&fresh_record("did:key:zDrop"))
.await
.unwrap();
m.delete_member("did:key:zDrop").await.unwrap();
let snap = m.snapshot().await;
assert!(snap.contains_key("did:key:zKeep"));
assert!(!snap.contains_key("did:key:zDrop"));
}
#[tokio::test]
async fn recognise_returns_true_for_seeded_issuer() {
let m = MockRegistryClient::new();
m.set_recognised("did:webvh:peer.example.com:abc").await;
assert!(m.recognise("did:webvh:peer.example.com:abc").await.unwrap());
assert!(!m.recognise("did:webvh:stranger.example").await.unwrap());
assert_eq!(m.call_counts().await.recognise, 2);
}
#[tokio::test]
async fn recognise_propagates_transport_errors() {
let m = MockRegistryClient::new();
m.fail_next_recognise(RegistryError::Unreachable("dns".into()))
.await;
let err = m
.recognise("did:webvh:peer.example")
.await
.expect_err("queued error must surface");
assert!(err.is_retriable());
}
#[test]
fn registry_error_retriable_classification() {
assert!(RegistryError::Transient("x".into()).is_retriable());
assert!(RegistryError::Unreachable("x".into()).is_retriable());
assert!(!RegistryError::Permanent("x".into()).is_retriable());
}
}