From 3a076ea65090c4ff0b5ef5a3c4c9f88d556e5daa Mon Sep 17 00:00:00 2001 From: Felipe Gonzalez Date: Mon, 9 Mar 2026 21:06:11 -0300 Subject: [PATCH 1/2] chore(minikupo): Add comprehensive tests --- Cargo.lock | 3 + crates/minibf/src/routes/scripts.rs | 220 ++++++++++++++++++ crates/minibf/src/test_support.rs | 16 ++ crates/minikupo/Cargo.toml | 5 + crates/minikupo/src/lib.rs | 2 + crates/minikupo/src/routes/datums.rs | 66 ++++++ crates/minikupo/src/routes/health.rs | 88 +++++++ crates/minikupo/src/routes/matches.rs | 305 +++++++++++++++++++++++++ crates/minikupo/src/routes/metadata.rs | 102 +++++++++ crates/minikupo/src/routes/scripts.rs | 68 ++++++ crates/minikupo/src/test_support.rs | 197 ++++++++++++++++ crates/testing/src/synthetic.rs | 147 +++++++++--- crates/testing/src/toy_domain.rs | 37 +++ 13 files changed, 1228 insertions(+), 28 deletions(-) create mode 100644 crates/minikupo/src/test_support.rs diff --git a/Cargo.lock b/Cargo.lock index 12b955bb..e495cef0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1371,11 +1371,14 @@ dependencies = [ "bech32 0.11.1", "dolos-cardano", "dolos-core", + "dolos-testing", "hex", + "http-body-util", "pallas", "serde", "serde_json", "tokio", + "tower 0.4.13", "tower-http 0.6.6", "tracing", ] diff --git a/crates/minibf/src/routes/scripts.rs b/crates/minibf/src/routes/scripts.rs index 523f22a7..af0c1cc8 100644 --- a/crates/minibf/src/routes/scripts.rs +++ b/crates/minibf/src/routes/scripts.rs @@ -159,3 +159,223 @@ where cbor: hex::encode(minicbor::to_vec(&datum).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?), })) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_support::{TestApp, TestFault}; + + fn fixture_app() -> TestApp { + TestApp::new() + } + + fn invalid_script_hash() -> &'static str { + "not-a-script-hash" + } + + fn missing_script_hash() -> &'static str { + "ffffffffffffffffffffffffffffffffffffffffffffffffffffffff" + } + + fn invalid_datum_hash() -> &'static str { + "not-a-datum-hash" + } + + fn missing_datum_hash() -> &'static str { + "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" + } + + async fn assert_status(app: &TestApp, path: &str, expected: StatusCode) { + let (status, bytes) = app.get_bytes(path).await; + assert_eq!( + status, + expected, + "unexpected status {status} with body: {}", + String::from_utf8_lossy(&bytes) + ); + } + + #[tokio::test] + async fn scripts_by_hash_happy_path() { + let app = fixture_app(); + let script_hash = app.vectors().script_hash.as_str(); + let path = format!("/scripts/{script_hash}"); + let (status, bytes) = app.get_bytes(&path).await; + + assert_eq!( + status, + StatusCode::OK, + "unexpected status {status} with body: {}", + String::from_utf8_lossy(&bytes) + ); + + let item: Script = serde_json::from_slice(&bytes).expect("failed to parse script"); + assert_eq!(item.script_hash, script_hash); + assert_eq!(item.r#type, ScriptType::Timelock); + assert_eq!(item.serialised_size, None); + } + + #[tokio::test] + async fn scripts_by_hash_not_found_for_invalid_hash() { + let app = fixture_app(); + let path = format!("/scripts/{}", invalid_script_hash()); + assert_status(&app, &path, StatusCode::NOT_FOUND).await; + } + + #[tokio::test] + async fn scripts_by_hash_not_found_for_missing_hash() { + let app = fixture_app(); + let path = format!("/scripts/{}", missing_script_hash()); + assert_status(&app, &path, StatusCode::NOT_FOUND).await; + } + + #[tokio::test] + async fn scripts_by_hash_internal_error() { + let app = TestApp::new_with_fault(Some(TestFault::ArchiveStoreError)); + let script_hash = app.vectors().script_hash.as_str(); + let path = format!("/scripts/{script_hash}"); + assert_status(&app, &path, StatusCode::INTERNAL_SERVER_ERROR).await; + } + + #[tokio::test] + async fn scripts_by_hash_json_happy_path() { + let app = fixture_app(); + let script_hash = app.vectors().script_hash.as_str(); + let path = format!("/scripts/{script_hash}/json"); + let (status, bytes) = app.get_bytes(&path).await; + + assert_eq!(status, StatusCode::OK); + + let item: ScriptJson = serde_json::from_slice(&bytes).expect("failed to parse script json"); + assert!(item.json.is_some()); + } + + #[tokio::test] + async fn scripts_by_hash_json_not_found_for_invalid_hash() { + let app = fixture_app(); + let path = format!("/scripts/{}/json", invalid_script_hash()); + assert_status(&app, &path, StatusCode::NOT_FOUND).await; + } + + #[tokio::test] + async fn scripts_by_hash_json_not_found_for_missing_hash() { + let app = fixture_app(); + let path = format!("/scripts/{}/json", missing_script_hash()); + assert_status(&app, &path, StatusCode::NOT_FOUND).await; + } + + #[tokio::test] + async fn scripts_by_hash_json_internal_error() { + let app = TestApp::new_with_fault(Some(TestFault::ArchiveStoreError)); + let script_hash = app.vectors().script_hash.as_str(); + let path = format!("/scripts/{script_hash}/json"); + assert_status(&app, &path, StatusCode::INTERNAL_SERVER_ERROR).await; + } + + #[tokio::test] + async fn scripts_by_hash_cbor_happy_path() { + let app = fixture_app(); + let script_hash = app.vectors().script_hash.as_str(); + let path = format!("/scripts/{script_hash}/cbor"); + let (status, bytes) = app.get_bytes(&path).await; + + assert_eq!(status, StatusCode::OK); + + let item: ScriptCbor = serde_json::from_slice(&bytes).expect("failed to parse script cbor"); + assert_eq!(item.cbor, None); + } + + #[tokio::test] + async fn scripts_by_hash_cbor_not_found_for_invalid_hash() { + let app = fixture_app(); + let path = format!("/scripts/{}/cbor", invalid_script_hash()); + assert_status(&app, &path, StatusCode::NOT_FOUND).await; + } + + #[tokio::test] + async fn scripts_by_hash_cbor_not_found_for_missing_hash() { + let app = fixture_app(); + let path = format!("/scripts/{}/cbor", missing_script_hash()); + assert_status(&app, &path, StatusCode::NOT_FOUND).await; + } + + #[tokio::test] + async fn scripts_by_hash_cbor_internal_error() { + let app = TestApp::new_with_fault(Some(TestFault::ArchiveStoreError)); + let script_hash = app.vectors().script_hash.as_str(); + let path = format!("/scripts/{script_hash}/cbor"); + assert_status(&app, &path, StatusCode::INTERNAL_SERVER_ERROR).await; + } + + #[tokio::test] + async fn scripts_by_datum_hash_happy_path() { + let app = fixture_app(); + let datum_hash = app.vectors().datum_hash.as_str(); + let path = format!("/scripts/datum/{datum_hash}"); + let (status, bytes) = app.get_bytes(&path).await; + + assert_eq!(status, StatusCode::OK); + + let item: ScriptDatum = + serde_json::from_slice(&bytes).expect("failed to parse script datum"); + assert_eq!(item.json_value.get("int"), Some(&serde_json::json!(42))); + } + + #[tokio::test] + async fn scripts_by_datum_hash_not_found_for_invalid_hash() { + let app = fixture_app(); + let path = format!("/scripts/datum/{}", invalid_datum_hash()); + assert_status(&app, &path, StatusCode::NOT_FOUND).await; + } + + #[tokio::test] + async fn scripts_by_datum_hash_not_found_for_missing_hash() { + let app = fixture_app(); + let path = format!("/scripts/datum/{}", missing_datum_hash()); + assert_status(&app, &path, StatusCode::NOT_FOUND).await; + } + + #[tokio::test] + async fn scripts_by_datum_hash_internal_error() { + let app = TestApp::new_with_fault(Some(TestFault::ArchiveStoreError)); + let datum_hash = app.vectors().datum_hash.as_str(); + let path = format!("/scripts/datum/{datum_hash}"); + assert_status(&app, &path, StatusCode::INTERNAL_SERVER_ERROR).await; + } + + #[tokio::test] + async fn scripts_by_datum_hash_cbor_happy_path() { + let app = fixture_app(); + let datum_hash = app.vectors().datum_hash.as_str(); + let path = format!("/scripts/datum/{datum_hash}/cbor"); + let (status, bytes) = app.get_bytes(&path).await; + + assert_eq!(status, StatusCode::OK); + + let item: ScriptDatumCbor = + serde_json::from_slice(&bytes).expect("failed to parse script datum cbor"); + assert_eq!(item.cbor, app.vectors().datum_cbor_hex); + } + + #[tokio::test] + async fn scripts_by_datum_hash_cbor_not_found_for_invalid_hash() { + let app = fixture_app(); + let path = format!("/scripts/datum/{}/cbor", invalid_datum_hash()); + assert_status(&app, &path, StatusCode::NOT_FOUND).await; + } + + #[tokio::test] + async fn scripts_by_datum_hash_cbor_not_found_for_missing_hash() { + let app = fixture_app(); + let path = format!("/scripts/datum/{}/cbor", missing_datum_hash()); + assert_status(&app, &path, StatusCode::NOT_FOUND).await; + } + + #[tokio::test] + async fn scripts_by_datum_hash_cbor_internal_error() { + let app = TestApp::new_with_fault(Some(TestFault::ArchiveStoreError)); + let datum_hash = app.vectors().datum_hash.as_str(); + let path = format!("/scripts/datum/{datum_hash}/cbor"); + assert_status(&app, &path, StatusCode::INTERNAL_SERVER_ERROR).await; + } +} diff --git a/crates/minibf/src/test_support.rs b/crates/minibf/src/test_support.rs index 2f323842..7d7ba10d 100644 --- a/crates/minibf/src/test_support.rs +++ b/crates/minibf/src/test_support.rs @@ -15,6 +15,7 @@ use dolos_testing::{ build_synthetic_blocks, seed_epoch_logs, seed_reward_logs, SyntheticBlockConfig, SyntheticVectors, }, + toy_domain::advance_epoch_state_to_slot, toy_domain::ToyDomain, }; use http_body_util::BodyExt; @@ -48,6 +49,21 @@ impl TestDomainBuilder { let (blocks, vectors, chain_config) = build_synthetic_blocks(cfg); let domain = ToyDomain::new_with_genesis_and_config(genesis, chain_config, None, None); + advance_epoch_state_to_slot(&domain, vectors.blocks[0].slot); + let summary = dolos_cardano::eras::load_era_summary::(domain.state()) + .expect("era summary"); + let (expected_epoch, _) = summary.slot_epoch(vectors.blocks[0].slot); + let current_epoch = + dolos_cardano::load_epoch::(domain.state()).expect("current epoch"); + assert_eq!( + current_epoch.number, expected_epoch, + "epoch preconditioning failed" + ); + assert_eq!( + current_epoch.rolling.epoch(), + Some(expected_epoch), + "rolling epoch preconditioning failed" + ); domain .import_blocks(blocks.clone()) .expect("failed to import synthetic blocks"); diff --git a/crates/minikupo/Cargo.toml b/crates/minikupo/Cargo.toml index 26dcc8b6..8c5c71cc 100644 --- a/crates/minikupo/Cargo.toml +++ b/crates/minikupo/Cargo.toml @@ -24,3 +24,8 @@ axum = { version = "0.8.4" } dolos-core = { path = "../core" } dolos-cardano = { path = "../cardano" } + +[dev-dependencies] +dolos-testing = { path = "../testing" } +tower = { workspace = true, features = ["util"] } +http-body-util = "0.1.2" diff --git a/crates/minikupo/src/lib.rs b/crates/minikupo/src/lib.rs index b1a657b3..0c15c54d 100644 --- a/crates/minikupo/src/lib.rs +++ b/crates/minikupo/src/lib.rs @@ -16,6 +16,8 @@ use crate::types::BadRequest; pub mod patterns; mod routes; +#[cfg(test)] +mod test_support; mod types; #[derive(Clone)] diff --git a/crates/minikupo/src/routes/datums.rs b/crates/minikupo/src/routes/datums.rs index 94f900d6..61c03f51 100644 --- a/crates/minikupo/src/routes/datums.rs +++ b/crates/minikupo/src/routes/datums.rs @@ -37,3 +37,69 @@ fn parse_datum_hash(value: &str) -> Result, StatusCode> { fn datum_hash_hint() -> String { "Invalid datum hash. Hash must be 64 lowercase hex characters.".to_string() } + +#[cfg(test)] +mod tests { + use axum::http::StatusCode; + + use crate::{ + test_support::{TestApp, TestFault}, + types::Datum, + }; + + fn missing_hash() -> &'static str { + "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" + } + + async fn assert_status(app: &TestApp, path: &str, expected: StatusCode) { + let (status, bytes) = app.get_bytes(path).await; + assert_eq!( + status, + expected, + "unexpected status {status} with body: {}", + String::from_utf8_lossy(&bytes) + ); + } + + #[tokio::test] + async fn datums_happy_path() { + let app = TestApp::new(); + let path = format!("/datums/{}", app.vectors().datum_hash); + let (status, bytes) = app.get_bytes(&path).await; + + assert_eq!( + status, + StatusCode::OK, + "unexpected status {status} with body: {}", + String::from_utf8_lossy(&bytes) + ); + + let datum: Datum = serde_json::from_slice(&bytes).expect("failed to parse datum response"); + assert_eq!(datum.datum, app.vectors().datum_cbor_hex); + } + + #[tokio::test] + async fn datums_missing_returns_null() { + let app = TestApp::new(); + let path = format!("/datums/{}", missing_hash()); + let (status, bytes) = app.get_bytes(&path).await; + + assert_eq!(status, StatusCode::OK); + let datum: Option = + serde_json::from_slice(&bytes).expect("failed to parse null datum response"); + assert_eq!(datum, None); + } + + #[tokio::test] + async fn datums_bad_request() { + let app = TestApp::new(); + assert_status(&app, "/datums/not-a-hash", StatusCode::BAD_REQUEST).await; + } + + #[tokio::test] + async fn datums_internal_error() { + let app = TestApp::new_with_fault(Some(TestFault::ArchiveStoreError)); + let path = format!("/datums/{}", app.vectors().datum_hash); + assert_status(&app, &path, StatusCode::INTERNAL_SERVER_ERROR).await; + } +} diff --git a/crates/minikupo/src/routes/health.rs b/crates/minikupo/src/routes/health.rs index 61bdb2d6..c056774a 100644 --- a/crates/minikupo/src/routes/health.rs +++ b/crates/minikupo/src/routes/health.rs @@ -74,3 +74,91 @@ pub async fn health(State(facade): State>, headers: HeaderM (StatusCode::OK, response_headers, Json(health)).into_response() } + +#[cfg(test)] +mod tests { + use axum::http::{header, StatusCode}; + + use crate::{ + test_support::{TestApp, TestFault}, + types::{ConnectionStatus, Health}, + }; + + async fn assert_status(app: &TestApp, path: &str, expected: StatusCode) { + let (status, _, bytes) = app.get_response(path).await; + assert_eq!( + status, + expected, + "unexpected status {status} with body: {}", + String::from_utf8_lossy(&bytes) + ); + } + + #[tokio::test] + async fn health_json_happy_path() { + let app = TestApp::new(); + let block = app.vectors().blocks.last().expect("missing block vectors"); + let (status, headers, bytes) = app.get_response("/health").await; + let expected_checkpoint = block.slot.to_string(); + + assert_eq!( + status, + StatusCode::OK, + "unexpected status {status} with body: {}", + String::from_utf8_lossy(&bytes) + ); + + let health: Health = serde_json::from_slice(&bytes).expect("failed to parse health"); + assert_eq!(health.connection_status, ConnectionStatus::Connected); + assert_eq!(health.most_recent_checkpoint, Some(block.slot)); + assert_eq!( + headers + .get("x-most-recent-checkpoint") + .and_then(|x| x.to_str().ok()), + Some(expected_checkpoint.as_str()) + ); + assert_eq!( + headers.get(header::ETAG).and_then(|x| x.to_str().ok()), + Some(block.block_hash.as_str()) + ); + } + + #[tokio::test] + async fn health_prometheus_happy_path() { + let app = TestApp::new(); + let (status, headers, bytes) = app + .get_response_with_accept("/health", Some("text/plain")) + .await; + + assert_eq!(status, StatusCode::OK); + assert_eq!( + headers + .get(header::CONTENT_TYPE) + .and_then(|x| x.to_str().ok()), + Some("text/plain;charset=utf-8") + ); + + let body = String::from_utf8(bytes).expect("health body should be utf8"); + assert!(body.contains("kupo_most_recent_checkpoint")); + assert!(body.contains("kupo_connection_status")); + } + + #[tokio::test] + async fn health_no_tip_happy_path() { + let app = TestApp::new_empty(); + let (status, headers, bytes) = app.get_response("/health").await; + + assert_eq!(status, StatusCode::OK); + + let health: Health = serde_json::from_slice(&bytes).expect("failed to parse health"); + assert_eq!(health.most_recent_checkpoint, None); + assert!(headers.get("x-most-recent-checkpoint").is_none()); + assert!(headers.get(header::ETAG).is_none()); + } + + #[tokio::test] + async fn health_internal_error() { + let app = TestApp::new_with_fault(Some(TestFault::ArchiveStoreError)); + assert_status(&app, "/health", StatusCode::INTERNAL_SERVER_ERROR).await; + } +} diff --git a/crates/minikupo/src/routes/matches.rs b/crates/minikupo/src/routes/matches.rs index 535b33e4..c18b3414 100644 --- a/crates/minikupo/src/routes/matches.rs +++ b/crates/minikupo/src/routes/matches.rs @@ -763,3 +763,308 @@ fn slot_range_hint() -> String { fn spent_hint() -> String { "Invalid or unsupported filter query parameters! 'spent', 'spent_after' and 'spent_before' are not supported. Only unspent results are available. In case of doubts, check the documentation at: !".to_string() } + +#[cfg(test)] +mod tests { + use std::collections::HashMap; + + use axum::http::StatusCode; + use serde::Deserialize; + use serde_json::{json, Value}; + + use crate::test_support::{TestApp, TestFault}; + + #[derive(Debug, Deserialize)] + struct MatchResponseTest { + transaction_index: u32, + transaction_id: String, + output_index: u32, + address: String, + value: ValueResponseTest, + datum_hash: Option, + datum_type: Option, + script_hash: Option, + created_at: PointResponseTest, + } + + #[derive(Debug, Deserialize)] + struct ValueResponseTest { + coins: u64, + assets: HashMap, + } + + #[derive(Debug, Deserialize)] + struct PointResponseTest { + slot_no: u64, + header_hash: String, + } + + async fn assert_status(app: &TestApp, path: &str, expected: StatusCode) { + let (status, bytes) = app.get_bytes(path).await; + assert_eq!( + status, + expected, + "unexpected status {status} with body: {}", + String::from_utf8_lossy(&bytes) + ); + } + + #[tokio::test] + async fn matches_address_happy_path() { + let app = TestApp::new(); + let path = format!("/matches/{}", app.vectors().address); + let (status, bytes) = app.get_bytes(&path).await; + + assert_eq!( + status, + StatusCode::OK, + "unexpected status {status} with body: {}", + String::from_utf8_lossy(&bytes) + ); + + let items: Vec = + serde_json::from_slice(&bytes).expect("failed to parse matches response"); + assert_eq!(items.len(), app.vectors().blocks.len()); + assert!(items + .iter() + .all(|item| item.address == app.vectors().address)); + assert!(items.iter().all(|item| item.transaction_index == 0)); + assert!(items.iter().all(|item| item.value.coins > 0)); + assert!(items + .iter() + .all(|item| item.datum_hash.as_deref() == Some(app.vectors().datum_hash.as_str()))); + assert!(items + .iter() + .all(|item| item.datum_type.as_deref() == Some("hash"))); + assert!(items + .iter() + .all(|item| item.script_hash.as_deref() == Some(app.vectors().script_hash.as_str()))); + + let actual_slots: Vec<_> = items.iter().map(|item| item.created_at.slot_no).collect(); + let expected_slots: Vec<_> = app + .vectors() + .blocks + .iter() + .rev() + .map(|block| block.slot) + .collect(); + assert_eq!(actual_slots, expected_slots); + } + + #[tokio::test] + async fn matches_asset_pattern_happy_path() { + let app = TestApp::new(); + let asset_pattern = format!( + "{}.{}", + app.vectors().policy_id, + app.vectors().asset_name_hex + ); + let asset_key = asset_pattern.clone(); + let path = format!("/matches/{asset_pattern}"); + let (status, bytes) = app.get_bytes(&path).await; + + assert_eq!(status, StatusCode::OK); + + let items: Vec = + serde_json::from_slice(&bytes).expect("failed to parse asset matches"); + assert!(!items.is_empty()); + assert!(items + .iter() + .all(|item| item.value.assets.contains_key(&asset_key))); + } + + #[tokio::test] + async fn matches_output_ref_happy_path() { + let app = TestApp::new(); + let path = format!("/matches/0@{}", app.vectors().tx_hash); + let (status, bytes) = app.get_bytes(&path).await; + + assert_eq!(status, StatusCode::OK); + + let items: Vec = + serde_json::from_slice(&bytes).expect("failed to parse output ref matches"); + assert_eq!(items.len(), 1); + assert_eq!(items[0].transaction_id, app.vectors().tx_hash); + assert_eq!(items[0].output_index, 0); + } + + #[tokio::test] + async fn matches_resolve_hashes_happy_path() { + let app = TestApp::new(); + let path = format!("/matches/{}?resolve_hashes", app.vectors().address); + let (status, bytes) = app.get_bytes(&path).await; + + assert_eq!(status, StatusCode::OK); + + let items: Vec = + serde_json::from_slice(&bytes).expect("failed to parse resolved matches"); + assert_eq!(items.len(), app.vectors().blocks.len()); + + for item in items { + assert_eq!( + item.get("datum_hash"), + Some(&json!(app.vectors().datum_hash)) + ); + assert_eq!(item.get("datum_type"), Some(&json!("hash"))); + assert_eq!( + item.get("datum"), + Some(&json!(app.vectors().datum_cbor_hex)) + ); + assert_eq!( + item.get("script_hash"), + Some(&json!(app.vectors().script_hash)) + ); + assert_eq!( + item.get("script"), + Some(&json!({ + "language": "native", + "script": app.vectors().script_cbor_hex, + })) + ); + } + } + + #[tokio::test] + async fn matches_order_oldest_first() { + let app = TestApp::new(); + let path = format!("/matches/{}?order=oldest_first", app.vectors().address); + let (status, bytes) = app.get_bytes(&path).await; + + assert_eq!(status, StatusCode::OK); + + let items: Vec = + serde_json::from_slice(&bytes).expect("failed to parse ordered matches"); + let slots: Vec<_> = items.iter().map(|item| item.created_at.slot_no).collect(); + assert!(slots.windows(2).all(|window| window[0] <= window[1])); + } + + #[tokio::test] + async fn matches_created_bounds_with_point() { + let app = TestApp::new(); + let block = app + .vectors() + .blocks + .get(2) + .expect("missing bounded block vector"); + let point = format!("{}.{}", block.slot, block.block_hash); + let path = format!( + "/matches/{}?created_after={point}&created_before={point}", + app.vectors().address + ); + let (status, bytes) = app.get_bytes(&path).await; + + assert_eq!(status, StatusCode::OK); + + let items: Vec = + serde_json::from_slice(&bytes).expect("failed to parse bounded matches"); + assert_eq!(items.len(), 1); + assert_eq!(items[0].created_at.slot_no, block.slot); + assert_eq!(items[0].created_at.header_hash, block.block_hash); + } + + #[tokio::test] + async fn matches_policy_and_asset_filters() { + let app = TestApp::new(); + let asset_key = format!( + "{}.{}", + app.vectors().policy_id, + app.vectors().asset_name_hex + ); + let path = format!( + "/matches/{}?policy_id={}&asset_name={}", + app.vectors().address, + app.vectors().policy_id, + app.vectors().asset_name_hex, + ); + let (status, bytes) = app.get_bytes(&path).await; + + assert_eq!(status, StatusCode::OK); + + let items: Vec = + serde_json::from_slice(&bytes).expect("failed to parse filtered matches"); + assert_eq!(items.len(), app.vectors().blocks.len()); + assert!(items + .iter() + .all(|item| item.value.assets.contains_key(&asset_key))); + } + + #[tokio::test] + async fn matches_transaction_id_and_output_index_filters() { + let app = TestApp::new(); + let path = format!( + "/matches/{}?transaction_id={}&output_index=0", + app.vectors().address, + app.vectors().tx_hash, + ); + let (status, bytes) = app.get_bytes(&path).await; + + assert_eq!(status, StatusCode::OK); + + let items: Vec = + serde_json::from_slice(&bytes).expect("failed to parse tx/output filtered matches"); + assert_eq!(items.len(), 1); + assert_eq!(items[0].transaction_id, app.vectors().tx_hash); + assert_eq!(items[0].output_index, 0); + } + + #[tokio::test] + async fn matches_wildcard_pattern_is_rejected() { + let app = TestApp::new(); + assert_status(&app, "/matches/*", StatusCode::BAD_REQUEST).await; + } + + #[tokio::test] + async fn matches_metadata_tag_pattern_is_rejected() { + let app = TestApp::new(); + assert_status(&app, "/matches/%7B42%7D", StatusCode::BAD_REQUEST).await; + } + + #[tokio::test] + async fn matches_spent_filters_are_rejected() { + let app = TestApp::new(); + let path = format!("/matches/{}?spent_after=1", app.vectors().address); + assert_status(&app, &path, StatusCode::BAD_REQUEST).await; + } + + #[tokio::test] + async fn matches_bad_order_is_rejected() { + let app = TestApp::new(); + let path = format!("/matches/{}?order=sideways", app.vectors().address); + assert_status(&app, &path, StatusCode::BAD_REQUEST).await; + } + + #[tokio::test] + async fn matches_asset_name_without_policy_id_is_rejected() { + let app = TestApp::new(); + let path = format!( + "/matches/{}?asset_name={}", + app.vectors().address, + app.vectors().asset_name_hex, + ); + assert_status(&app, &path, StatusCode::BAD_REQUEST).await; + } + + #[tokio::test] + async fn matches_output_index_without_transaction_id_is_rejected() { + let app = TestApp::new(); + let path = format!("/matches/{}?output_index=0", app.vectors().address); + assert_status(&app, &path, StatusCode::BAD_REQUEST).await; + } + + #[tokio::test] + async fn matches_bad_created_point_is_rejected() { + let app = TestApp::new(); + let path = format!( + "/matches/{}?created_after=1.not-a-hash", + app.vectors().address + ); + assert_status(&app, &path, StatusCode::BAD_REQUEST).await; + } + + #[tokio::test] + async fn matches_internal_error() { + let app = TestApp::new_with_fault(Some(TestFault::IndexStoreError)); + let path = format!("/matches/{}", app.vectors().address); + assert_status(&app, &path, StatusCode::INTERNAL_SERVER_ERROR).await; + } +} diff --git a/crates/minikupo/src/routes/metadata.rs b/crates/minikupo/src/routes/metadata.rs index 5a5a509c..8b0831a2 100644 --- a/crates/minikupo/src/routes/metadata.rs +++ b/crates/minikupo/src/routes/metadata.rs @@ -161,3 +161,105 @@ fn metadatum_to_model(datum: &alonzo::Metadatum) -> Result String { "Invalid or incomplete filter query parameters! 'transaction_id' query value must be encoded in base16. In case of doubts, check the documentation at: !".to_string() } + +#[cfg(test)] +mod tests { + use axum::http::StatusCode; + + use crate::{ + test_support::{TestApp, TestFault}, + types::Metadata, + }; + + async fn assert_status(app: &TestApp, path: &str, expected: StatusCode) { + let (status, _, bytes) = app.get_response(path).await; + assert_eq!( + status, + expected, + "unexpected status {status} with body: {}", + String::from_utf8_lossy(&bytes) + ); + } + + #[tokio::test] + async fn metadata_happy_path() { + let app = TestApp::new(); + let block = app.vectors().blocks.first().expect("missing block vectors"); + let path = format!("/metadata/{}", block.slot); + let (status, headers, bytes) = app.get_response(&path).await; + + assert_eq!( + status, + StatusCode::OK, + "unexpected status {status} with body: {}", + String::from_utf8_lossy(&bytes) + ); + assert_eq!( + headers + .get("x-block-header-hash") + .and_then(|x| x.to_str().ok()), + Some(block.block_hash.as_str()) + ); + + let items: Vec = + serde_json::from_slice(&bytes).expect("failed to parse metadata response"); + assert_eq!(items.len(), 1); + assert!(hex::decode(&items[0].raw).is_ok()); + assert!(items[0].schema.contains_key(&app.vectors().metadata_label)); + } + + #[tokio::test] + async fn metadata_transaction_id_filter_happy_path() { + let app = TestApp::new(); + let block = app.vectors().blocks.first().expect("missing block vectors"); + let tx_hash = block.tx_hashes.first().expect("missing tx hash"); + let path = format!("/metadata/{}?transaction_id={tx_hash}", block.slot); + let (status, _, bytes) = app.get_response(&path).await; + + assert_eq!(status, StatusCode::OK); + + let items: Vec = + serde_json::from_slice(&bytes).expect("failed to parse filtered metadata"); + assert_eq!(items.len(), 1); + } + + #[tokio::test] + async fn metadata_transaction_id_filter_empty() { + let app = TestApp::new(); + let block = app.vectors().blocks.first().expect("missing block vectors"); + let tx_hash = block + .tx_hashes + .get(1) + .expect("missing tx hash without metadata"); + let path = format!("/metadata/{}?transaction_id={tx_hash}", block.slot); + let (status, _, bytes) = app.get_response(&path).await; + + assert_eq!(status, StatusCode::OK); + + let items: Vec = + serde_json::from_slice(&bytes).expect("failed to parse empty metadata response"); + assert!(items.is_empty()); + } + + #[tokio::test] + async fn metadata_bad_request_invalid_transaction_id() { + let app = TestApp::new(); + let block = app.vectors().blocks.first().expect("missing block vectors"); + let path = format!("/metadata/{}?transaction_id=not-a-hash", block.slot); + assert_status(&app, &path, StatusCode::BAD_REQUEST).await; + } + + #[tokio::test] + async fn metadata_not_found() { + let app = TestApp::new(); + assert_status(&app, "/metadata/999999999", StatusCode::NOT_FOUND).await; + } + + #[tokio::test] + async fn metadata_internal_error() { + let app = TestApp::new_with_fault(Some(TestFault::ArchiveStoreError)); + let block = app.vectors().blocks.first().expect("missing block vectors"); + let path = format!("/metadata/{}", block.slot); + assert_status(&app, &path, StatusCode::INTERNAL_SERVER_ERROR).await; + } +} diff --git a/crates/minikupo/src/routes/scripts.rs b/crates/minikupo/src/routes/scripts.rs index f2341954..3428356e 100644 --- a/crates/minikupo/src/routes/scripts.rs +++ b/crates/minikupo/src/routes/scripts.rs @@ -39,3 +39,71 @@ fn parse_script_hash(value: &str) -> Result, StatusCode> { fn script_hash_hint() -> String { "Invalid script hash. Hash must be 56 lowercase hex characters.".to_string() } + +#[cfg(test)] +mod tests { + use axum::http::StatusCode; + + use crate::{ + test_support::{TestApp, TestFault}, + types::{Script, ScriptLanguage}, + }; + + fn missing_hash() -> &'static str { + "ffffffffffffffffffffffffffffffffffffffffffffffffffffffff" + } + + async fn assert_status(app: &TestApp, path: &str, expected: StatusCode) { + let (status, bytes) = app.get_bytes(path).await; + assert_eq!( + status, + expected, + "unexpected status {status} with body: {}", + String::from_utf8_lossy(&bytes) + ); + } + + #[tokio::test] + async fn scripts_happy_path() { + let app = TestApp::new(); + let path = format!("/scripts/{}", app.vectors().script_hash); + let (status, bytes) = app.get_bytes(&path).await; + + assert_eq!( + status, + StatusCode::OK, + "unexpected status {status} with body: {}", + String::from_utf8_lossy(&bytes) + ); + + let script: Script = + serde_json::from_slice(&bytes).expect("failed to parse script response"); + assert_eq!(script.language, ScriptLanguage::Native); + assert_eq!(script.script, app.vectors().script_cbor_hex); + } + + #[tokio::test] + async fn scripts_missing_returns_null() { + let app = TestApp::new(); + let path = format!("/scripts/{}", missing_hash()); + let (status, bytes) = app.get_bytes(&path).await; + + assert_eq!(status, StatusCode::OK); + let script: Option