use async_trait::async_trait;
use crate::types::{
AdmissionMode, LookupQuestion, LookupResult, OutputAdmittedByTopic, OutputSpent,
ServiceMetadata, SpendNotificationMode,
};
#[cfg(test)]
use crate::types::UTXOReference;
#[async_trait(?Send)]
pub trait LookupService {
fn admission_mode(&self) -> AdmissionMode;
fn spend_notification_mode(&self) -> SpendNotificationMode;
fn reads_off_chain_values(&self, _topic: &str) -> bool {
true
}
async fn output_admitted_by_topic(
&self,
payload: &OutputAdmittedByTopic,
) -> Result<(), LookupServiceError>;
async fn output_spent(&self, _payload: &OutputSpent) -> Result<(), LookupServiceError> {
Ok(())
}
async fn output_evicted(&self, txid: &str, output_index: u32)
-> Result<(), LookupServiceError>;
async fn output_no_longer_retained_in_history(
&self,
_txid: &str,
_output_index: u32,
_topic: &str,
) -> Result<(), LookupServiceError> {
Ok(())
}
async fn lookup(&self, question: &LookupQuestion) -> Result<LookupResult, LookupServiceError>;
async fn get_documentation(&self) -> String;
async fn get_metadata(&self) -> ServiceMetadata;
}
#[derive(Debug, thiserror::Error)]
pub enum LookupServiceError {
#[error("invalid query: {0}")]
InvalidQuery(String),
#[error("storage error: {0}")]
StorageError(String),
#[error("unsupported query: {0}")]
Unsupported(String),
#[error("{0}")]
Other(String),
}
#[cfg(test)]
mod tests {
use super::*;
struct MockLookupService {
records: std::sync::Mutex<Vec<UTXOReference>>,
}
impl MockLookupService {
fn new() -> Self {
Self {
records: std::sync::Mutex::new(Vec::new()),
}
}
fn record_count(&self) -> usize {
self.records.lock().unwrap().len()
}
}
#[async_trait(?Send)]
impl LookupService for MockLookupService {
fn admission_mode(&self) -> AdmissionMode {
AdmissionMode::LockingScript
}
fn spend_notification_mode(&self) -> SpendNotificationMode {
SpendNotificationMode::None
}
async fn output_admitted_by_topic(
&self,
payload: &OutputAdmittedByTopic,
) -> Result<(), LookupServiceError> {
let (txid, output_index) = match payload {
OutputAdmittedByTopic::LockingScript {
txid, output_index, ..
} => (txid.clone(), *output_index),
OutputAdmittedByTopic::WholeTx { output_index, .. } => {
("whole-tx".to_string(), *output_index)
}
};
self.records
.lock()
.unwrap()
.push(UTXOReference { txid, output_index });
Ok(())
}
async fn output_evicted(
&self,
txid: &str,
output_index: u32,
) -> Result<(), LookupServiceError> {
self.records
.lock()
.unwrap()
.retain(|r| !(r.txid == txid && r.output_index == output_index));
Ok(())
}
async fn lookup(
&self,
_question: &LookupQuestion,
) -> Result<LookupResult, LookupServiceError> {
Ok(LookupResult::OutputList(
self.records.lock().unwrap().clone(),
))
}
async fn get_documentation(&self) -> String {
"Mock lookup service for testing.".to_string()
}
async fn get_metadata(&self) -> ServiceMetadata {
ServiceMetadata {
name: "mock-lookup".to_string(),
description: Some("Test lookup service".to_string()),
..Default::default()
}
}
}
#[tokio::test]
async fn test_output_admitted_and_lookup() {
let svc = MockLookupService::new();
let payload = OutputAdmittedByTopic::LockingScript {
txid: "abc".to_string(),
output_index: 0,
topic: "tm_test".to_string(),
satoshis: 1000,
locking_script: vec![0x76],
off_chain_values: None,
};
svc.output_admitted_by_topic(&payload).await.unwrap();
assert_eq!(svc.record_count(), 1);
let question = LookupQuestion::new("ls_test", serde_json::json!({}));
let result = svc.lookup(&question).await.unwrap();
let LookupResult::OutputList(refs) = result else {
panic!("expected OutputList");
};
assert_eq!(refs.len(), 1);
assert_eq!(refs[0].txid, "abc");
assert_eq!(refs[0].output_index, 0);
}
#[tokio::test]
async fn test_output_evicted() {
let svc = MockLookupService::new();
let payload = OutputAdmittedByTopic::LockingScript {
txid: "abc".to_string(),
output_index: 0,
topic: "tm_test".to_string(),
satoshis: 1000,
locking_script: vec![],
off_chain_values: None,
};
svc.output_admitted_by_topic(&payload).await.unwrap();
assert_eq!(svc.record_count(), 1);
svc.output_evicted("abc", 0).await.unwrap();
assert_eq!(svc.record_count(), 0);
let question = LookupQuestion::new("ls_test", serde_json::json!({}));
let result = svc.lookup(&question).await.unwrap();
let LookupResult::OutputList(refs) = result else {
panic!("expected OutputList");
};
assert!(refs.is_empty());
}
#[tokio::test]
async fn test_default_output_spent_is_noop() {
let svc = MockLookupService::new();
let payload = OutputSpent::None {
txid: "abc".to_string(),
output_index: 0,
topic: "tm_test".to_string(),
};
svc.output_spent(&payload).await.unwrap();
}
#[tokio::test]
async fn test_default_output_no_longer_retained() {
let svc = MockLookupService::new();
svc.output_no_longer_retained_in_history("abc", 0, "tm_test")
.await
.unwrap();
}
#[tokio::test]
async fn test_lookup_service_is_object_safe() {
let svc: Box<dyn LookupService> = Box::new(MockLookupService::new());
assert_eq!(svc.admission_mode(), AdmissionMode::LockingScript);
assert_eq!(svc.spend_notification_mode(), SpendNotificationMode::None);
let meta = svc.get_metadata().await;
assert_eq!(meta.name, "mock-lookup");
}
#[tokio::test]
async fn test_multiple_admissions_and_selective_eviction() {
let svc = MockLookupService::new();
for i in 0..5 {
let payload = OutputAdmittedByTopic::LockingScript {
txid: format!("tx{i}"),
output_index: 0,
topic: "tm_test".to_string(),
satoshis: 1000,
locking_script: vec![],
off_chain_values: None,
};
svc.output_admitted_by_topic(&payload).await.unwrap();
}
assert_eq!(svc.record_count(), 5);
svc.output_evicted("tx2", 0).await.unwrap();
assert_eq!(svc.record_count(), 4);
let result = svc
.lookup(&LookupQuestion::new("ls_test", serde_json::json!({})))
.await
.unwrap();
let LookupResult::OutputList(refs) = result else {
panic!("expected OutputList");
};
assert!(!refs.iter().any(|r| r.txid == "tx2"));
}
}