diff --git a/packages/core/Cargo.toml b/packages/core/Cargo.toml index 13e95f6..4694f83 100644 --- a/packages/core/Cargo.toml +++ b/packages/core/Cargo.toml @@ -22,3 +22,4 @@ toml = "0.8" [dev-dependencies] httpmock = "0.7" serde_json = "1" +wiremock = "0.6" diff --git a/packages/core/src/explain/transaction.rs b/packages/core/src/explain/transaction.rs index 50a618c..c89586c 100644 --- a/packages/core/src/explain/transaction.rs +++ b/packages/core/src/explain/transaction.rs @@ -4,11 +4,15 @@ use serde::{Deserialize, Serialize}; +use crate::explain::memo::explain_memo; use crate::models::fee::FeeStats; use crate::models::transaction::Transaction; -use crate::explain::memo::explain_memo; -use super::operation::payment::{explain_payment, PaymentExplanation}; +use super::operation::payment::{ + explain_payment, + explain_payment_with_fee, + PaymentExplanation, +}; /// Complete explanation of a transaction. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] @@ -21,6 +25,8 @@ pub struct TransactionExplanation { /// Human-readable explanation of the transaction memo. /// None if the transaction has no memo. pub memo_explanation: Option, + /// Human-readable explanation of transaction fee context. + pub fee_explanation: Option, } /// Result type for transaction explanation. @@ -72,7 +78,6 @@ pub fn explain_transaction( transaction: &Transaction, fee_stats: Option<&FeeStats>, ) -> ExplainResult { -pub fn explain_transaction(transaction: &Transaction) -> ExplainResult { let total_operations = transaction.operations.len(); if total_operations == 0 { @@ -85,21 +90,15 @@ pub fn explain_transaction(transaction: &Transaction) -> ExplainResult { let payment_explanations = transaction .payment_operations() .into_iter() - .map(|payment| explain_payment(payment)) + .map(|payment| match fee_stats { + Some(stats) => explain_payment_with_fee(payment, transaction.fee_charged, stats), + None => explain_payment(payment), + }) .collect::>(); - let summary = build_transaction_summary( - transaction.successful, - payment_count, - skipped_operations, - ); - - let memo_explanation = transaction.memo.as_ref().and_then(|m| explain_memo(m)); - - let fee_explanation = explain_fee(transaction.fee_charged, fee_stats); - - // Wire in memo explanation — None if transaction has no memo - let memo_explanation = transaction.memo.as_ref().and_then(|m| explain_memo(m)); + let summary = build_transaction_summary(transaction.successful, payment_count, skipped_operations); + let memo_explanation = transaction.memo.as_ref().and_then(explain_memo); + let fee_explanation = Some(explain_fee(transaction.fee_charged, fee_stats)); Ok(TransactionExplanation { transaction_hash: transaction.hash.clone(), @@ -146,14 +145,13 @@ fn build_transaction_summary(successful: bool, payment_count: usize, skipped: us #[cfg(test)] mod tests { use super::*; - use crate::models::fee::FeeStats; use crate::models::memo::Memo; - use crate::models::operation::{OtherOperation, PaymentOperation, Operation}; + use crate::models::operation::{Operation, OtherOperation, PaymentOperation}; fn create_payment_operation(id: &str, amount: &str) -> Operation { Operation::Payment(PaymentOperation { id: id.to_string(), - source_account: None, + source_account: Some("GSENDER".to_string()), destination: "GDESTXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX".to_string(), asset_type: "native".to_string(), asset_code: None, @@ -169,10 +167,6 @@ mod tests { }) } - fn default_fee_stats() -> FeeStats { - FeeStats::default_network_fees() - } - #[test] fn test_explain_fee_standard() { let stats = FeeStats::new(100, 100, 5000, 100, 250); @@ -190,40 +184,7 @@ mod tests { } #[test] - fn test_explain_fee_no_stats_fallback() { - let result = explain_fee(100, None); - assert!(result.contains("0.0000100")); - // No context — just the raw amount - assert!(!result.contains("standard")); - assert!(!result.contains("above average")); - } - - #[test] - fn test_explain_transaction_includes_fee_explanation() { - fn test_explain_single_payment_no_memo() { - let tx = Transaction { - hash: "abc123".to_string(), - successful: true, - fee_charged: 100, - operations: vec![create_payment_operation("1", "50.0")], - memo: None, - }; - let stats = default_fee_stats(); - let explanation = explain_transaction(&tx, Some(&stats)).unwrap(); - assert!(!explanation.fee_explanation.is_empty()); - assert!(explanation.fee_explanation.contains("standard")); - - let explanation = explain_transaction(&tx).unwrap(); - assert_eq!(explanation.transaction_hash, "abc123"); - assert!(explanation.successful); - assert_eq!(explanation.payment_explanations.len(), 1); - assert_eq!(explanation.skipped_operations, 0); - assert!(explanation.summary.contains("1 payment")); - assert_eq!(explanation.memo_explanation, None); - } - - #[test] - fn test_explain_transaction_with_text_memo() { + fn test_explain_transaction_with_memo() { let tx = Transaction { hash: "abc123".to_string(), successful: true, @@ -232,123 +193,33 @@ mod tests { memo: Some(Memo::text("Invoice #12345").unwrap()), }; - let explanation = explain_transaction(&tx).unwrap(); - assert!(explanation.memo_explanation.is_some()); - let memo_text = explanation.memo_explanation.unwrap(); - assert!(memo_text.contains("Invoice #12345")); - assert!(memo_text.contains("text memo")); - } - - #[test] - fn test_explain_transaction_with_id_memo() { - let tx = Transaction { - hash: "abc123".to_string(), - successful: true, - fee_charged: 100, - operations: vec![create_payment_operation("1", "50.0")], - memo: Some(Memo::id(987654321)), - }; - - let explanation = explain_transaction(&tx).unwrap(); + let explanation = explain_transaction(&tx, None).unwrap(); + assert_eq!(explanation.transaction_hash, "abc123"); assert!(explanation.memo_explanation.is_some()); - let memo_text = explanation.memo_explanation.unwrap(); - assert!(memo_text.contains("987654321")); - assert!(memo_text.contains("ID memo")); - } - - #[test] - fn test_explain_transaction_memo_none_variant() { - let tx = Transaction { - hash: "abc123".to_string(), - successful: true, - fee_charged: 100, - operations: vec![create_payment_operation("1", "50.0")], - memo: Some(Memo::None), - }; - - let explanation = explain_transaction(&tx).unwrap(); - // Memo::None should produce no explanation - assert_eq!(explanation.memo_explanation, None); - } - - #[test] - fn test_explain_transaction_high_fee() { - let tx = Transaction { - hash: "abc123".to_string(), - successful: true, - fee_charged: 10000, - operations: vec![create_payment_operation("1", "50.0")], - memo: None, - }; - let stats = default_fee_stats(); - let explanation = explain_transaction(&tx, Some(&stats)).unwrap(); - assert!(explanation.fee_explanation.contains("above average")); - } - - #[test] - fn test_explain_transaction_fee_stats_fallback() { - - let explanation = explain_transaction(&tx).unwrap(); - assert_eq!(explanation.payment_explanations.len(), 3); - assert_eq!(explanation.skipped_operations, 0); - assert!(explanation.summary.contains("3 payments")); - assert_eq!(explanation.memo_explanation, None); + assert!(explanation + .memo_explanation + .unwrap() + .contains("Invoice #12345")); + assert!(explanation.fee_explanation.is_some()); } #[test] fn test_explain_no_payments_returns_ok() { let tx = Transaction { - hash: "abc123".to_string(), + hash: "ghi789".to_string(), successful: true, fee_charged: 100, - operations: vec![create_payment_operation("1", "50.0")], + operations: vec![create_other_operation("1"), create_other_operation("2")], memo: None, }; - // No fee stats available — should not panic, should produce basic message - let explanation = explain_transaction(&tx, None).unwrap(); - assert!(!explanation.fee_explanation.is_empty()); - } - - #[test] - fn test_explain_single_payment_no_memo() { - // Non-payment transactions should return Ok with empty payment_explanations - let result = explain_transaction(&tx); + let result = explain_transaction(&tx, None); assert!(result.is_ok()); let explanation = result.unwrap(); assert_eq!(explanation.payment_explanations.len(), 0); assert_eq!(explanation.skipped_operations, 2); } - #[test] - fn test_explain_empty_transaction_returns_err() { - let tx = Transaction { - hash: "abc123".to_string(), - successful: true, - fee_charged: 100, - operations: vec![create_payment_operation("1", "50.0")], - memo: None, - }; - let explanation = explain_transaction(&tx, None).unwrap(); - assert_eq!(explanation.transaction_hash, "abc123"); - assert_eq!(explanation.payment_explanations.len(), 1); - assert_eq!(explanation.memo_explanation, None); - } - - #[test] - fn test_explain_transaction_with_text_memo() { - let tx = Transaction { - hash: "abc123".to_string(), - successful: true, - fee_charged: 100, - operations: vec![create_payment_operation("1", "50.0")], - memo: Some(Memo::text("Invoice #12345").unwrap()), - }; - let explanation = explain_transaction(&tx, None).unwrap(); - assert!(explanation.memo_explanation.is_some()); - assert!(explanation.memo_explanation.unwrap().contains("Invoice #12345")); - } - #[test] fn test_explain_empty_transaction_returns_err() { let tx = Transaction { @@ -358,63 +229,8 @@ mod tests { operations: vec![], memo: None, }; - assert!(explain_transaction(&tx, None).is_err()); - - let explanation = explain_transaction(&tx).unwrap(); - assert_eq!(explanation.payment_explanations.len(), 2); - assert_eq!(explanation.skipped_operations, 3); - assert!(explanation.summary.contains("2 payments")); - assert!(explanation.summary.contains("3 other operations were skipped")); - assert_eq!(explanation.memo_explanation, None); - } - - #[test] - fn test_explain_no_payments_returns_ok() { - let tx = Transaction { - hash: "ghi789".to_string(), - successful: true, - fee_charged: 100, - operations: vec![create_other_operation("1"), create_other_operation("2")], - memo: None, - }; - let result = explain_transaction(&tx, None); - assert!(result.is_ok()); - let explanation = result.unwrap(); - assert_eq!(explanation.payment_explanations.len(), 0); - assert_eq!(explanation.skipped_operations, 2); - - let explanation = explain_transaction(&tx).unwrap(); - assert!(!explanation.successful); - assert!(explanation.summary.contains("failed")); - assert_eq!(explanation.memo_explanation, None); - } - - #[test] - fn test_build_transaction_summary_single_payment() { - let summary = build_transaction_summary(true, 1, 0); - assert_eq!(summary, "This successful transaction contains 1 payment."); - } - - #[test] - fn test_build_transaction_summary_multiple_payments() { - let summary = build_transaction_summary(true, 3, 0); - assert_eq!(summary, "This successful transaction contains 3 payments."); - } - - #[test] - fn test_build_transaction_summary_with_skipped() { - let summary = build_transaction_summary(true, 2, 3); - assert_eq!( - summary, - "This successful transaction contains 2 payments. 3 other operations were skipped." - ); - } - #[test] - fn test_build_transaction_summary_no_payments() { - let summary = build_transaction_summary(true, 0, 2); - assert!(summary.contains("does not yet support")); - assert!(summary.contains("2 operations")); + assert!(explain_transaction(&tx, None).is_err()); } #[test] @@ -422,4 +238,4 @@ mod tests { let summary = build_transaction_summary(false, 1, 0); assert_eq!(summary, "This failed transaction contains 1 payment."); } -} \ No newline at end of file +} diff --git a/packages/core/src/lib.rs b/packages/core/src/lib.rs index ac2ebfd..7db159c 100644 --- a/packages/core/src/lib.rs +++ b/packages/core/src/lib.rs @@ -4,5 +4,7 @@ pub mod errors; pub mod explain; +pub mod middleware; pub mod models; -pub mod services; \ No newline at end of file +pub mod routes; +pub mod services; diff --git a/packages/core/src/models/operation.rs b/packages/core/src/models/operation.rs index 08f3ccc..121af62 100644 --- a/packages/core/src/models/operation.rs +++ b/packages/core/src/models/operation.rs @@ -4,10 +4,9 @@ use serde::{Deserialize, Serialize}; +use crate::services::horizon::{HorizonOperation, HorizonPathAsset}; + /// Represents a Stellar operation. -/// -/// For v1, we support Payment, ChangeTrust, and CreateAccount operations. -/// Other operation types are preserved but not explained. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(tag = "type", rename_all = "snake_case")] pub enum Operation { @@ -68,6 +67,8 @@ pub struct PathPaymentOperation { pub dest_amount: String, pub path: Vec, pub payment_type: PathPaymentType, +} + /// A create_account operation that funds and activates a new Stellar account. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct CreateAccountOperation { @@ -78,8 +79,6 @@ pub struct CreateAccountOperation { } /// A change_trust operation that opts an account in or out of holding a non-native asset. -/// -/// Setting limit to "0" removes the trust line; any other value adds or updates it. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct ChangeTrustOperation { pub id: String, @@ -89,9 +88,7 @@ pub struct ChangeTrustOperation { pub limit: String, } -/// Placeholder for non-payment operations. -/// -/// These are preserved in the transaction model but not explained in v1. +/// Placeholder for non-supported operation types. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct OtherOperation { pub id: String, @@ -129,49 +126,6 @@ impl Operation { } } -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_is_payment() { - let payment = Operation::Payment(PaymentOperation { - id: "12345".to_string(), - source_account: None, - destination: "GDEST...".to_string(), - asset_type: "native".to_string(), - asset_code: None, - asset_issuer: None, - amount: "100.0".to_string(), - }); - - let other = Operation::Other(OtherOperation { - id: "67890".to_string(), - operation_type: "create_account".to_string(), - }); - - assert!(payment.is_payment()); - assert!(!other.is_payment()); - } - - #[test] - fn test_operation_id() { - let payment = Operation::Payment(PaymentOperation { - id: "12345".to_string(), - source_account: None, - destination: "GDEST...".to_string(), - asset_type: "native".to_string(), - asset_code: None, - asset_issuer: None, - amount: "100.0".to_string(), - }); - - assert_eq!(payment.id(), "12345"); - } -} - -use crate::services::horizon::{HorizonOperation, HorizonPathAsset}; - fn format_asset( asset_type: Option, asset_code: Option, @@ -198,16 +152,18 @@ fn format_offer_asset( fn format_path(path: Option>) -> Vec { path.unwrap_or_default() .into_iter() - .map(|a| format_asset(Some(a.asset_type), a.asset_code, a.asset_issuer)) + .map(|asset| format_asset(Some(asset.asset_type), asset.asset_code, asset.asset_issuer)) .collect() } impl From for Operation { fn from(op: HorizonOperation) -> Self { - match op.type_i.as_str() { + let op_type = op.type_i.clone(); + + match op_type.as_str() { "payment" => Operation::Payment(PaymentOperation { id: op.id, - source_account: op.from, + source_account: op.from.or(op.source_account), destination: op.to.unwrap_or_default(), asset_type: op.asset_type.unwrap_or_else(|| "native".to_string()), asset_code: op.asset_code, @@ -250,22 +206,18 @@ impl From for Operation { offer_id: op.offer_id.unwrap_or(0), offer_type: OfferType::Buy, }), - "path_payment_strict_send" => { - let path = format_path(op.path); - Operation::PathPaymentStrictSend(PathPaymentOperation { - id: op.id, - source_account: op.from.or(op.source_account), - destination: op.to.unwrap_or_default(), - send_asset: format_asset(op.source_asset_type, op.source_asset_code, op.source_asset_issuer), - send_amount: op.source_amount.unwrap_or_else(|| "0".to_string()), - dest_asset: format_asset(op.asset_type, op.asset_code, op.asset_issuer), - dest_amount: op.amount.unwrap_or_else(|| "0".to_string()), - path, - payment_type: PathPaymentType::StrictSend, - }) - } + "path_payment_strict_send" => Operation::PathPaymentStrictSend(PathPaymentOperation { + id: op.id, + source_account: op.from.or(op.source_account), + destination: op.to.unwrap_or_default(), + send_asset: format_asset(op.source_asset_type, op.source_asset_code, op.source_asset_issuer), + send_amount: op.source_amount.unwrap_or_else(|| "0".to_string()), + dest_asset: format_asset(op.asset_type, op.asset_code, op.asset_issuer), + dest_amount: op.amount.unwrap_or_else(|| "0".to_string()), + path: format_path(op.path), + payment_type: PathPaymentType::StrictSend, + }), "path_payment_strict_receive" => { - let path = format_path(op.path); Operation::PathPaymentStrictReceive(PathPaymentOperation { id: op.id, source_account: op.from.or(op.source_account), @@ -274,32 +226,68 @@ impl From for Operation { send_amount: op.source_amount.unwrap_or_else(|| "0".to_string()), dest_asset: format_asset(op.asset_type, op.asset_code, op.asset_issuer), dest_amount: op.amount.unwrap_or_else(|| "0".to_string()), - path, + path: format_path(op.path), payment_type: PathPaymentType::StrictReceive, }) } - _ => Operation::Other(OtherOperation { - }) - } else if op.type_i == "change_trust" { - Operation::ChangeTrust(ChangeTrustOperation { + "change_trust" => Operation::ChangeTrust(ChangeTrustOperation { id: op.id, trustor: op.trustor.unwrap_or_default(), asset_code: op.asset_code.unwrap_or_default(), asset_issuer: op.asset_issuer.unwrap_or_default(), limit: op.limit.unwrap_or_else(|| "0".to_string()), - }) - } else if op.type_i == "create_account" { - Operation::CreateAccount(CreateAccountOperation { + }), + "create_account" => Operation::CreateAccount(CreateAccountOperation { id: op.id, funder: op.funder.unwrap_or_default(), new_account: op.account.unwrap_or_default(), starting_balance: op.starting_balance.unwrap_or_else(|| "0".to_string()), - }) - } else { - Operation::Other(OtherOperation { + }), + _ => Operation::Other(OtherOperation { id: op.id, - operation_type: op.type_i, + operation_type: op_type, }), } } -} \ No newline at end of file +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_is_payment() { + let payment = Operation::Payment(PaymentOperation { + id: "12345".to_string(), + source_account: None, + destination: "GDEST...".to_string(), + asset_type: "native".to_string(), + asset_code: None, + asset_issuer: None, + amount: "100.0".to_string(), + }); + + let other = Operation::Other(OtherOperation { + id: "67890".to_string(), + operation_type: "create_account".to_string(), + }); + + assert!(payment.is_payment()); + assert!(!other.is_payment()); + } + + #[test] + fn test_operation_id() { + let payment = Operation::Payment(PaymentOperation { + id: "12345".to_string(), + source_account: None, + destination: "GDEST...".to_string(), + asset_type: "native".to_string(), + asset_code: None, + asset_issuer: None, + amount: "100.0".to_string(), + }); + + assert_eq!(payment.id(), "12345"); + } +} diff --git a/packages/core/src/routes/tx.rs b/packages/core/src/routes/tx.rs index ff1bab1..ac1e70d 100644 --- a/packages/core/src/routes/tx.rs +++ b/packages/core/src/routes/tx.rs @@ -31,6 +31,7 @@ pub struct TxExplanationResponse { ), responses( (status = 200, description = "Transaction explanation", body = TxExplanationResponse), + (status = 400, description = "Invalid transaction hash"), (status = 404, description = "Transaction not found"), (status = 500, description = "Internal server error") ) @@ -54,6 +55,22 @@ pub async fn get_tx_explanation( "incoming_request" ); + if !is_valid_transaction_hash(&hash) { + let app_error = AppError::BadRequest( + "Invalid transaction hash format. Expected 64-character hexadecimal hash." + .to_string(), + ); + info!( + request_id = %request_id, + hash = %hash, + status = app_error.status_code().as_u16(), + total_duration_ms = request_started_at.elapsed().as_millis() as u64, + error = ?app_error, + "request_completed" + ); + return Err(app_error); + } + // Fetch transaction, operations, and fee stats in parallel let horizon_started_at = Instant::now(); let tx_future = horizon_client.fetch_transaction(&hash); @@ -139,3 +156,7 @@ pub async fn get_tx_explanation( Ok(Json(explanation)) } + +fn is_valid_transaction_hash(hash: &str) -> bool { + hash.len() == 64 && hash.chars().all(|c| c.is_ascii_hexdigit()) +} diff --git a/packages/core/src/services/label.rs b/packages/core/src/services/labels.rs similarity index 100% rename from packages/core/src/services/label.rs rename to packages/core/src/services/labels.rs diff --git a/packages/core/tests/integration.rs b/packages/core/tests/integration.rs new file mode 100644 index 0000000..6d3bbe6 --- /dev/null +++ b/packages/core/tests/integration.rs @@ -0,0 +1 @@ +mod integration; diff --git a/packages/core/tests/integration/mod.rs b/packages/core/tests/integration/mod.rs new file mode 100644 index 0000000..6725793 --- /dev/null +++ b/packages/core/tests/integration/mod.rs @@ -0,0 +1,258 @@ +use std::{sync::Arc, time::Duration}; + +use axum::{middleware, routing::get, Router}; +use reqwest::StatusCode; +use serde_json::{json, Value}; +use stellar_explain_core::{ + middleware::request_id::request_id_middleware, + routes::tx::get_tx_explanation, + services::horizon::HorizonClient, +}; +use tokio::net::TcpListener; +use wiremock::{ + matchers::{method, path}, + Mock, MockServer, ResponseTemplate, +}; + +fn test_hash(seed: char) -> String { + std::iter::repeat(seed).take(64).collect() +} + +async fn spawn_app(horizon_base_url: &str) -> String { + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("failed to bind ephemeral port"); + let addr = listener.local_addr().expect("failed to read local addr"); + + let app = Router::new() + .route("/tx/:hash", get(get_tx_explanation)) + .with_state(Arc::new(HorizonClient::new(horizon_base_url.to_string()))) + .layer(middleware::from_fn(request_id_middleware)); + + tokio::spawn(async move { + axum::serve(listener, app) + .await + .expect("server failed unexpectedly"); + }); + + // Small delay to avoid request race with startup. + tokio::time::sleep(Duration::from_millis(40)).await; + + format!("http://{}", addr) +} + +async fn mock_fee_stats(server: &MockServer) { + Mock::given(method("GET")) + .and(path("/fee_stats")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "last_ledger_base_fee": "100", + "fee_charged": { + "min": "100", + "max": "1000", + "mode": "100", + "p90": "250" + } + }))) + .mount(server) + .await; +} + +async fn mock_transaction( + server: &MockServer, + hash: &str, + successful: bool, + fee_charged: &str, + memo_type: Option<&str>, + memo: Option<&str>, +) { + Mock::given(method("GET")) + .and(path(format!("/transactions/{hash}"))) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "hash": hash, + "successful": successful, + "fee_charged": fee_charged, + "memo_type": memo_type, + "memo": memo, + }))) + .mount(server) + .await; +} + +async fn mock_operations(server: &MockServer, hash: &str, operations: Value) { + Mock::given(method("GET")) + .and(path(format!("/transactions/{hash}/operations"))) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "_embedded": { + "records": operations, + } + }))) + .mount(server) + .await; +} + +#[tokio::test] +async fn successful_payment_transaction_returns_transaction_explanation_json() { + let horizon_mock = MockServer::start().await; + let hash = test_hash('a'); + + mock_fee_stats(&horizon_mock).await; + mock_transaction(&horizon_mock, &hash, true, "100", Some("none"), None).await; + mock_operations( + &horizon_mock, + &hash, + json!([ + { + "id": "123456789", + "transaction_hash": hash.clone(), + "type": "payment", + "from": "GCOINBASEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + "to": "GBINANCEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + "asset_type": "native", + "amount": "500.0000000" + } + ]), + ) + .await; + + let app_url = spawn_app(&horizon_mock.uri()).await; + let response = reqwest::get(format!("{app_url}/tx/{hash}")) + .await + .expect("request failed"); + + assert_eq!(response.status(), StatusCode::OK); + + let payload: Value = response.json().await.expect("json parse failed"); + assert_eq!(payload["transaction_hash"], hash); + assert_eq!(payload["successful"], true); + assert!(payload["summary"].as_str().unwrap_or_default().contains("payment")); + assert_eq!(payload["payment_explanations"][0]["amount"], "500.0000000"); + assert!(payload["payment_explanations"][0]["summary"] + .as_str() + .unwrap_or_default() + .contains("Coinbase")); +} + +#[tokio::test] +async fn transaction_with_memo_returns_memo_explanation() { + let horizon_mock = MockServer::start().await; + let hash = test_hash('b'); + + mock_fee_stats(&horizon_mock).await; + mock_transaction( + &horizon_mock, + &hash, + true, + "100", + Some("text"), + Some("Invoice #2026"), + ) + .await; + mock_operations( + &horizon_mock, + &hash, + json!([ + { + "id": "555555", + "transaction_hash": hash.clone(), + "type": "payment", + "from": "GSENDERAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + "to": "GRECIPAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + "asset_type": "native", + "amount": "42.0000000" + } + ]), + ) + .await; + + let app_url = spawn_app(&horizon_mock.uri()).await; + let response = reqwest::get(format!("{app_url}/tx/{hash}")) + .await + .expect("request failed"); + + assert_eq!(response.status(), StatusCode::OK); + + let payload: Value = response.json().await.expect("json parse failed"); + let memo_explanation = payload["memo_explanation"].as_str().unwrap_or_default(); + assert!(memo_explanation.contains("Invoice #2026")); +} + +#[tokio::test] +async fn non_existent_transaction_hash_returns_404_json_error() { + let horizon_mock = MockServer::start().await; + let hash = test_hash('c'); + + Mock::given(method("GET")) + .and(path(format!("/transactions/{hash}"))) + .respond_with(ResponseTemplate::new(404)) + .mount(&horizon_mock) + .await; + + Mock::given(method("GET")) + .and(path(format!("/transactions/{hash}/operations"))) + .respond_with(ResponseTemplate::new(404)) + .mount(&horizon_mock) + .await; + + let app_url = spawn_app(&horizon_mock.uri()).await; + let response = reqwest::get(format!("{app_url}/tx/{hash}")) + .await + .expect("request failed"); + + assert_eq!(response.status(), StatusCode::NOT_FOUND); + + let payload: Value = response.json().await.expect("json parse failed"); + assert_eq!(payload["error"]["code"], "NOT_FOUND"); +} + +#[tokio::test] +#[ignore = "Enable once /tx includes create_account operation explanations (Issue #11)."] +async fn create_account_transaction_returns_create_account_explanation() { + let horizon_mock = MockServer::start().await; + let hash = test_hash('d'); + + mock_fee_stats(&horizon_mock).await; + mock_transaction(&horizon_mock, &hash, true, "100", Some("none"), None).await; + mock_operations( + &horizon_mock, + &hash, + json!([ + { + "id": "777777", + "transaction_hash": hash.clone(), + "type": "create_account", + "funder": "GFUNDERAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + "account": "GNEWACCOUNTAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + "starting_balance": "2.5000000" + } + ]), + ) + .await; + + let app_url = spawn_app(&horizon_mock.uri()).await; + let response = reqwest::get(format!("{app_url}/tx/{hash}")) + .await + .expect("request failed"); + + assert_eq!(response.status(), StatusCode::OK); + + let payload: Value = response.json().await.expect("json parse failed"); + let create_account = payload["create_account_explanations"][0]["summary"] + .as_str() + .unwrap_or_default(); + assert!(create_account.contains("created account")); +} + +#[tokio::test] +async fn invalid_hash_format_returns_400_json_error() { + let horizon_mock = MockServer::start().await; + let app_url = spawn_app(&horizon_mock.uri()).await; + + let response = reqwest::get(format!("{app_url}/tx/not-a-valid-stellar-hash")) + .await + .expect("request failed"); + + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + + let payload: Value = response.json().await.expect("json parse failed"); + assert_eq!(payload["error"]["code"], "BAD_REQUEST"); +}