//! 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
{ 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 { 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