open-standards/openpay/crates/op-server/tests/api.rs
Transaction Science 70ecd6caa0 Initial import — three open standards under one shelf
Transaction Science open standards monorepo. Each subdirectory is a
self-contained reference protocol with its own README and licence.

- openpay/    Apache-2.0  payment-acceptance stack (Rust, 26 crates)
- smart-byte/ CC-BY-4.0   content-addressed value carrier (spec)
- eoc/        CC-BY-4.0   energy-optimised compute (spec)

The wire format and the right to fork are public. The optional hosted
services are the offer; the protocols are not.
2026-05-23 07:58:09 -04:00

573 lines
18 KiB
Rust

//! HTTP integration tests via `tower::ServiceExt::oneshot` — no
//! sockets, no listener, just direct router invocation. Tests the
//! full handler stack: JSON parsing → store → JSON out.
use std::sync::Arc;
use axum::body::Body;
use axum::http::{Request, StatusCode};
use http_body_util::BodyExt;
use op_server::{AppState, router};
use op_webhook::{HttpTransport, MockTransport};
use serde_json::{Value, json};
use tower::util::ServiceExt;
async fn body_to_json(body: Body) -> Value {
let bytes = body.collect().await.expect("collect body").to_bytes();
serde_json::from_slice(&bytes).expect("body is json")
}
fn json_request(method: &str, path: &str, body: Value) -> Request<Body> {
Request::builder()
.method(method)
.uri(path)
.header("content-type", "application/json")
.body(Body::from(body.to_string()))
.unwrap()
}
fn empty_request(method: &str, path: &str) -> Request<Body> {
Request::builder()
.method(method)
.uri(path)
.body(Body::empty())
.unwrap()
}
#[tokio::test]
async fn health_returns_ok() {
let app = router(AppState::new_in_memory());
let res = app.oneshot(empty_request("GET", "/health")).await.unwrap();
assert_eq!(res.status(), StatusCode::OK);
let body = body_to_json(res.into_body()).await;
assert_eq!(body["status"], "ok");
}
#[tokio::test]
async fn readiness_returns_counts() {
let app = router(AppState::new_in_memory());
let res = app
.oneshot(empty_request("GET", "/readiness"))
.await
.unwrap();
assert_eq!(res.status(), StatusCode::OK);
let body = body_to_json(res.into_body()).await;
assert_eq!(body["status"], "ready");
assert_eq!(body["refunds"], 0);
}
#[tokio::test]
async fn create_refund_round_trip() {
let app = router(AppState::new_in_memory());
let tx_id = uuid::Uuid::now_v7();
let req = json!({
"original_tx_id": tx_id.to_string(),
"amount_minor": 7500,
"currency": "USD",
"reason": "customer_request",
"external_id": "ext-1",
"requested_at_unix_secs": 1_700_000_000_u64,
});
let res = app
.clone()
.oneshot(json_request("POST", "/v1/refunds", req))
.await
.unwrap();
assert_eq!(res.status(), StatusCode::OK);
let body = body_to_json(res.into_body()).await;
assert_eq!(body["amount_minor"], 7500);
assert_eq!(body["currency"], "USD");
assert_eq!(body["status"], "requested");
let refund_id = body["id"].as_str().unwrap();
let res = app
.oneshot(empty_request("GET", &format!("/v1/refunds/{refund_id}")))
.await
.unwrap();
assert_eq!(res.status(), StatusCode::OK);
let body = body_to_json(res.into_body()).await;
assert_eq!(body["external_id"], "ext-1");
}
#[tokio::test]
async fn refund_unknown_returns_404() {
let app = router(AppState::new_in_memory());
let missing = uuid::Uuid::now_v7();
let res = app
.oneshot(empty_request("GET", &format!("/v1/refunds/{missing}")))
.await
.unwrap();
assert_eq!(res.status(), StatusCode::NOT_FOUND);
let body = body_to_json(res.into_body()).await;
assert_eq!(body["code"], "not_found");
}
#[tokio::test]
async fn refund_idempotency_replay_returns_same() {
let app = router(AppState::new_in_memory());
let tx_id = uuid::Uuid::now_v7();
let req = json!({
"original_tx_id": tx_id.to_string(),
"amount_minor": 100,
"currency": "USD",
"reason": "duplicate_charge",
"external_id": "ext-idem",
"requested_at_unix_secs": 1_700_000_000_u64,
});
let res1 = app
.clone()
.oneshot(json_request("POST", "/v1/refunds", req.clone()))
.await
.unwrap();
assert_eq!(res1.status(), StatusCode::OK);
let body1 = body_to_json(res1.into_body()).await;
let res2 = app
.oneshot(json_request("POST", "/v1/refunds", req))
.await
.unwrap();
assert_eq!(res2.status(), StatusCode::OK);
let body2 = body_to_json(res2.into_body()).await;
assert_eq!(body1["id"], body2["id"]);
}
#[tokio::test]
async fn create_dispute_round_trip() {
let app = router(AppState::new_in_memory());
let tx_id = uuid::Uuid::now_v7();
let req = json!({
"original_tx_id": tx_id.to_string(),
"amount_minor": 9999,
"currency": "USD",
"reason": "fraudulent",
"network_reason_code": "10.4",
"external_id": "disp-1",
"opened_at_unix_secs": 1_700_000_000_u64,
});
let res = app
.oneshot(json_request("POST", "/v1/disputes", req))
.await
.unwrap();
assert_eq!(res.status(), StatusCode::OK);
let body = body_to_json(res.into_body()).await;
assert_eq!(body["status"], "chargeback");
assert_eq!(body["network_reason_code"], "10.4");
}
#[tokio::test]
async fn open_batch_and_close() {
let app = router(AppState::new_in_memory());
let req = json!({
"currency": "USD",
"rail": "ach_nacha",
"external_id": "batch-1",
"opened_at_unix_secs": 1_700_000_000_u64,
});
let res = app
.clone()
.oneshot(json_request("POST", "/v1/settlement/batches", req))
.await
.unwrap();
assert_eq!(res.status(), StatusCode::OK);
let body = body_to_json(res.into_body()).await;
let bid = body["id"].as_str().unwrap();
assert_eq!(body["status"], "open");
// Add an entry.
let tx_id = uuid::Uuid::now_v7();
let entry = json!({
"tx_id": tx_id.to_string(),
"amount_minor": 12345,
});
let res = app
.clone()
.oneshot(json_request(
"POST",
&format!("/v1/settlement/batches/{bid}/entries"),
entry,
))
.await
.unwrap();
assert_eq!(res.status(), StatusCode::OK);
let body = body_to_json(res.into_body()).await;
assert_eq!(body["entry_count"], 1);
assert_eq!(body["gross_minor"], 12345);
// Close with 1% reserve.
let close = json!({
"flat_rate_bps": 100,
"max_total_bps": 10000,
"dispute_adjustment_bps": 0,
"closed_at_unix_secs": 1_700_001_000_u64,
});
let res = app
.oneshot(json_request(
"POST",
&format!("/v1/settlement/batches/{bid}/close"),
close,
))
.await
.unwrap();
assert_eq!(res.status(), StatusCode::OK);
let body = body_to_json(res.into_body()).await;
assert_eq!(body["status"], "closed");
assert_eq!(body["reserve_minor"], 123); // 1% of 12345
}
#[tokio::test]
async fn audit_report_empty_window() {
let app = router(AppState::new_in_memory());
let res = app
.oneshot(empty_request(
"GET",
"/v1/audit/report?start_tx=0&end_tx=100&generated_at_unix_secs=9999",
))
.await
.unwrap();
assert_eq!(res.status(), StatusCode::OK);
let body = body_to_json(res.into_body()).await;
assert!(body["entries"].as_array().unwrap().is_empty());
}
#[tokio::test]
async fn subscription_create_pause_resume_cancel() {
let app = router(AppState::new_in_memory());
let req = json!({
"customer_ref": "cust-1",
"plan_name": "Pro Monthly",
"amount_minor": 4_900,
"currency": "USD",
"interval": "month",
"interval_count": 1,
"trial_days": 14,
"method": { "type": "vault", "token": "tok_v7_test" },
"external_id": "sub-1",
"now_unix_secs": 1_700_000_000_u64,
});
let res = app
.clone()
.oneshot(json_request("POST", "/v1/subscriptions", req))
.await
.unwrap();
assert_eq!(res.status(), StatusCode::OK);
let body = body_to_json(res.into_body()).await;
assert_eq!(body["status"], "trialing");
assert_eq!(body["plan_name"], "Pro Monthly");
let sid = body["id"].as_str().unwrap().to_owned();
// Pause.
let res = app
.clone()
.oneshot(json_request(
"POST",
&format!("/v1/subscriptions/{sid}/pause"),
json!({ "now_unix_secs": 1_700_000_100_u64 }),
))
.await
.unwrap();
assert_eq!(res.status(), StatusCode::OK);
let body = body_to_json(res.into_body()).await;
assert_eq!(body["status"], "paused");
// Resume.
let res = app
.clone()
.oneshot(json_request(
"POST",
&format!("/v1/subscriptions/{sid}/resume"),
json!({}),
))
.await
.unwrap();
assert_eq!(res.status(), StatusCode::OK);
let body = body_to_json(res.into_body()).await;
assert_eq!(body["status"], "active");
// List by customer.
let res = app
.clone()
.oneshot(empty_request(
"GET",
"/v1/subscriptions?customer_ref=cust-1",
))
.await
.unwrap();
assert_eq!(res.status(), StatusCode::OK);
let body = body_to_json(res.into_body()).await;
assert_eq!(body.as_array().unwrap().len(), 1);
// Cancel immediately.
let res = app
.oneshot(json_request(
"POST",
&format!("/v1/subscriptions/{sid}/cancel"),
json!({ "at_period_end": false, "now_unix_secs": 1_700_000_200_u64 }),
))
.await
.unwrap();
assert_eq!(res.status(), StatusCode::OK);
let body = body_to_json(res.into_body()).await;
assert_eq!(body["status"], "canceled");
}
#[tokio::test]
async fn fx_quote_returns_configured_rate() {
use op_core::Currency;
use op_fx::StaticQuoteProvider;
let provider: Arc<dyn op_fx::QuoteProvider> =
Arc::new(StaticQuoteProvider::new().with_rate(Currency::EUR, Currency::USD, 1_082_500));
let state = AppState::new_in_memory().with_fx_provider(provider);
let app = router(state);
let res = app
.oneshot(empty_request("GET", "/v1/fx/quote?from=EUR&to=USD"))
.await
.unwrap();
assert_eq!(res.status(), StatusCode::OK);
let body = body_to_json(res.into_body()).await;
assert_eq!(body["from"], "EUR");
assert_eq!(body["to"], "USD");
assert_eq!(body["rate_ppm"], 1_082_500);
assert_eq!(body["source"], "static");
}
#[tokio::test]
async fn fx_convert_applies_rate() {
use op_core::Currency;
use op_fx::StaticQuoteProvider;
let provider: Arc<dyn op_fx::QuoteProvider> =
Arc::new(StaticQuoteProvider::new().with_rate(Currency::EUR, Currency::USD, 1_082_500));
let state = AppState::new_in_memory().with_fx_provider(provider);
let app = router(state);
let res = app
.oneshot(json_request(
"POST",
"/v1/fx/convert",
json!({
"from": "EUR",
"to": "USD",
"amount_minor": 10_000,
}),
))
.await
.unwrap();
assert_eq!(res.status(), StatusCode::OK);
let body = body_to_json(res.into_body()).await;
assert_eq!(body["source_amount_minor"], 10_000);
assert_eq!(body["target_amount_minor"], 10_825);
assert_eq!(body["rounding"], "half_even");
}
#[tokio::test]
async fn fx_quote_for_unknown_pair_returns_404() {
let state = AppState::new_in_memory();
let app = router(state);
let res = app
.oneshot(empty_request("GET", "/v1/fx/quote?from=USD&to=JPY"))
.await
.unwrap();
assert_eq!(res.status(), StatusCode::NOT_FOUND);
let body = body_to_json(res.into_body()).await;
assert_eq!(body["code"], "not_found");
}
#[tokio::test]
async fn refund_creation_fires_webhook_to_subscribed_endpoint() {
let mock = Arc::new(MockTransport::new());
mock.push_ok();
let transport: Arc<dyn HttpTransport> = mock.clone();
let state = AppState::new_in_memory().with_webhook_transport(transport);
let app = router(state);
// Register a webhook endpoint subscribed to refund.created.
let res = app
.clone()
.oneshot(json_request(
"POST",
"/v1/webhooks/endpoints",
json!({
"url": "https://example.test/refund-hook",
"secret": "shared-secret-xyz",
"event_filters": ["refund.created"],
}),
))
.await
.unwrap();
assert_eq!(res.status(), StatusCode::OK);
let body = body_to_json(res.into_body()).await;
assert_eq!(body["status"], "active");
// Create a refund.
let tx_id = uuid::Uuid::now_v7();
let res = app
.oneshot(json_request(
"POST",
"/v1/refunds",
json!({
"original_tx_id": tx_id.to_string(),
"amount_minor": 250,
"currency": "USD",
"reason": "customer_request",
"external_id": "ext-webhook",
"requested_at_unix_secs": 1_700_000_000_u64,
}),
))
.await
.unwrap();
assert_eq!(res.status(), StatusCode::OK);
// The mock transport should have seen exactly one outbound delivery.
assert_eq!(mock.captured_count(), 1);
let captured = mock.take_captured();
assert_eq!(captured[0].url, "https://example.test/refund-hook");
// Payload is the refund response JSON; verify a field is present.
let payload_str = std::str::from_utf8(&captured[0].body).unwrap();
assert!(payload_str.contains("\"external_id\":\"ext-webhook\""));
assert!(payload_str.contains("\"amount_minor\":250"));
}
#[tokio::test]
async fn refund_creation_skips_disabled_endpoint() {
let mock = Arc::new(MockTransport::new());
let transport: Arc<dyn HttpTransport> = mock.clone();
let state = AppState::new_in_memory().with_webhook_transport(transport);
let app = router(state);
let res = app
.clone()
.oneshot(json_request(
"POST",
"/v1/webhooks/endpoints",
json!({
"url": "https://example.test/disabled-hook",
"secret": "s",
"event_filters": ["refund.created"],
}),
))
.await
.unwrap();
let body = body_to_json(res.into_body()).await;
let endpoint_id = body["id"].as_str().unwrap().to_owned();
// Disable it.
let _ = app
.clone()
.oneshot(empty_request(
"POST",
&format!("/v1/webhooks/endpoints/{endpoint_id}/disable"),
))
.await
.unwrap();
let tx_id = uuid::Uuid::now_v7();
let _ = app
.oneshot(json_request(
"POST",
"/v1/refunds",
json!({
"original_tx_id": tx_id.to_string(),
"amount_minor": 100,
"currency": "USD",
"reason": "duplicate_charge",
"external_id": "ext-disabled",
"requested_at_unix_secs": 1_700_000_000_u64,
}),
))
.await
.unwrap();
assert_eq!(
mock.captured_count(),
0,
"disabled endpoint should not receive deliveries"
);
}
#[tokio::test]
async fn intent_create_then_resume_after_challenge() {
use op_core::{Currency, Money, PaymentMethod, VaultRef};
use op_driver_sdk::DeterministicCardAcquirer;
use op_orchestrator::{CardAdapter, Orchestrator, PolicyRouter};
use op_rails_card::acquirer::AuthStatus;
// Configure the acquirer so the test idempotency key triggers
// a challenge.
let acquirer = Arc::new(DeterministicCardAcquirer::new().with_key_override(
"intent-3ds-1",
AuthStatus::RequiresCustomerAction,
None,
));
let card = Arc::new(CardAdapter::new("det-card", acquirer));
let mut orch = Orchestrator::new().with_router(Box::new(PolicyRouter::new(
vec!["det-card".to_owned()],
vec![],
)));
orch.register_adapter(card);
let state = AppState::new_in_memory().with_orchestrator(orch);
let app = router(state);
// Step 1: create intent. Expect requires_customer_action.
let res = app
.clone()
.oneshot(json_request(
"POST",
"/v1/intents",
json!({
"idempotency_key": "intent-3ds-1",
"amount_minor": 1_500,
"currency": "USD",
"method": { "type": "vault", "token": "tok_v7_3ds" },
}),
))
.await
.unwrap();
assert_eq!(res.status(), StatusCode::OK);
let body = body_to_json(res.into_body()).await;
assert_eq!(body["terminal_status"], "requires_customer_action");
let psp_id = body["psp_payment_id"].as_str().unwrap().to_owned();
let _ = Currency::USD;
let _ = Money::from_minor(0, Currency::USD);
let _ = PaymentMethod::Vault(VaultRef::new("x"));
// Step 2: resume after challenge. Expect approved.
let res = app
.oneshot(json_request(
"POST",
"/v1/intents/resume",
json!({
"idempotency_key": "intent-3ds-1",
"amount_minor": 1_500,
"currency": "USD",
"method": { "type": "vault", "token": "tok_v7_3ds" },
"rail": "card",
"driver": "det-card",
"psp_payment_id": psp_id,
}),
))
.await
.unwrap();
assert_eq!(res.status(), StatusCode::OK);
let body = body_to_json(res.into_body()).await;
assert_eq!(body["terminal_status"], "approved");
assert_eq!(body["psp_payment_id"], psp_id);
}
#[tokio::test]
async fn bad_currency_returns_400() {
let app = router(AppState::new_in_memory());
let tx_id = uuid::Uuid::now_v7();
let req = json!({
"original_tx_id": tx_id.to_string(),
"amount_minor": 100,
"currency": "usd", // lowercase
"reason": "customer_request",
"requested_at_unix_secs": 1_700_000_000_u64,
});
let res = app
.oneshot(json_request("POST", "/v1/refunds", req))
.await
.unwrap();
assert_eq!(res.status(), StatusCode::BAD_REQUEST);
}