diff --git a/contract/src/handlers/verify.rs b/contract/src/handlers/verify.rs index 549f2c28..7a1f1a76 100644 --- a/contract/src/handlers/verify.rs +++ b/contract/src/handlers/verify.rs @@ -283,3 +283,156 @@ pub async fn verify_single_hash(state: &AppState, hash: String) -> BatchVerifyIt error: None, } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::cache::{CacheBackend, InMemoryCache}; + use crate::metrics::MetricsRegistry; + use crate::stellar::StellarClient; + + fn test_state() -> AppState { + AppState { + stellar: StellarClient::new("https://horizon-testnet.stellar.org"), + cache: CacheBackend::InMemory(InMemoryCache::new()), + metrics: MetricsRegistry::new(), + stellar_secret_key: "SAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA".to_string(), + } + } + + #[tokio::test] + async fn test_verify_single_hash_invalid_length() { + let state = test_state(); + let item = verify_single_hash(&state, "short_hash".to_string()).await; + assert_eq!(item.hash, "short_hash"); + assert!(!item.verified); + assert!(item.transaction_id.is_none()); + assert!(item.timestamp.is_none()); + assert!(item.error.is_some()); + assert!(item.error.unwrap().contains("wrong length")); + } + + #[tokio::test] + async fn test_verify_single_hash_invalid_hex() { + let state = test_state(); + let invalid_hex = "z".repeat(64); + let item = verify_single_hash(&state, invalid_hex.clone()).await; + assert_eq!(item.hash, invalid_hex); + assert!(!item.verified); + assert!(item.error.is_some()); + assert!(item.error.unwrap().contains("invalid character")); + } + + #[tokio::test] + async fn test_verify_single_hash_empty() { + let state = test_state(); + let item = verify_single_hash(&state, "".to_string()).await; + assert_eq!(item.hash, ""); + assert!(!item.verified); + assert!(item.error.is_some()); + assert!(item.error.unwrap().contains("empty")); + } + + #[tokio::test] + async fn test_verify_single_hash_cached_hit() { + let state = test_state(); + let valid_hash = + "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855".to_string(); + + let cached_resp = VerifyResponse { + verified: true, + transaction_id: Some("tx_cached_123".to_string()), + timestamp: Some(1700000000), + cached: true, + revoked: None, + revoked_at: None, + }; + state + .cache + .set(&valid_hash, &cached_resp, 3600) + .await + .unwrap(); + + let item = verify_single_hash(&state, valid_hash.clone()).await; + assert_eq!(item.hash, valid_hash); + assert!(item.verified); + assert_eq!(item.transaction_id, Some("tx_cached_123".to_string())); + assert_eq!(item.timestamp, Some(1700000000)); + assert!(item.error.is_none()); + } + + #[tokio::test] + async fn test_batch_verify_mixed_hashes_returns_distinct_results() { + let state = test_state(); + + let valid_cached_hash = + "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855".to_string(); + let cached_resp = VerifyResponse { + verified: true, + transaction_id: Some("tx_valid_cached".to_string()), + timestamp: Some(1700000000), + cached: true, + revoked: None, + revoked_at: None, + }; + state + .cache + .set(&valid_cached_hash, &cached_resp, 3600) + .await + .unwrap(); + + let invalid_short_hash = "abc123".to_string(); + let invalid_char_hash = "g".repeat(64); + + let req = BatchVerifyRequest { + hashes: vec![ + valid_cached_hash.clone(), + invalid_short_hash.clone(), + invalid_char_hash.clone(), + ], + }; + + let response = batch_verify_documents(State(state), Json(req)).await; + assert_eq!(response.status(), StatusCode::OK); + + // Convert body to BatchVerifyResponse + let body = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .unwrap(); + let batch_resp: BatchVerifyResponse = serde_json::from_slice(&body).unwrap(); + + assert_eq!(batch_resp.total, 3); + assert_eq!(batch_resp.verified_count, 1); + assert_eq!(batch_resp.failed_count, 2); + assert_eq!(batch_resp.results.len(), 3); + + // First item (valid, cached): verified = true, error = None + assert_eq!(batch_resp.results[0].hash, valid_cached_hash); + assert!(batch_resp.results[0].verified); + assert_eq!( + batch_resp.results[0].transaction_id, + Some("tx_valid_cached".to_string()) + ); + assert!(batch_resp.results[0].error.is_none()); + + // Second item (invalid length): verified = false, error = Some(...) + assert_eq!(batch_resp.results[1].hash, invalid_short_hash); + assert!(!batch_resp.results[1].verified); + assert!(batch_resp.results[1].error.is_some()); + assert!(batch_resp.results[1] + .error + .as_ref() + .unwrap() + .contains("wrong length")); + + // Third item (invalid character): verified = false, error = Some(...) + assert_eq!(batch_resp.results[2].hash, invalid_char_hash); + assert!(!batch_resp.results[2].verified); + assert!(batch_resp.results[2].error.is_some()); + assert!(batch_resp.results[2] + .error + .as_ref() + .unwrap() + .contains("invalid character")); + } +} diff --git a/contract/src/lib.rs b/contract/src/lib.rs index b6b92517..d0a1f87d 100644 --- a/contract/src/lib.rs +++ b/contract/src/lib.rs @@ -82,13 +82,13 @@ pub struct AppState { } // Request/Response types -#[derive(Debug, Deserialize)] +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] pub struct VerifyRequest { pub document_hash: String, pub transaction_id: Option, } -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] pub struct VerifyResponse { pub verified: bool, pub transaction_id: Option, @@ -101,7 +101,7 @@ pub struct VerifyResponse { } /// Request type for submitting a document hash to Stellar blockchain -#[derive(Debug, Deserialize)] +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] pub struct SubmitRequest { pub document_hash: String, pub document_id: String, @@ -109,7 +109,7 @@ pub struct SubmitRequest { } /// Response type for document hash submission -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] pub struct SubmitResponse { pub success: bool, pub transaction_id: Option, @@ -117,21 +117,21 @@ pub struct SubmitResponse { pub error: Option, } -#[derive(Debug, Deserialize)] +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] pub struct RevokeRequest { pub document_hash: String, pub reason: String, pub revoked_by: String, } -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] pub struct RevokeResponse { pub transaction_id: String, pub revoked_at: i64, pub revoked: bool, } -#[derive(Debug, Serialize)] +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] pub struct HealthResponse { pub status: String, pub stellar_connected: bool, @@ -141,7 +141,7 @@ pub struct HealthResponse { } /// Response type for document verification history -#[derive(Debug, Serialize)] +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] pub struct HistoryResponse { pub document_hash: String, pub transactions: Vec, @@ -149,17 +149,17 @@ pub struct HistoryResponse { pub cached: bool, } -#[derive(Debug, Serialize)] +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] pub struct ValidationErrorResponse { pub error: String, } -#[derive(Debug, Deserialize)] +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] pub struct BatchVerifyRequest { pub hashes: Vec, } -#[derive(Debug, Serialize)] +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] pub struct BatchVerifyResponse { pub results: Vec, pub total: usize, @@ -167,7 +167,7 @@ pub struct BatchVerifyResponse { pub failed_count: usize, } -#[derive(Debug, Serialize)] +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] pub struct BatchVerifyItem { pub hash: String, pub verified: bool, @@ -176,7 +176,7 @@ pub struct BatchVerifyItem { pub error: Option, } -#[derive(Debug, Deserialize, Clone)] +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] pub struct TransferRequest { pub document_hash: String, pub from_owner: String, @@ -185,7 +185,7 @@ pub struct TransferRequest { pub transfer_reference: String, } -#[derive(Debug, Serialize, Deserialize, Clone)] +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] pub struct TransferRecord { pub document_hash: String, pub from_owner: String, @@ -197,7 +197,7 @@ pub struct TransferRecord { pub anchored_at: String, } -#[derive(Debug, Serialize)] +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] pub struct TransferResponse { pub transfer_hash: String, pub memo: String, diff --git a/contract/src/stellar.rs b/contract/src/stellar.rs index 65986589..387a33eb 100644 --- a/contract/src/stellar.rs +++ b/contract/src/stellar.rs @@ -26,7 +26,7 @@ pub struct StellarClient { retry_policy: RetryPolicy, } -#[derive(Debug, Serialize, Deserialize, Clone)] +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] pub struct TransactionRecord { pub transaction_id: String, pub timestamp: i64, diff --git a/contract/src/tests/integration.rs b/contract/src/tests/integration.rs index 0f51123a..58f6d6c0 100644 --- a/contract/src/tests/integration.rs +++ b/contract/src/tests/integration.rs @@ -252,6 +252,85 @@ async fn batch_verify_calls_horizon_once_per_hash() { account_mock.assert_hits_async(10).await; } +#[tokio::test] +async fn batch_verify_mixed_valid_and_invalid_hashes_returns_distinct_results() { + let mock_server = MockServer::start_async().await; + + mock_server.mock(|when, then| { + when.method(GET).path_contains("/accounts/"); + then.status(200) + .header("content-type", "application/json") + .json_body(horizon_empty_account_json()); + }); + + let state = make_state(&mock_server.base_url()); + + // Prime cache with a verified document + let verified_hash = SAMPLE_HASH.to_string(); + let cached_resp = crate::VerifyResponse { + verified: true, + transaction_id: Some("tx_integration_batch".to_string()), + timestamp: Some(1700000000), + cached: true, + revoked: None, + revoked_at: None, + }; + state + .cache + .set(&verified_hash, &cached_resp, 3600) + .await + .unwrap(); + + let server = TestServer::new(app(state)).unwrap(); + + let invalid_short = "short"; + let invalid_hex = "x".repeat(64); + let valid_unanchored = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + + let resp = server + .post("/verify/batch") + .json(&json!({ + "hashes": [ + verified_hash, + invalid_short, + invalid_hex, + valid_unanchored + ] + })) + .await; + + resp.assert_status_ok(); + let body: Value = resp.json(); + + assert_eq!(body["total"], 4); + assert_eq!(body["verified_count"], 1); + assert_eq!(body["failed_count"], 3); + + let results = body["results"].as_array().unwrap(); + assert_eq!(results.len(), 4); + + // Item 0: valid, anchored (cached) -> verified: true, error: null + assert_eq!(results[0]["hash"], SAMPLE_HASH); + assert_eq!(results[0]["verified"], true); + assert_eq!(results[0]["transaction_id"], "tx_integration_batch"); + assert!(results[0]["error"].is_null()); + + // Item 1: invalid length -> verified: false, error: "wrong length" + assert_eq!(results[1]["hash"], invalid_short); + assert_eq!(results[1]["verified"], false); + assert!(results[1]["error"].as_str().unwrap().contains("wrong length")); + + // Item 2: invalid hex -> verified: false, error: "invalid character" + assert_eq!(results[2]["hash"], invalid_hex); + assert_eq!(results[2]["verified"], false); + assert!(results[2]["error"].as_str().unwrap().contains("invalid character")); + + // Item 3: valid unanchored -> verified: false, error: null + assert_eq!(results[3]["hash"], valid_unanchored); + assert_eq!(results[3]["verified"], false); + assert!(results[3]["error"].is_null()); +} + // ───────────────────────────────────────────────────────────────────────────── // 7. Batch validate: empty batch → 400 // ───────────────────────────────────────────────────────────────────────────── diff --git a/contract/src/types.rs b/contract/src/types.rs index 01128334..0d366f0d 100644 --- a/contract/src/types.rs +++ b/contract/src/types.rs @@ -16,13 +16,13 @@ pub struct AppState { } // Request/Response types -#[derive(Debug, Deserialize)] +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] pub struct VerifyRequest { pub document_hash: String, pub transaction_id: Option, } -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] pub struct VerifyResponse { pub verified: bool, pub transaction_id: Option, @@ -35,7 +35,7 @@ pub struct VerifyResponse { } /// Request type for submitting a document hash to Stellar blockchain -#[derive(Debug, Deserialize)] +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] pub struct SubmitRequest { pub document_hash: String, pub document_id: String, @@ -43,7 +43,7 @@ pub struct SubmitRequest { } /// Response type for document hash submission -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] pub struct SubmitResponse { pub success: bool, pub transaction_id: Option, @@ -51,21 +51,21 @@ pub struct SubmitResponse { pub error: Option, } -#[derive(Debug, Deserialize)] +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] pub struct RevokeRequest { pub document_hash: String, pub reason: String, pub revoked_by: String, } -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] pub struct RevokeResponse { pub transaction_id: String, pub revoked_at: i64, pub revoked: bool, } -#[derive(Debug, Serialize)] +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] pub struct HealthResponse { pub status: String, pub stellar_connected: bool, @@ -73,7 +73,7 @@ pub struct HealthResponse { } /// Response type for document verification history -#[derive(Debug, Serialize)] +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] pub struct HistoryResponse { pub document_hash: String, pub transactions: Vec, @@ -81,17 +81,17 @@ pub struct HistoryResponse { pub cached: bool, } -#[derive(Debug, Serialize)] +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] pub struct ValidationErrorResponse { pub error: String, } -#[derive(Debug, Deserialize)] +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] pub struct BatchVerifyRequest { pub hashes: Vec, } -#[derive(Debug, Serialize)] +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] pub struct BatchVerifyResponse { pub results: Vec, pub total: usize, @@ -99,7 +99,7 @@ pub struct BatchVerifyResponse { pub failed_count: usize, } -#[derive(Debug, Serialize)] +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] pub struct BatchVerifyItem { pub hash: String, pub verified: bool, @@ -108,7 +108,7 @@ pub struct BatchVerifyItem { pub error: Option, } -#[derive(Debug, Deserialize, Clone)] +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] pub struct TransferRequest { pub document_hash: String, pub from_owner: String, @@ -117,7 +117,7 @@ pub struct TransferRequest { pub transfer_reference: String, } -#[derive(Debug, Serialize, Deserialize, Clone)] +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] pub struct TransferRecord { pub document_hash: String, pub from_owner: String, @@ -129,7 +129,7 @@ pub struct TransferRecord { pub anchored_at: String, } -#[derive(Debug, Serialize)] +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] pub struct TransferResponse { pub transfer_hash: String, pub memo: String, @@ -246,7 +246,7 @@ pub fn cosine_similarity(doc1: &str, doc2: &str) -> f64 { } /// Document similarity result -#[derive(Debug, Clone)] +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] pub struct SimilarityResult { pub doc1: String, pub doc2: String, @@ -541,4 +541,395 @@ mod tests { assert_eq!(item.timestamp, None); assert_eq!(item.error, Some("invalid hash format".to_string())); } + + // ── Round-trip serde tests for all public types ────────────── + + fn assert_serde_round_trip(original: &T) + where + T: Serialize + for<'de> Deserialize<'de> + PartialEq + std::fmt::Debug, + { + let json_string = + serde_json::to_string(original).expect("serialization to JSON string should succeed"); + let deserialized: T = serde_json::from_str(&json_string) + .expect("deserialization from JSON string should succeed"); + assert_eq!( + original, &deserialized, + "Round-trip value mismatch for JSON string" + ); + + let json_bytes = + serde_json::to_vec(original).expect("serialization to JSON bytes should succeed"); + let deserialized_bytes: T = serde_json::from_slice(&json_bytes) + .expect("deserialization from JSON slice should succeed"); + assert_eq!( + original, &deserialized_bytes, + "Round-trip value mismatch for JSON bytes" + ); + } + + #[test] + fn test_round_trip_verify_request() { + let req_with_tx = VerifyRequest { + document_hash: "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + .to_string(), + transaction_id: Some("tx_abc123".to_string()), + }; + assert_serde_round_trip(&req_with_tx); + + let req_without_tx = VerifyRequest { + document_hash: "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + .to_string(), + transaction_id: None, + }; + assert_serde_round_trip(&req_without_tx); + } + + #[test] + fn test_round_trip_verify_response() { + let resp_full = VerifyResponse { + verified: true, + transaction_id: Some("tx_123".to_string()), + timestamp: Some(1700000000), + cached: true, + revoked: Some(false), + revoked_at: None, + }; + assert_serde_round_trip(&resp_full); + + let resp_revoked = VerifyResponse { + verified: false, + transaction_id: Some("tx_456".to_string()), + timestamp: Some(1700000000), + cached: false, + revoked: Some(true), + revoked_at: Some(1700001000), + }; + assert_serde_round_trip(&resp_revoked); + + let resp_minimal = VerifyResponse { + verified: false, + transaction_id: None, + timestamp: None, + cached: false, + revoked: None, + revoked_at: None, + }; + assert_serde_round_trip(&resp_minimal); + } + + #[test] + fn test_round_trip_submit_request() { + let req = SubmitRequest { + document_hash: "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + .to_string(), + document_id: "doc-999".to_string(), + submitter: "alice".to_string(), + }; + assert_serde_round_trip(&req); + } + + #[test] + fn test_round_trip_submit_response() { + let resp_success = SubmitResponse { + success: true, + transaction_id: Some("tx_submit_123".to_string()), + anchored_at: Some(1700000000), + error: None, + }; + assert_serde_round_trip(&resp_success); + + let resp_failed = SubmitResponse { + success: false, + transaction_id: None, + anchored_at: None, + error: Some("network error".to_string()), + }; + assert_serde_round_trip(&resp_failed); + } + + #[test] + fn test_round_trip_revoke_request() { + let req = RevokeRequest { + document_hash: "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + .to_string(), + reason: "superseded by version 2".to_string(), + revoked_by: "admin".to_string(), + }; + assert_serde_round_trip(&req); + } + + #[test] + fn test_round_trip_revoke_response() { + let resp = RevokeResponse { + transaction_id: "tx_revoke_789".to_string(), + revoked_at: 1700002000, + revoked: true, + }; + assert_serde_round_trip(&resp); + } + + #[test] + fn test_round_trip_health_response() { + let resp_healthy = HealthResponse { + status: "healthy".to_string(), + stellar_connected: true, + redis_connected: true, + }; + assert_serde_round_trip(&resp_healthy); + + let resp_degraded = HealthResponse { + status: "degraded".to_string(), + stellar_connected: false, + redis_connected: true, + }; + assert_serde_round_trip(&resp_degraded); + } + + #[test] + fn test_round_trip_history_response() { + let resp_empty = HistoryResponse { + document_hash: "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + .to_string(), + transactions: vec![], + count: 0, + cached: false, + }; + assert_serde_round_trip(&resp_empty); + + let resp_with_records = HistoryResponse { + document_hash: "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + .to_string(), + transactions: vec![ + TransactionRecord { + transaction_id: "tx_1".to_string(), + timestamp: 1690000000, + verified: true, + }, + TransactionRecord { + transaction_id: "tx_2".to_string(), + timestamp: 1700000000, + verified: true, + }, + ], + count: 2, + cached: true, + }; + assert_serde_round_trip(&resp_with_records); + } + + #[test] + fn test_round_trip_validation_error_response() { + let resp = ValidationErrorResponse { + error: "hash must not be empty".to_string(), + }; + assert_serde_round_trip(&resp); + } + + #[test] + fn test_round_trip_batch_verify_request() { + let req_empty = BatchVerifyRequest { hashes: vec![] }; + assert_serde_round_trip(&req_empty); + + let req_populated = BatchVerifyRequest { + hashes: vec![ + "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855".to_string(), + "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad".to_string(), + ], + }; + assert_serde_round_trip(&req_populated); + } + + #[test] + fn test_round_trip_batch_verify_item() { + let item_verified = BatchVerifyItem { + hash: "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855".to_string(), + verified: true, + transaction_id: Some("tx_batch_1".to_string()), + timestamp: Some(1700000000), + error: None, + }; + assert_serde_round_trip(&item_verified); + + let item_with_error = BatchVerifyItem { + hash: "invalid-hash".to_string(), + verified: false, + transaction_id: None, + timestamp: None, + error: Some("hash has wrong length: expected 64 characters, got 12".to_string()), + }; + assert_serde_round_trip(&item_with_error); + } + + #[test] + fn test_round_trip_batch_verify_response() { + let resp = BatchVerifyResponse { + results: vec![ + BatchVerifyItem { + hash: "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + .to_string(), + verified: true, + transaction_id: Some("tx_1".to_string()), + timestamp: Some(1700000000), + error: None, + }, + BatchVerifyItem { + hash: "invalid_hash".to_string(), + verified: false, + transaction_id: None, + timestamp: None, + error: Some("hash has wrong length: expected 64 characters, got 12".to_string()), + }, + ], + total: 2, + verified_count: 1, + failed_count: 1, + }; + assert_serde_round_trip(&resp); + } + + #[test] + fn test_round_trip_transfer_request() { + let req = TransferRequest { + document_hash: "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + .to_string(), + from_owner: "Alice".to_string(), + to_owner: "Bob".to_string(), + transfer_date: "2025-01-15".to_string(), + transfer_reference: "REF-2025-001".to_string(), + }; + assert_serde_round_trip(&req); + } + + #[test] + fn test_round_trip_transfer_record() { + let record = TransferRecord { + document_hash: "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + .to_string(), + from_owner: "Alice".to_string(), + to_owner: "Bob".to_string(), + transfer_date: "2025-01-15".to_string(), + transfer_reference: "REF-2025-001".to_string(), + transfer_hash: "a1b2c3d4e5f6".to_string(), + memo: "TRANSFER:a1b2c3d4e5f6".to_string(), + anchored_at: "2025-01-15T12:00:00Z".to_string(), + }; + assert_serde_round_trip(&record); + } + + #[test] + fn test_round_trip_transfer_response() { + let resp = TransferResponse { + transfer_hash: "a1b2c3d4e5f67890".to_string(), + memo: "TRANSFER:a1b2c3d4e5f67890".to_string(), + }; + assert_serde_round_trip(&resp); + } + + #[test] + fn test_round_trip_similarity_result() { + let result = SimilarityResult { + doc1: "Hello world".to_string(), + doc2: "Hello earth".to_string(), + cosine: 0.85, + levenshtein: 0.72, + combined: 0.785, + }; + assert_serde_round_trip(&result); + } + + #[test] + fn test_all_public_types_round_trip_suite() { + // Consolidated test verifying all 16 public data/serde types + assert_serde_round_trip(&VerifyRequest { + document_hash: "hash123".to_string(), + transaction_id: Some("tx1".to_string()), + }); + assert_serde_round_trip(&VerifyResponse { + verified: true, + transaction_id: Some("tx1".to_string()), + timestamp: Some(100), + cached: false, + revoked: None, + revoked_at: None, + }); + assert_serde_round_trip(&SubmitRequest { + document_hash: "hash123".to_string(), + document_id: "doc1".to_string(), + submitter: "user1".to_string(), + }); + assert_serde_round_trip(&SubmitResponse { + success: true, + transaction_id: Some("tx1".to_string()), + anchored_at: Some(100), + error: None, + }); + assert_serde_round_trip(&RevokeRequest { + document_hash: "hash123".to_string(), + reason: "revoked".to_string(), + revoked_by: "admin".to_string(), + }); + assert_serde_round_trip(&RevokeResponse { + transaction_id: "tx1".to_string(), + revoked_at: 100, + revoked: true, + }); + assert_serde_round_trip(&HealthResponse { + status: "healthy".to_string(), + stellar_connected: true, + redis_connected: true, + }); + assert_serde_round_trip(&HistoryResponse { + document_hash: "hash123".to_string(), + transactions: vec![], + count: 0, + cached: false, + }); + assert_serde_round_trip(&ValidationErrorResponse { + error: "bad request".to_string(), + }); + assert_serde_round_trip(&BatchVerifyRequest { + hashes: vec!["hash1".to_string()], + }); + assert_serde_round_trip(&BatchVerifyItem { + hash: "hash1".to_string(), + verified: true, + transaction_id: Some("tx1".to_string()), + timestamp: Some(100), + error: None, + }); + assert_serde_round_trip(&BatchVerifyResponse { + results: vec![], + total: 0, + verified_count: 0, + failed_count: 0, + }); + assert_serde_round_trip(&TransferRequest { + document_hash: "hash1".to_string(), + from_owner: "alice".to_string(), + to_owner: "bob".to_string(), + transfer_date: "2025-01-01".to_string(), + transfer_reference: "ref1".to_string(), + }); + assert_serde_round_trip(&TransferRecord { + document_hash: "hash1".to_string(), + from_owner: "alice".to_string(), + to_owner: "bob".to_string(), + transfer_date: "2025-01-01".to_string(), + transfer_reference: "ref1".to_string(), + transfer_hash: "thash".to_string(), + memo: "memo".to_string(), + anchored_at: "2025-01-01T00:00:00Z".to_string(), + }); + assert_serde_round_trip(&TransferResponse { + transfer_hash: "thash".to_string(), + memo: "memo".to_string(), + }); + assert_serde_round_trip(&SimilarityResult { + doc1: "a".to_string(), + doc2: "b".to_string(), + cosine: 0.5, + levenshtein: 0.5, + combined: 0.5, + }); + } } diff --git a/contract/tests/handler_integration_tests.rs b/contract/tests/handler_integration_tests.rs index 69c5e0a9..9dee523a 100644 --- a/contract/tests/handler_integration_tests.rs +++ b/contract/tests/handler_integration_tests.rs @@ -218,6 +218,101 @@ async fn test_batch_verify_too_many_hashes_returns_400() { assert_eq!(response.status(), StatusCode::BAD_REQUEST); } +#[tokio::test] +async fn test_batch_verify_mixed_valid_and_invalid_hashes_returns_per_item_results() { + let state = test_app_state(); + let valid_hash_1 = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"; + let invalid_hash_short = "too-short"; + let invalid_hash_bad_chars = "z".repeat(64); + let valid_hash_2 = "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"; + + // Prime cache for valid_hash_1 so it verifies true + let cached_resp = stellar_doc_verifier::VerifyResponse { + verified: true, + transaction_id: Some("tx_integration_1".to_string()), + timestamp: Some(1700000000), + cached: true, + revoked: None, + revoked_at: None, + }; + state + .cache + .set(valid_hash_1, &cached_resp, 3600) + .await + .unwrap(); + + let router = app(state); + + let body = serde_json::json!({ + "hashes": [ + valid_hash_1, + invalid_hash_short, + invalid_hash_bad_chars, + valid_hash_2 + ] + }) + .to_string(); + + let response = router + .oneshot( + Request::builder() + .method("POST") + .uri("/verify/batch") + .header("content-type", "application/json") + .body(Body::from(body)) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + + let body_bytes = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .unwrap(); + let resp: stellar_doc_verifier::BatchVerifyResponse = + serde_json::from_slice(&body_bytes).unwrap(); + + assert_eq!(resp.total, 4); + assert_eq!(resp.verified_count, 1); + assert_eq!(resp.failed_count, 3); + assert_eq!(resp.results.len(), 4); + + // Item 0: valid and verified from cache + assert_eq!(resp.results[0].hash, valid_hash_1); + assert!(resp.results[0].verified); + assert_eq!( + resp.results[0].transaction_id, + Some("tx_integration_1".to_string()) + ); + assert_eq!(resp.results[0].timestamp, Some(1700000000)); + assert!(resp.results[0].error.is_none()); + + // Item 1: invalid length -> per-item error without failing batch + assert_eq!(resp.results[1].hash, invalid_hash_short); + assert!(!resp.results[1].verified); + assert!(resp.results[1].error.is_some()); + assert!(resp.results[1] + .error + .as_ref() + .unwrap() + .contains("wrong length")); + + // Item 2: invalid character -> per-item error without failing batch + assert_eq!(resp.results[2].hash, invalid_hash_bad_chars); + assert!(!resp.results[2].verified); + assert!(resp.results[2].error.is_some()); + assert!(resp.results[2] + .error + .as_ref() + .unwrap() + .contains("invalid character")); + + // Item 3: valid format hash but query failed / not found -> verified: false + assert_eq!(resp.results[3].hash, valid_hash_2); + assert!(!resp.results[3].verified); +} + #[tokio::test] async fn test_submit_with_invalid_hash_returns_400() { let state = test_app_state();