Mục lục
- Mục Tiêu Bài Học
- Setup testcontainers Postgres Cho Test Isolated
- Helper Function HTTP Request Trong Test
- Test Case 1: Register User Flow
- Test Case 2: Full Workflow Cart → Checkout → Order
- Test Case 3: Stripe Webhook Mock + Payment Success
- Test Case 4: Idempotency-Key Replay Verify
- Run Test + Performance Note
- Tổng Kết Group 7 + Roadmap Group 8
- Tổng Kết
- Bài Tập Củng Cố
- Bài Tiếp Theo
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.
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
containerphải giữ trongTestContext— drop ngầm sẽ stop container ngay, làmpoolmất kết nối giữa chừng. sqlx::migrate!đọc thư mục../shop-db/migrationschứ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.
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:
oneshottừtower::ServiceExtnhận 1Request, trả 1Response— 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-Keygiúp test không quên header bắt buộc cho POST/PATCH/DELETE theo middleware B66. - Limit
1024 * 1024byte (1 MB) phù hợp cho response Shop API; tăng nếu test endpoint export NDJSON lớn.
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).
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.
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).
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.
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:
testcontainersdefault 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
testcontainersdù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
TRUNCATEgiữa các test. - Run integration test CHỈ pre-merge (KHÔNG mỗi push) để giữ vòng feedback CI ngắn.
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-Keymiddleware + 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+ centralizedFrommapping. - 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.
Tổng Kết
- testcontainers Postgres isolated mỗi test class — container auto-cleanup khi
TestContextdrop. tower::ServiceExt::oneshotgửi request trực tiếpaxum::Router— faster hơnreqwest, 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_signaturehelper 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.
Bài Tập Củng Cố
Tự trả lời, đáp án ở cuối:
- 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?
tower::ServiceExt::oneshotvs reqwest HTTP client — performance + isolation khác nhau ra sao? Khi nào vẫn nên dùng reqwest?- 3 assertion layer (HTTP + DB + audit) — tại sao MANDATORY cả 3? Cho ví dụ bug bị miss nếu chỉ assert HTTP layer.
- Stripe webhook test mock signature
compute_test_signature— pros/cons so với Stripe CLI forward event thật? - Idempotency replay test 3 scenario — scenario nào dễ miss trong unit test mock service? Tại sao integration test catch được?
Đáp án
- testcontainers vs service container CI. Pattern 1 — service container Postgres CI (1 container Postgres dùng chung, mỗi test reset state qua
TRUNCATEhoặcBEGIN; ... 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=Nchạ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. tower::ServiceExt::oneshotvsreqwestHTTP client.oneshot: gửiRequesttrực tiếp vàoaxum::Routerqua traittower::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 response —http::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::TraceLayerproxy header (X-Forwarded-For); (b) không test TLS termination — phải bỏ qua phần xử lý certificate.reqwestHTTP 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ùngoneshotcho 99% integration test (focus business logic + handler + service + DB); dùngreqwest+ spawn server thật cho smoke test pre-production verify TLS + proxy header + load balancer behavior (thường viết riêng trongtests/smoke_test.rschạy CHỈ trên staging environment). Reference:tower::ServiceExt::oneshotdocumentation; axum testing guide official.- 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_tempthayusers); HTTP layer thấy thành công, DB layer mới catch khiSELECT FROM userscount = 0; (c) missing audit log — handler quên callaudit::log_actionsau 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ừ stockproducts.stock(regression bug khi refactor service B72), HTTP test pass; chỉ DB assertionSELECT stock FROM productsmới catch; (e) cart không clear — sau checkout,DELETE FROM cart_itemsbị 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ưngINSERT INTO paymentsbị 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àoaudit_logstheo 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. - Stripe webhook test mock signature vs Stripe CLI forward thật. Mock signature
compute_test_signaturetính HMAC-SHA256 trực tiếp vớiwhsec_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 fieldlivemode) → 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+sha2crate. - 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::createsẽ đượ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ọicreate()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 quatower::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.
Bài Tiếp Theo
Bài 76: Middleware Overview — Tower Layer Architecture — 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).
