use axum::body::Body; use axum::http::{Request, StatusCode}; use axum::routing::get; use axum::Router; use ed25519_dalek::SigningKey; use sqlx::sqlite::SqlitePoolOptions; use tower::ServiceExt; use wiremock::matchers::{method, path_regex}; use wiremock::{Mock, MockServer, ResponseTemplate}; use clean_copy_server::handlers::{activate, checkout}; use clean_copy_server::AppState; async fn setup_app(mock_url: &str) -> Router { let pool = SqlitePoolOptions::new() .connect("sqlite::memory:") .await .unwrap(); sqlx::migrate!().run(&pool).await.unwrap(); let key = SigningKey::from_bytes(&[42u8; 32]); let stripe_client = stripe::Client::from_url(mock_url, "sk_test_fake"); let state = AppState { config: clean_copy_server::config::Config { stripe_secret_key: "sk_test_fake".to_string(), stripe_price_id: "price_test_123".to_string(), signing_key_hex: hex::encode(key.to_bytes()), database_url: "sqlite::memory:".to_string(), release_dir: "/tmp/releases".to_string(), static_dir: "./static".to_string(), port: 3000, app_url: "http://localhost:3000".to_string(), }, db: pool, signing_key: key, stripe_client, }; Router::new() .route("/api/checkout", get(checkout::checkout)) .route("/api/activate", axum::routing::post(activate::activate)) .with_state(state) } fn checkout_session_json(id: &str, url: Option<&str>) -> serde_json::Value { let mut json = serde_json::json!({ "id": id, "object": "checkout.session", "mode": "payment", "automatic_tax": { "enabled": false }, "created": 1700000000, "expires_at": 1700003600, "custom_fields": [], "custom_text": { "after_submit": null, "shipping_address": null, "submit": null, "terms_of_service_acceptance": null }, "livemode": false, "payment_method_types": ["card"], "payment_status": "unpaid", "shipping_options": [], }); if let Some(u) = url { json["url"] = serde_json::json!(u); } json } #[tokio::test] async fn test_checkout_redirects_to_stripe() { let mock_server = MockServer::start().await; Mock::given(method("POST")) .and(path_regex(r"/v1/checkout/sessions")) .respond_with( ResponseTemplate::new(200).set_body_json(checkout_session_json( "cs_test_123", Some("https://checkout.stripe.com/pay/cs_test_123"), )), ) .expect(1) .mount(&mock_server) .await; let app = setup_app(&mock_server.uri()).await; let response = app .oneshot( Request::builder() .method("GET") .uri("/api/checkout") .body(Body::empty()) .unwrap(), ) .await .unwrap(); assert_eq!(response.status(), StatusCode::SEE_OTHER); let location = response .headers() .get("location") .unwrap() .to_str() .unwrap(); assert_eq!(location, "https://checkout.stripe.com/pay/cs_test_123"); } #[tokio::test] async fn test_checkout_stripe_error() { let mock_server = MockServer::start().await; Mock::given(method("POST")) .and(path_regex(r"/v1/checkout/sessions")) .respond_with(ResponseTemplate::new(400).set_body_json(serde_json::json!({ "error": { "type": "invalid_request_error", "message": "No such price: 'price_bad'", } }))) .expect(1) .mount(&mock_server) .await; let app = setup_app(&mock_server.uri()).await; let response = app .oneshot( Request::builder() .method("GET") .uri("/api/checkout") .body(Body::empty()) .unwrap(), ) .await .unwrap(); assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR); } #[tokio::test] async fn test_checkout_no_url_in_response() { let mock_server = MockServer::start().await; Mock::given(method("POST")) .and(path_regex(r"/v1/checkout/sessions")) .respond_with( ResponseTemplate::new(200).set_body_json(checkout_session_json("cs_test_no_url", None)), ) .expect(1) .mount(&mock_server) .await; let app = setup_app(&mock_server.uri()).await; let response = app .oneshot( Request::builder() .method("GET") .uri("/api/checkout") .body(Body::empty()) .unwrap(), ) .await .unwrap(); assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR); }