Danh sách bài viết

Bài 75: End-to-End CRUD Integration Test — Cart → Order → Payment

Bài 75 của series Rust RESTful API — bài CODE thực tế lớn CUỐI Group 7 CRUD HTTP Endpoints (15/15), tổng kết toàn bộ pattern đã xây qua 14 bài trước thành 1 bộ end-to-end integration test chạy full workflow Shop API: từ register user → add to cart → checkout → order → payment → Stripe webhook → audit log; setup testcontainers Postgres isolated per test class (Pattern 2 từ B58 — mỗi #[tokio::test] spawn container Postgres riêng, auto-cleanup khi test struct drop, đảm bảo zero shared state giữa các test); thêm dev-dependencies mới vào crates/shop-api/Cargo.toml: testcontainers = "0.20" + testcontainers-modules = "0.8" features = ["postgres"] + tower = "0.5" features = ["util"] + hyper = "1" + hmac + sha2 + hex cho Stripe signature mock; tạo file crates/shop-api/tests/common/mod.rs chứa TestContext helper struct (spawn container + connect pool + run migrations + build AppState 6 service + wire production impl + build axum Router + return context với pool + app + container) cùng request(app, method, uri, body) helper dùng tower::ServiceExt::oneshot gửi request trực tiếp axum::Router KHÔNG cần bind TCP port (faster + isolated hơn reqwest HTTP client + auto-inject Idempotency-Key UUID cho non-GET); tạo file crates/shop-api/tests/full_workflow_test.rs với 4 test case: (1) test_user_register_flow verify POST /api/v1/users/register trả 201 + body không leak password_hash + DB insert 1 user + email_verification_tokens 1 row + audit_logs 1 entry action register; (2) test_full_cart_checkout_flow 8 assertion check toàn bộ chuỗi cart → checkout → order: register user + create product stock 10 + POST /cart/items quantity 2 + verify cart_items 1 row + POST /cart/checkout payment cod + verify order created + verify stock decreased 10 → 8 (UPDATE atomic B66) + verify cart cleared + verify payment record pending + verify audit_logs orders entry; (3) test_stripe_webhook_payment_succeeded mock Stripe event payload payment_intent.succeeded qua helper compute_test_signature HMAC-SHA256 (hmac + sha2 + hex crate) generate Stripe-Signature header format t=<ts>,v1=<hex>, POST /webhooks/stripe, verify payment status → success + order status → paid + stripe_webhook_events insert 1 row dedup; (4) test_idempotency_replay 3 scenario test Idempotency-Key middleware B66: first request OK + replay same key → return cached 201 (vẫn 1 order), replay same key DIFFERENT body → 422 Unprocessable Entity; run test qua cargo test -p shop-api --test full_workflow_test yêu cầu Docker daemon running; performance lock: container spawn 3-5s/test class (chậm hơn unit test mock B72 100×), testcontainers default sequential (--test-threads=4 để parallel), CI strategy lock decision pre-merge CHỈ chạy integration test (KHÔNG mỗi PR push) + service container Postgres (B58 Pattern 1) cho speed; tổng kết Group 7 (15/15): 5 crate workspace (shop-api + shop-common + shop-db + shop-core + shop-macros) + 26+ endpoint sống cho 6 resource (products + orders + cart + users + categories + brands + payments) + 14 table + 33+ index + 15 migration applied + service layer abstraction + domain error pattern unified + Idempotency middleware + PII sanitize + audit log + retry production-grade; foundation roadmap Group 8 Middleware Sâu B76-B85: tracing layer + CORS + security headers + rate limiting per-IP/per-user + request body limit + authentication preview + 5 middleware composition ordering chiến lược.

16/06/2026
14 phút đọc
2 lượt xem
1

Mục Tiêu Bài Học

Sau bài học, bạn sẽ:

  • Implement end-to-end integration test cho Shop API complete workflow.
  • Setup testcontainers Postgres isolated mỗi test class — zero shared state.
  • Setup Stripe mock cho webhook test — KHÔNG cần real Stripe API hoặc Stripe sandbox.
  • Test full flow: register user → add to cart → checkout → order → payment → webhook → audit.
  • Áp dụng 3 assertion layer (HTTP status/body + DB state + audit log) cho mọi test.
  • Tổng kết Group 7 (15/15) hoàn thành và xem roadmap Group 8 Middleware Sâu.
  • Có foundation pattern hoàn thiện cho mọi resource Shop API tương lai.
2

Setup testcontainers Postgres Cho Test Isolated

B58 đã preview 2 pattern integration test: Pattern 1 dùng service container Postgres dùng chung cho CI (nhanh, deterministic, chia sẻ schema giữa tất cả test), Pattern 2 dùng testcontainers spawn container riêng mỗi test class cho local dev (chậm hơn nhưng isolated tuyệt đối — không cần lo race condition khi test parallel ghi cùng bảng). B75 lock Pattern 2 cho local dev integration test, Pattern 1 vẫn dùng cho CI để giữ tốc độ pipeline.

Cập nhật crates/shop-api/Cargo.toml thêm dev-dependencies:

# File: crates/shop-api/Cargo.toml
[dev-dependencies]
testcontainers = "0.20"
testcontainers-modules = { version = "0.8", features = ["postgres"] }
tower = { version = "0.5", features = ["util"] }
hyper = "1"
hmac = "0.12"
sha2 = "0.10"
hex = "0.4"

# Đã có từ B72 — giữ nguyên
mockall = { workspace = true }
tokio-test = { workspace = true }

Tạo file crates/shop-api/tests/common/mod.rs chứa helper TestContext spawn container Postgres + connect pool + run migrations + build axum Router. Mỗi test gọi TestContext::new() sẽ nhận 1 environment riêng biệt; khi test struct drop, container auto-cleanup nhờ Drop impl của testcontainers.

// File: crates/shop-api/tests/common/mod.rs
use std::sync::Arc;
use sqlx::PgPool;
use testcontainers::runners::AsyncRunner;
use testcontainers_modules::postgres::Postgres;

use shop_api::{build_router, state::AppState};
use shop_common::config::AppConfig;
use shop_core::{
    brands::PgBrandService,
    carts::PgCartService,
    orders::PgOrderService,
    payments::StripePaymentService,
    products::PgProductService,
    users::PgUserService,
};

pub struct TestContext {
    pub pool: PgPool,
    pub app: axum::Router,
    // Giữ container alive trong suốt lifetime của TestContext.
    // Khi context drop → container drop → docker stop + rm tự động.
    pub container: testcontainers::ContainerAsync<Postgres>,
}

impl TestContext {
    pub async fn new() -> Self {
        // 1. Spawn Postgres container riêng cho test này
        let container = Postgres::default()
            .with_user("test_user")
            .with_password("test_pass")
            .with_db_name("shop_test")
            .start()
            .await
            .expect("spawn postgres container");

        let port = container
            .get_host_port_ipv4(5432)
            .await
            .expect("get host port");
        let url = format!(
            "postgres://test_user:test_pass@localhost:{port}/shop_test"
        );

        // 2. Connect pool
        let pool = sqlx::PgPool::connect(&url)
            .await
            .expect("connect test pool");

        // 3. Apply 15 migration đến hiện tại Group 7
        sqlx::migrate!("../shop-db/migrations")
            .run(&pool)
            .await
            .expect("run migrations");

        // 4. Build AppConfig giả lập env Local
        let config = AppConfig {
            database_url: url.clone(),
            stripe_secret_key: "sk_test_mock".into(),
            stripe_webhook_secret: "whsec_test_mock".into(),
            server_bind: "0.0.0.0:0".into(),
            // ... các field pool config + environment::Local giữ default
            ..AppConfig::test_defaults()
        };

        // 5. Wire 6 service Arc<dyn> với pool — bám B72 + B74
        let state = AppState {
            config: Arc::new(config.clone()),
            db: pool.clone(),
            product_service: Arc::new(PgProductService::new(pool.clone())),
            order_service: Arc::new(PgOrderService::new(pool.clone())),
            payment_service: Arc::new(StripePaymentService::new(
                pool.clone(),
                config.stripe_secret_key.clone(),
                config.stripe_webhook_secret.clone(),
            )),
            cart_service: Arc::new(PgCartService::new(pool.clone())),
            user_service: Arc::new(PgUserService::new(pool.clone())),
            brand_service: Arc::new(PgBrandService::new(pool.clone())),
        };

        // 6. Build axum Router production
        let app = build_router(state);

        Self { pool, app, container }
    }
}

Điểm quan trọng:

  • Field container phải giữ trong TestContext — drop ngầm sẽ stop container ngay, làm pool mất kết nối giữa chừng.
  • sqlx::migrate! đọc thư mục ../shop-db/migrations chứa 15 file (1 mỗi step Group 6 + Group 7) để dựng schema đầy đủ.
  • Wire đúng 6 service như production main.rs (5 service B72 + brand_service B74) để test thật sự chạy code production, không phải mock.
3

Helper Function HTTP Request Trong Test

Thay vì spawn server và dùng reqwest HTTP client, dùng tower::ServiceExt::oneshot gửi Request trực tiếp vào axum::Router (axum chính là tower::Service). Lợi ích: không cần bind port, không bị OS quản lý socket, mỗi test isolated tuyệt đối, latency thấp hơn HTTP client nhiều.

// File: crates/shop-api/tests/common/mod.rs (tiếp)
use axum::body::Body;
use axum::http::{Request, StatusCode};
use serde_json::Value;
use tower::ServiceExt;

pub async fn request(
    app: &axum::Router,
    method: &str,
    uri: &str,
    body: Option<Value>,
) -> (StatusCode, Value) {
    let mut builder = Request::builder()
        .method(method)
        .uri(uri)
        .header("Content-Type", "application/json");

    // Auto-inject Idempotency-Key cho non-GET — middleware B66 yêu cầu
    if method != "GET" {
        builder = builder.header(
            "Idempotency-Key",
            uuid::Uuid::new_v4().to_string(),
        );
    }

    let body_bytes = match body {
        Some(v) => Body::from(serde_json::to_vec(&v).unwrap()),
        None => Body::empty(),
    };

    // oneshot consume 1 lần — clone() Router (rẻ vì axum::Router là Arc bên trong)
    let response = app
        .clone()
        .oneshot(builder.body(body_bytes).unwrap())
        .await
        .expect("oneshot send");

    let status = response.status();
    let body_bytes = axum::body::to_bytes(response.into_body(), 1024 * 1024)
        .await
        .expect("read response body");
    let body_json: Value = serde_json::from_slice(&body_bytes)
        .unwrap_or(Value::Null);

    (status, body_json)
}

Điểm khóa:

  • oneshot từ tower::ServiceExt nhận 1 Request, trả 1 Response — không cần server đang chạy.
  • Faster + isolated hơn reqwest (không qua TCP stack OS, không cần bind port, không race condition khi nhiều test parallel).
  • Auto-inject Idempotency-Key giúp test không quên header bắt buộc cho POST/PATCH/DELETE theo middleware B66.
  • Limit 1024 * 1024 byte (1 MB) phù hợp cho response Shop API; tăng nếu test endpoint export NDJSON lớn.
4

Test Case 1: Register User Flow

Tạo file crates/shop-api/tests/full_workflow_test.rs chứa 4 test case. Test đầu tiên verify pattern B70 (register Argon2id + email verification + audit log).

// File: crates/shop-api/tests/full_workflow_test.rs
mod common;

use axum::http::StatusCode;
use common::{request, TestContext};
use serde_json::json;

#[tokio::test]
async fn test_user_register_flow() {
    let ctx = TestContext::new().await;

    // 1. Gửi POST /api/v1/users/register
    let (status, body) = request(
        &ctx.app,
        "POST",
        "/api/v1/users/register",
        Some(json!({
            "email": "[email protected]",
            "password": "SecurePass123",
            "display_name": "Test User",
            "phone": "+84912345678"
        })),
    )
    .await;

    // --- HTTP layer assertion ---
    assert_eq!(status, StatusCode::CREATED);
    assert_eq!(body["email"], "[email protected]");
    assert_eq!(body["display_name"], "Test User");
    assert!(body["id"].is_number());
    assert!(
        body.get("password_hash").is_none(),
        "password_hash KHÔNG được leak ra response"
    );

    // --- DB layer assertion ---
    let (user_count,): (i64,) =
        sqlx::query_as("SELECT COUNT(*) FROM users WHERE email = $1")
            .bind("[email protected]")
            .fetch_one(&ctx.pool)
            .await
            .expect("count user");
    assert_eq!(user_count, 1);

    let (token_count,): (i64,) =
        sqlx::query_as("SELECT COUNT(*) FROM email_verification_tokens")
            .fetch_one(&ctx.pool)
            .await
            .expect("count token");
    assert_eq!(token_count, 1, "phải tạo đúng 1 token verify email");

    // --- Audit layer assertion ---
    let (audit_count,): (i64,) = sqlx::query_as(
        "SELECT COUNT(*) FROM audit_logs \
         WHERE table_name = 'users' AND action = 'register'",
    )
    .fetch_one(&ctx.pool)
    .await
    .expect("count audit");
    assert_eq!(audit_count, 1);
}

3 assertion layer chạy trong cùng 1 test giúp bắt nhiều loại bug khác nhau:

  • HTTP layer: status code, body field, header — đảm bảo client thấy đúng response chuẩn REST B62 (201 Created + body không leak field nhạy cảm).
  • DB layer: row count, state row sau khi handler chạy — đảm bảo dữ liệu thực sự ghi xuống bảng, không phải chỉ trả 201 rồi rollback ngầm.
  • Audit layer: tracking action vào audit_logs — đảm bảo side-effect phụ vẫn xảy ra theo pattern B62 (mọi mutation lưu lại lịch sử thao tác).
5

Test Case 2: Full Workflow Cart → Checkout → Order

Test này dài, đan chéo nhiều handler. Mục tiêu verify chuỗi 6 step B69 (cart checkout flow) chạy đúng atomic: cart tạo → checkout trừ stock + tạo order + clear cart + insert payment + log audit, tất cả trong 1 transaction.

// File: crates/shop-api/tests/full_workflow_test.rs (tiếp)
#[tokio::test]
async fn test_full_cart_checkout_flow() {
    let ctx = TestContext::new().await;

    // ---- Setup: register user + create product ----
    let _ = request(
        &ctx.app,
        "POST",
        "/api/v1/users/register",
        Some(json!({
            "email": "[email protected]",
            "password": "BuyerPass123",
            "display_name": "Buyer"
        })),
    )
    .await;

    let (_, product) = request(
        &ctx.app,
        "POST",
        "/api/v1/products",
        Some(json!({
            "name": "iPhone 15",
            "slug": "iphone-15",
            "price": "25000000.00",
            "stock": 10
        })),
    )
    .await;
    let product_id = product["id"].as_i64().unwrap();

    // ---- 1. Add to cart ----
    let (status, cart) = request(
        &ctx.app,
        "POST",
        "/api/v1/cart/items",
        Some(json!({ "product_id": product_id, "quantity": 2 })),
    )
    .await;
    assert_eq!(status, StatusCode::OK);
    assert_eq!(cart["item_count"], 2);
    assert_eq!(cart["items"][0]["quantity"], 2);

    // ---- 2. Verify cart in DB ----
    let (cart_rows,): (i64,) =
        sqlx::query_as("SELECT COUNT(*) FROM cart_items")
            .fetch_one(&ctx.pool)
            .await
            .unwrap();
    assert_eq!(cart_rows, 1);

    // ---- 3. Checkout ----
    let (status, order_response) = request(
        &ctx.app,
        "POST",
        "/api/v1/cart/checkout",
        Some(json!({
            "payment_method": {
                "type": "cod",
                "phone": "+84912345678"
            }
        })),
    )
    .await;
    assert_eq!(status, StatusCode::CREATED);

    let order_id = order_response["data"]["id"].as_i64().unwrap();
    assert_eq!(order_response["data"]["status"], "pending");
    assert_eq!(
        order_response["data"]["items"].as_array().unwrap().len(),
        1
    );

    // ---- 4. Verify order created in DB ----
    let (db_order_id, db_status): (i64, String) = sqlx::query_as(
        "SELECT id, status FROM orders WHERE id = $1",
    )
    .bind(order_id)
    .fetch_one(&ctx.pool)
    .await
    .unwrap();
    assert_eq!(db_order_id, order_id);
    assert_eq!(db_status, "pending");

    // ---- 5. Verify stock decreased atomic ----
    let (stock,): (i32,) =
        sqlx::query_as("SELECT stock FROM products WHERE id = $1")
            .bind(product_id)
            .fetch_one(&ctx.pool)
            .await
            .unwrap();
    assert_eq!(stock, 8, "stock phải giảm 10 → 8 theo create_order_atomic B66");

    // ---- 6. Verify cart cleared ----
    let (cart_rows_after,): (i64,) =
        sqlx::query_as("SELECT COUNT(*) FROM cart_items")
            .fetch_one(&ctx.pool)
            .await
            .unwrap();
    assert_eq!(cart_rows_after, 0, "cart phải clear sau checkout");

    // ---- 7. Verify payment record (cod = pending) ----
    let (payment_status,): (String,) = sqlx::query_as(
        "SELECT status FROM payments WHERE order_id = $1",
    )
    .bind(order_id)
    .fetch_one(&ctx.pool)
    .await
    .unwrap();
    assert_eq!(payment_status, "pending");

    // ---- 8. Verify audit log (order created) ----
    let (audit_count,): (i64,) = sqlx::query_as(
        "SELECT COUNT(*) FROM audit_logs \
         WHERE table_name = 'orders' AND row_id = $1",
    )
    .bind(order_id)
    .fetch_one(&ctx.pool)
    .await
    .unwrap();
    assert!(audit_count >= 1, "phải có ít nhất 1 audit entry cho order");
}

Test này cover toàn bộ chuỗi 3 layer (HTTP + DB + audit) qua 8 assertion. Nếu 1 trong các bước fail (vd stock không trừ vì create_order_atomic B66 bị regression), test fail ngay và chỉ ra dòng nào sai. Đây là pattern khó replicate bằng unit test mock vì cần thấy side-effect chéo nhiều bảng cùng lúc.

6

Test Case 3: Stripe Webhook Mock + Payment Success

Webhook Stripe thật yêu cầu Stripe CLI forward event từ Stripe sandbox về localhost, vừa chậm vừa cần network. Test này mock luôn payload + ký lại signature bằng cùng webhook secret whsec_test_mock đã wire trong TestContext. Handler POST /webhooks/stripe không phân biệt được signature đến từ Stripe thật hay từ compute_test_signature miễn HMAC-SHA256 trùng khớp.

// File: crates/shop-api/tests/full_workflow_test.rs (tiếp)
use axum::body::Body;
use axum::http::Request;
use tower::ServiceExt;

#[tokio::test]
async fn test_stripe_webhook_payment_succeeded() {
    let ctx = TestContext::new().await;

    // ---- Setup: tạo order + payment pending có intent_id ----
    let order_id: i64 = sqlx::query_scalar(
        "INSERT INTO orders (user_id, total, status) \
         VALUES (1, 100000, 'pending') RETURNING id",
    )
    .fetch_one(&ctx.pool)
    .await
    .unwrap();

    let intent_id = "pi_test_1234567890";
    sqlx::query(
        "INSERT INTO payments \
         (order_id, payment_type, payment_payload, status) \
         VALUES ($1, 'stripe', $2, 'pending')",
    )
    .bind(order_id)
    .bind(serde_json::json!({
        "payment_intent_id": intent_id
    }))
    .execute(&ctx.pool)
    .await
    .unwrap();

    // ---- Mock Stripe event payload + signature ----
    let event = serde_json::json!({
        "id": "evt_test_001",
        "type": "payment_intent.succeeded",
        "data": {
            "object": {
                "id": intent_id,
                "status": "succeeded",
                "amount": 100000,
                "currency": "vnd"
            }
        }
    });

    let webhook_body = event.to_string();
    let timestamp = chrono::Utc::now().timestamp();
    let sig = compute_test_signature(
        &webhook_body,
        timestamp,
        "whsec_test_mock",
    );

    let req = Request::builder()
        .method("POST")
        .uri("/webhooks/stripe")
        .header(
            "Stripe-Signature",
            format!("t={timestamp},v1={sig}"),
        )
        .header("Content-Type", "application/json")
        .body(Body::from(webhook_body))
        .unwrap();

    let response = ctx.app.clone().oneshot(req).await.unwrap();
    assert_eq!(response.status(), StatusCode::OK);

    // ---- Verify payment status → success ----
    let (payment_status,): (String,) = sqlx::query_as(
        "SELECT status FROM payments WHERE order_id = $1",
    )
    .bind(order_id)
    .fetch_one(&ctx.pool)
    .await
    .unwrap();
    assert_eq!(payment_status, "success");

    // ---- Verify order status → paid ----
    let (order_status,): (String,) = sqlx::query_as(
        "SELECT status FROM orders WHERE id = $1",
    )
    .bind(order_id)
    .fetch_one(&ctx.pool)
    .await
    .unwrap();
    assert_eq!(order_status, "paid");

    // ---- Verify webhook event logged + idempotent ----
    let (event_count,): (i64,) =
        sqlx::query_as("SELECT COUNT(*) FROM stripe_webhook_events")
            .fetch_one(&ctx.pool)
            .await
            .unwrap();
    assert_eq!(event_count, 1, "B71 dedup pattern: 1 event lưu lại");
}

/// HMAC-SHA256 sign theo đúng cách Stripe ký webhook.
/// Stripe-Signature header format: `t=<ts>,v1=<hex_hmac>`
fn compute_test_signature(body: &str, timestamp: i64, secret: &str) -> String {
    use hmac::{Hmac, Mac};
    use sha2::Sha256;

    let payload = format!("{timestamp}.{body}");
    let mut mac = Hmac::<Sha256>::new_from_slice(secret.as_bytes())
        .expect("HMAC accepts any key length");
    mac.update(payload.as_bytes());
    hex::encode(mac.finalize().into_bytes())
}

Helper compute_test_signature chính là phép tính Stripe dùng phía server. Việc tự ký lại trong test environment hoàn toàn an toàn vì secret là test-only (whsec_test_mock), không trùng secret production. Test này verify cả signature verify path (B37 + B71) + state machine transition (payment pending → success, order pending → paid) + dedup pattern (stripe_webhook_events insert đúng 1 row).

7

Test Case 4: Idempotency-Key Replay Verify

Idempotency middleware B66 đảm bảo client gửi cùng Idempotency-Key với cùng body → server return cached response, KHÔNG tạo order trùng. Nếu body khác → 422 cảnh báo. Test này verify cả 3 scenario.

// File: crates/shop-api/tests/full_workflow_test.rs (tiếp)
#[tokio::test]
async fn test_idempotency_replay() {
    let ctx = TestContext::new().await;

    // ---- Setup: register + create product stock 10 ----
    let _ = request(
        &ctx.app,
        "POST",
        "/api/v1/users/register",
        Some(json!({
            "email": "[email protected]",
            "password": "Pass1234",
            "display_name": "Idem"
        })),
    )
    .await;

    let (_, product) = request(
        &ctx.app,
        "POST",
        "/api/v1/products",
        Some(json!({
            "name": "X",
            "slug": "x",
            "price": "100.00",
            "stock": 10
        })),
    )
    .await;
    let pid = product["id"].as_i64().unwrap();

    let order_body = json!({
        "items": [{ "product_id": pid, "quantity": 1 }],
        "payment_method": { "type": "cod", "phone": "+84912345678" }
    });

    let key = uuid::Uuid::new_v4().to_string();

    // ---- Scenario 1: first request OK ----
    let req1 = Request::builder()
        .method("POST")
        .uri("/api/v1/orders")
        .header("Content-Type", "application/json")
        .header("Idempotency-Key", &key)
        .body(Body::from(serde_json::to_vec(&order_body).unwrap()))
        .unwrap();
    let resp1 = ctx.app.clone().oneshot(req1).await.unwrap();
    assert_eq!(resp1.status(), StatusCode::CREATED);

    let (count1,): (i64,) =
        sqlx::query_as("SELECT COUNT(*) FROM orders")
            .fetch_one(&ctx.pool)
            .await
            .unwrap();
    assert_eq!(count1, 1);

    // ---- Scenario 2: replay SAME key + SAME body → cached 201 ----
    let req2 = Request::builder()
        .method("POST")
        .uri("/api/v1/orders")
        .header("Content-Type", "application/json")
        .header("Idempotency-Key", &key)
        .body(Body::from(serde_json::to_vec(&order_body).unwrap()))
        .unwrap();
    let resp2 = ctx.app.clone().oneshot(req2).await.unwrap();
    assert_eq!(resp2.status(), StatusCode::CREATED);

    let (count2,): (i64,) =
        sqlx::query_as("SELECT COUNT(*) FROM orders")
            .fetch_one(&ctx.pool)
            .await
            .unwrap();
    assert_eq!(count2, 1, "vẫn 1 order — middleware trả cached");

    // ---- Scenario 3: replay SAME key + DIFFERENT body → 422 ----
    let mut other_body = order_body.clone();
    other_body["items"][0]["quantity"] = json!(5);
    let req3 = Request::builder()
        .method("POST")
        .uri("/api/v1/orders")
        .header("Content-Type", "application/json")
        .header("Idempotency-Key", &key)
        .body(Body::from(serde_json::to_vec(&other_body).unwrap()))
        .unwrap();
    let resp3 = ctx.app.clone().oneshot(req3).await.unwrap();
    assert_eq!(resp3.status(), StatusCode::UNPROCESSABLE_ENTITY);
}

Pattern 3 scenario trong cùng 1 test giúp khoá ngữ nghĩa idempotency B66 chặt chẽ: first OK, replay cached, mismatch body reject 422. Bug dễ miss trong unit test mock service vì mock không biết về middleware layer — chỉ integration test mới chạy đầy đủ chuỗi middleware → handler → service → DB.

8

Run Test + Performance Note

Yêu cầu Docker daemon đang chạy (testcontainers gọi docker socket spawn container). Chạy lệnh:

cargo test -p shop-api --test full_workflow_test

Expected output:

    Finished `test` profile [unoptimized + debuginfo] target(s) in 18.42s
     Running tests/full_workflow_test.rs

running 4 tests
test test_user_register_flow ... ok
test test_full_cart_checkout_flow ... ok
test test_stripe_webhook_payment_succeeded ... ok
test test_idempotency_replay ... ok

test result: ok. 4 passed; 0 failed; 0 ignored; 0 measured

Bật parallel để rút ngắn tổng thời gian:

cargo test -p shop-api --test full_workflow_test -- --test-threads=4

Performance đo lường thực tế:

  • Container spawn: ~3-5 giây mỗi test class (gồm pull image lần đầu, start postgres, run 15 migration). Chậm hơn unit test mock B72 khoảng 100 lần.
  • Test parallel: testcontainers default sequential, cần --test-threads=4 để chạy song song. Mỗi test có container riêng nên không bị deadlock cross-test.
  • CI tradeoff: integration test chậm → tách thành CI job riêng (test:integration) chạy pre-merge thay vì mỗi PR push.

Lock decision Shop API về test strategy:

  • Integration test bằng testcontainers dùng cho local dev — isolated tuyệt đối, dễ debug.
  • CI dùng service container Postgres (B58 Pattern 1) — nhanh hơn vì 1 container dùng chung, schema reset bằng TRUNCATE giữa các test.
  • Run integration test CHỈ pre-merge (KHÔNG mỗi push) để giữ vòng feedback CI ngắn.
9

Tổng Kết Group 7 + Roadmap Group 8

Group 7 đã hoàn thành 15/15 bài (B61 → B75) phủ kín pattern CRUD HTTP Endpoints cho 6 resource Shop API:

  • B61 — REST resource modeling + idempotency overview + ETag concept.
  • B62 — Products CRUD complete + soft delete + audit log + Location header.
  • B63 — Categories tree + materialized path + cycle detection.
  • B64 — Products ↔ Categories M:N + N+1 avoidance + batch fetch.
  • B65 — Soft delete pattern đầy đủ + restore + cycle detection bulk operation.
  • B66 — POST /orders + Idempotency-Key middleware + retry + create_order_atomic.
  • B67 — GET /orders list + cursor pagination + batch fetch hiệu quả.
  • B68 — GET /orders/{id} + audit timeline + PATCH limited theo state machine.
  • B69 — Cart endpoints + checkout flow 6 step + UPSERT.
  • B70 — Users register + Argon2id + email verification token.
  • B71 — Payment Stripe integration + webhook signature verify + dedup pattern.
  • B72 — Service layer abstraction + 5 service trait + mockall test pattern.
  • B73 — Domain error pattern unified + thiserror + centralized From mapping.
  • B74 — CRUD macro derive + #[derive(SimpleCrud)] + Brand resource + 70/30 rule.
  • B75 — End-to-end integration test full workflow (bài hiện tại).

Foundation Shop API sau Group 7:

  • 5 crate workspace: shop-api + shop-common + shop-db + shop-core + shop-macros.
  • 26+ endpoint sống cho 6 resource (products + orders + cart + users + categories + brands + payments).
  • 14 table + 33+ index + 15 migration applied.
  • Service layer + domain error + integration test patterns lock vĩnh viễn.
  • PII sanitize + audit log + Idempotency + retry production-grade.

Group 8 sẽ cover B76-B85 — Middleware Sâu:

  • B76 — Tracing layer + structured logging + trace ID propagation.
  • B77 — CORS + security headers (X-Content-Type-Options, X-Frame-Options, CSP).
  • B78 — Rate limiting per-IP / per-user (tower-governor).
  • B79 — Request body limit per-route (DefaultBodyLimit).
  • B80-B85 — Authentication middleware preview + 5 middleware composition + ordering chiến lược.
10

Tổng Kết

  • testcontainers Postgres isolated mỗi test class — container auto-cleanup khi TestContext drop.
  • tower::ServiceExt::oneshot gửi request trực tiếp axum::Router — faster hơn reqwest, không cần bind port.
  • 3 assertion layer MANDATORY: HTTP status/body + DB state + audit log — bắt nhiều loại bug khác nhau.
  • 4 test case demo: register flow, full cart → checkout flow, Stripe webhook payment, idempotency replay.
  • compute_test_signature helper HMAC-SHA256 mock Stripe signature — không cần Stripe sandbox.
  • 3 idempotency scenario: first OK, replay cached, mismatch body 422 — khoá ngữ nghĩa B66 chặt.
  • Container spawn 3-5s/class — chậm hơn unit test mock 100×; chấp nhận trade-off cho isolation.
  • CI strategy lock: integration test pre-merge CHỈ (KHÔNG mỗi PR push), CI dùng service container B58 Pattern 1 cho speed.
  • HOÀN THÀNH Group 7 (15/15) — 5 crate workspace + 26+ endpoint + 14 table + 33+ index + 15 migration.
  • Foundation pattern cho Group 8 Middleware Sâu (tracing + CORS + rate limit + body limit + auth + composition).
  • File path lock: crates/shop-api/tests/common/mod.rs, crates/shop-api/tests/full_workflow_test.rs.
11

Bài Tập Củng Cố

Tự trả lời, đáp án ở cuối:

  1. testcontainers Postgres vs service container CI (B58) — pros/cons mỗi pattern? Shop API quyết định khi nào dùng pattern nào?
  2. tower::ServiceExt::oneshot vs reqwest HTTP client — performance + isolation khác nhau ra sao? Khi nào vẫn nên dùng reqwest?
  3. 3 assertion layer (HTTP + DB + audit) — tại sao MANDATORY cả 3? Cho ví dụ bug bị miss nếu chỉ assert HTTP layer.
  4. Stripe webhook test mock signature compute_test_signature — pros/cons so với Stripe CLI forward event thật?
  5. Idempotency replay test 3 scenario — scenario nào dễ miss trong unit test mock service? Tại sao integration test catch được?
Đáp án
  1. testcontainers vs service container CI. Pattern 1 — service container Postgres CI (1 container Postgres dùng chung, mỗi test reset state qua TRUNCATE hoặc BEGIN; ... ROLLBACK). Pros: (a) fast — chỉ start Postgres 1 lần đầu pipeline (~3-5s), test sau chạy luôn không cần spawn; (b) deterministic — schema cố định, dễ debug khi fail; (c) compatible tốt với GitHub Actions service definition (services: { postgres: ... } built-in); (d) resource hiệu quả — chỉ 1 docker container thay N. Cons: (a) shared state risk — nếu test reset không sạch (vd quên TRUNCATE sequence) → race condition test phụ thuộc thứ tự; (b) parallelization khó — phải tách schema per test hoặc dùng transaction rollback; (c) concurrent test cùng bảng phải nghiêm cẩn isolation. Pattern 2 — testcontainers spawn container riêng mỗi test. Pros: (a) isolated tuyệt đối — mỗi test có DB riêng, không lo race condition, không lo state leak; (b) parallelization dễ--test-threads=N chạy song song không xung đột; (c) debug rõ ràng — fail 1 test không ảnh hưởng test khác; (d) schema reset tự nhiên — container drop là sạch. Cons: (a) slow — 3-5s overhead spawn mỗi test (chậm hơn unit test mock 100×); (b) tốn resource — N test = N container; (c) yêu cầu Docker daemon chạy local; (d) không phù hợp CI tight loop chạy mỗi PR push (pipeline chậm). Lock decision Shop API: local dev dùng Pattern 2 (testcontainers) — developer happy với isolation tuyệt đối, debug dễ, không lo state leak khi run nhanh nhiều test; CI dùng Pattern 1 (service container Postgres) — speed pipeline ưu tiên, chấp nhận shared state với reset chiến lược. Integration test schedule: chạy CHỈ pre-merge (PR approve + merge button click) thay vì mỗi push, giữ vòng feedback PR ngắn 2-3 phút thay vì 15-20 phút.
  2. tower::ServiceExt::oneshot vs reqwest HTTP client. oneshot: gửi Request trực tiếp vào axum::Router qua trait tower::Service. Pros: (a) không bind TCP port — không qua kernel network stack, không cần địa chỉ free; (b) không race condition khi parallel — mỗi test có Router instance riêng; (c) latency thấp — bỏ qua serialization HTTP + TCP handshake; (d) không phụ thuộc network state OS (firewall, DNS, port conflict); (e) typed responsehttp::Response<Body> trực tiếp, không cần parse status từ string. Cons: (a) không test middleware tầng network như tower-http::TraceLayer proxy header (X-Forwarded-For); (b) không test TLS termination — phải bỏ qua phần xử lý certificate. reqwest HTTP client: gửi qua TCP real đến server đã bind port. Pros: (a) test full network stack — proxy, TLS, DNS; (b) gần production hơn — verify được middleware extract proxy header thật; (c) test được middleware lifecycle với connection pool real. Cons: (a) chậm hơn (TCP handshake + serialization); (b) cần spawn server + bind port (race condition khi nhiều test parallel); (c) phụ thuộc OS network state. Lock decision Shop API: dùng oneshot cho 99% integration test (focus business logic + handler + service + DB); dùng reqwest + spawn server thật cho smoke test pre-production verify TLS + proxy header + load balancer behavior (thường viết riêng trong tests/smoke_test.rs chạy CHỈ trên staging environment). Reference: tower::ServiceExt::oneshot documentation; axum testing guide official.
  3. 3 assertion layer MANDATORY HTTP + DB + audit. Lý do MANDATORY cả 3: chỉ assert 1 layer dễ bỏ sót bug subtle khác layer. Bug miss nếu chỉ HTTP layer: (a) handler return 201 + body đúng nhưng transaction rollback ngầm — sqlx::query trả Ok nhưng ngay sau đó commit fail vì FK constraint, handler bắt error rồi log warning + vẫn return 201; HTTP test pass nhưng DB không có row → next test query fail mới phát hiện; (b) shadow write — handler INSERT vào staging table thay production table do typo (users_temp thay users); HTTP layer thấy thành công, DB layer mới catch khi SELECT FROM users count = 0; (c) missing audit log — handler quên call audit::log_action sau mutation; HTTP user happy nhưng admin compliance audit trail thiếu, sau 6 tháng GDPR investigation request data history → không có record (B62 yêu cầu); (d) side-effect chéo — checkout endpoint trả 201 + order body nhưng quên trừ stock products.stock (regression bug khi refactor service B72), HTTP test pass; chỉ DB assertion SELECT stock FROM products mới catch; (e) cart không clear — sau checkout, DELETE FROM cart_items bị bỏ sót, user next request /cart vẫn thấy item cũ; HTTP test order trả OK không phát hiện; (f) payment record không insert — order tạo OK nhưng INSERT INTO payments bị throw silent, downstream Stripe webhook không match được. DB layer assert: verify state thực sự ghi xuống — row count, column value, FK link. Audit layer assert: verify side-effect tracking — mọi mutation phải log vào audit_logs theo pattern B62 để compliance + debug history; thiếu audit là bug nghiêm trọng nhưng silent (user không thấy, chỉ admin biết khi cần query lịch sử). Pattern lock: mọi integration test mutation endpoint phải có 3 assertion tối thiểu — HTTP status + body field critical + DB row state + audit_logs count.
  4. Stripe webhook test mock signature vs Stripe CLI forward thật. Mock signature compute_test_signature tính HMAC-SHA256 trực tiếp với whsec_test_mock. Pros: (a) không cần internet — test chạy offline trên CI air-gapped, devloper máy bay; (b) không phụ thuộc Stripe sandbox uptime — Stripe có downtime hoặc rate limit không ảnh hưởng test; (c) deterministic — event payload do test control hoàn toàn, không lo Stripe thay đổi structure event; (d) fast — không cần spawn Stripe CLI process, không cần webhook listener; (e) không tốn quota Stripe test mode (limit số event/giây); (f) test edge case dễ — payload malformed, signature invalid, timestamp expired, replay attack — Stripe sandbox không generate được các case này; (g) không leak credentials — không cần Stripe test API key trong CI secrets. Cons: (a) không verify được parse Stripe payload real — Stripe có thể đổi field (vd thêm field livemode) → handler không update kịp, test mock vẫn pass nhưng prod fail; (b) không catch được API change Stripe SDK version bump; (c) không test latency real webhook delivery (Stripe có retry policy + delay). Stripe CLI forward event thật: stripe listen --forward-to localhost:3000/webhooks/stripe + stripe trigger payment_intent.succeeded. Pros: (a) payload real từ Stripe — catch được structural change; (b) test full integration network + signature + parse + handler; (c) verify version SDK compatible. Cons: (a) cần Stripe account + CLI install; (b) cần network ổn định; (c) chậm hơn (mỗi event đợi Stripe relay ~1-3s); (d) khó test edge case (malformed payload). Lock decision Shop API: mock signature cho 95% integration test (B75 pattern) + Stripe CLI manual test cho smoke test trước release (verify SDK version + payload real không drift). Reference: Stripe Webhook Signatures documentation; hmac + sha2 crate.
  5. Idempotency replay test 3 scenario — bug dễ miss trong unit test mock. Unit test mock service B72 chỉ test logic trong handler/service, KHÔNG bao gồm middleware layer phía trên. Scenario dễ miss: (a) Scenario 2 (replay same key + same body) — mock OrderService::create sẽ được gọi 2 lần nếu test gọi handler trực tiếp 2 lần với mock; middleware Idempotency B66 mới là layer chặn replay + return cached response trước khi gọi handler; mock không catch được middleware này → unit test có thể pass nhưng production thực sự tạo 2 order trùng nếu middleware bị disable/regression. (b) Scenario 3 (same key + different body) — middleware so sánh body hash với cached entry; nếu hash khác phải return 422 trước khi gọi handler; unit test mock nhảy thẳng vào handler, không có middleware check → 2 request đều gọi create() tạo 2 order khác nhau với cùng key (data corruption). Tại sao integration test catch được: integration test chạy full stack qua tower::ServiceExt::oneshot — request đi qua tất cả middleware layer (Idempotency B66, tracing B76 future, auth B112 future) trước khi đến handler; mỗi assertion verify trạng thái thực sự sau toàn bộ chain. Bug khác dễ miss bằng unit test: (a) middleware order swap — Idempotency layer đặt sau auth thay vì trước → unauthorized request bypass Idempotency check; (b) cache key collision — middleware lưu key dưới {key} thay {user_id}:{key} → 2 user khác nhau cùng key cùng method nhận response của nhau (lỗ hổng cross-tenant); (c) cache không TTL — replay sau 7 ngày vẫn return cached; (d) body hash function bug — JSON serialize không deterministic (field order khác nhau cùng object → hash khác → middleware tưởng body khác → 422 false alarm); chỉ integration test mới catch full chain. Lock pattern Shop API: mọi feature có middleware involve (Idempotency, rate limit, auth, body limit) PHẢI có integration test scenario; unit test mock CHỈ phù hợp test pure business logic không qua middleware. Reference: tower::Layer composition documentation; axum middleware order best practice.
12

Bài Tiếp Theo

— mở Group 8: tower::Layer + tower::Service trait, middleware composition chain, axum::middleware::from_fn vs custom Layer, ordering chiến lược, áp Shop API middleware stack chuẩn (trace + cors + rate-limit + body-limit + auth + compression).