Chuỗi bài (Phần 8 của 8): Bài tổng kết series tập hợp toàn bộ chiến lược kiểm thử dành riêng cho từng layer của Core Banking Architecture đã được đề cập ở các phần trước — từ ledger consistency đến distributed SQL, Saga, ISO 20022, API Security, và Streaming Fraud Detection.

Tại Sao Core Banking Cần SDET Riêng?

Kiểm thử hệ thống tài chính phân tán đòi hỏi kỹ năng chuyên biệt ngoài unit tests thông thường. Các lỗi nghiêm trọng nhất thường chỉ xuất hiện khi có concurrency cao, network failures, clock drift, hoặc partial system failures — những điều kiện không thể reproduce bằng integration tests đơn giản.

6 category chiến lược kiểm thử trong bài này tương ứng trực tiếp với từng phần của series:

Test CategoryTương ứng với PhầnRisk nếu bỏ qua
Double-Entry InvariantPhần 1 (Ledger)Double-spend, unbalanced GL
Distributed SQL & ClockPhần 2 (Distributed SQL)Split-brain, stale reads
Event Replay & OutboxPhần 3 (Event Sourcing)Data inconsistency, lost events
Saga CompensationPhần 4 (Saga)Orphaned holds, money stuck
Idempotency & APIPhần 5, 6 (ISO 20022, Security)Double-charge, token theft
Flink State & SLAPhần 7 (Fraud Detection)Undetected fraud, false positives

Category 1: Double-Entry Invariant Auditing

Test 1.1: Concurrent Double-Spend Prevention

Mục tiêu: 100 goroutines đồng thời rút tiền từ một tài khoản — chỉ số yêu cầu đủ balance được phép thành công.

func TestConcurrentDoubleSpend(t *testing.T) {
    const (
        numWorkers     = 100
        withdrawAmount = 10_000   // 10,000 VND
        initialBalance = 100_000  // 100,000 VND
    )
    
    // Setup: tạo account với balance cố định
    accountID := createTestAccount(initialBalance)
    
    var (
        successCount atomic.Int64
        wg           sync.WaitGroup
    )
    
    // Chạy 100 concurrent withdrawals
    for i := 0; i < numWorkers; i++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            err := withdrawFromAccount(accountID, withdrawAmount)
            if err == nil {
                successCount.Add(1)
            }
        }()
    }
    wg.Wait()
    
    // Chỉ 10 requests được phép thành công
    assert.Equal(t, int64(10), successCount.Load(),
        "Chính xác 10 withdrawals được phép với balance 100,000 VND")
    
    // Balance cuối phải bằng 0 — không âm, không double-counted
    finalBalance := getAccountBalance(accountID)
    assert.Equal(t, int64(0), finalBalance,
        "Balance sau khi rút hết phải = 0, không âm")
    
    // Ledger invariant: SUM(DEBIT) = SUM(CREDIT)
    imbalance := checkLedgerBalance(accountID)
    assert.Equal(t, int64(0), imbalance,
        "Sổ cái phải cân bằng: SUM(DEBIT) = SUM(CREDIT)")
}

Test 1.2: Continuous Reconciliation Job

// Chạy mỗi 5 phút trong production monitoring
func RunLedgerReconciliation(ctx context.Context, db *sql.DB) ([]DiscrepancyReport, error) {
    query := `
        SELECT
            transaction_id,
            SUM(CASE WHEN direction = 'DEBIT' THEN amount ELSE -amount END) AS discrepancy
        FROM entries
        GROUP BY transaction_id
        HAVING SUM(CASE WHEN direction = 'DEBIT' THEN amount ELSE -amount END) <> 0
        LIMIT 100
    `
    
    rows, err := db.QueryContext(ctx, query)
    if err != nil {
        return nil, err
    }
    defer rows.Close()
    
    var reports []DiscrepancyReport
    for rows.Next() {
        var report DiscrepancyReport
        rows.Scan(&report.TransactionID, &report.Discrepancy)
        reports = append(reports, report)
    }
    
    if len(reports) > 0 {
        // CRITICAL: Fire P1 alert — ledger balance violated
        fireP1Alert(ctx, "LEDGER_IMBALANCE", reports)
    }
    
    return reports, nil
}

Test 1.3: Deadlock Prevention Verification

func TestDeadlockFreeTransfers(t *testing.T) {
    // Tạo 2 accounts
    accountA := createTestAccount(1_000_000)
    accountB := createTestAccount(1_000_000)
    
    var wg sync.WaitGroup
    errs := make(chan error, 100)
    
    // 50 goroutines: A → B
    for i := 0; i < 50; i++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            err := transferBetween(accountA, accountB, 1000)
            if err != nil {
                errs <- err
            }
        }()
    }
    
    // 50 goroutines: B → A (tạo ra điều kiện deadlock nếu lock order sai)
    for i := 0; i < 50; i++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            err := transferBetween(accountB, accountA, 1000)
            if err != nil {
                errs <- err
            }
        }()
    }
    
    // Chạy với timeout để detect deadlock
    done := make(chan struct{})
    go func() { wg.Wait(); close(done) }()
    
    select {
    case <-done:
        // No deadlock — tất cả goroutines hoàn thành
    case <-time.After(10 * time.Second):
        t.Fatal("DEADLOCK DETECTED: transfers không hoàn thành trong 10s")
    }
    
    // Tổng balance phải không đổi
    totalBalance := getAccountBalance(accountA) + getAccountBalance(accountB)
    assert.Equal(t, int64(2_000_000), totalBalance,
        "Tổng balance phải không thay đổi sau tất cả transfers")
}

Category 2: Distributed SQL & Clock Resilience Testing

Test 2.1: Network Partition (Split-Brain) Simulation

#!/bin/bash
# Simulation: 5-node CockroachDB cluster bị partition thành 3 + 2

MINORITY_NODES=("node4" "node5")
MAJORITY_NODES=("node1" "node2" "node3")

echo "=== Starting network partition simulation ==="

# Drop packets giữa minority và majority nodes
for node in "${MINORITY_NODES[@]}"; do
    # SSH vào node và drop packets đến majority
    ssh "$node" "sudo tc qdisc add dev eth0 root netem loss 100%"
    echo "Partitioned: $node disconnected from cluster"
done

sleep 5  # Đợi partition có hiệu lực

echo "=== Testing write behavior during partition ==="

# Test: Write trên majority side phải thành công
echo "Testing majority write..."
cockroach sql --host=node1:26257 --insecure \
    --execute="INSERT INTO test_transactions VALUES (gen_random_uuid(), 1000, 'VND', NOW())"
echo "Majority write: EXPECTED SUCCESS"

# Test: Write trên minority side phải fail
echo "Testing minority write..."
cockroach sql --host=node4:26257 --insecure --timeout=5s \
    --execute="INSERT INTO test_transactions VALUES (gen_random_uuid(), 1000, 'VND', NOW())" \
    && echo "FAIL: Minority write succeeded (should have failed!)" \
    || echo "PASS: Minority write correctly rejected"

echo "=== Healing partition ==="
for node in "${MINORITY_NODES[@]}"; do
    ssh "$node" "sudo tc qdisc del dev eth0 root"
done

sleep 10  # Đợi cluster đồng bộ

# Verify: Minority nodes phải catch up về consistent state
cockroach node status --host=node1:26257 --insecure
echo "All nodes should show consistent RANGES count"

Test 2.2: Clock Skew Injection (libfaketime)

#!/bin/bash
# Inject clock drift vượt max_clock_offset của CockroachDB (500ms)

# Install libfaketime
apt-get install -y libfaketime

# Test với clock drift = 600ms (vượt 500ms threshold)
echo "=== Testing with 600ms clock drift ==="
LD_PRELOAD=/usr/lib/x86_64-linux-gnu/faketime/libfaketime.so.1 \
FAKETIME="+0.6s" \
go test ./distributed/... -run TestClockSkewResilience -v 2>&1

# Kỳ vọng: Database phải detect và reject hoặc retry
# KHÔNG được: return stale/out-of-order data

# Test với TiDB: inject drift > TSO timestamp
echo "=== Testing TiDB clock skew ==="
LD_PRELOAD=/usr/lib/x86_64-linux-gnu/faketime/libfaketime.so.1 \
FAKETIME="+2s" \
go test ./tidb/... -run TestTSOClockDrift -v 2>&1
// Go test: Verify database reaction to clock skew
func TestClockSkewResilience(t *testing.T) {
    // Hàm này chạy DƯỚI faketime injection (600ms drift)
    
    // Attempt write trên node với drifted clock
    err := performDatabaseWrite("test-account-001", 50000)
    
    // CockroachDB phải:
    // - Detect clock skew > 500ms offset
    // - Either: reject với clear error, or
    // - Retry với timestamp push
    
    if err != nil {
        // Verify error là clock-related, không phải network/business error
        assert.Contains(t, err.Error(), "clock skew",
            "Error phải mention clock skew")
    } else {
        // Nếu succeed: verify read của chúng ta không bị stale
        result := readLastWrite("test-account-001")
        assert.NotNil(t, result, "Nếu write succeed, phải đọc được ngay")
    }
}

Category 3: Event Sourcing & Outbox Testing

Test 3.1: Event Store Replay Consistency

func TestEventReplayConsistency(t *testing.T) {
    ctx := context.Background()
    
    // 1. Verify "live" balance từ CQRS read model
    liveBalance := getReadModelBalance(ctx, "account-001")
    t.Logf("Live balance: %d", liveBalance)
    
    // 2. Drop và rebuild read model từ event store
    dropAccountBalancesTable(ctx)
    replayAllEventsFromEventStore(ctx)
    
    // 3. So sánh replayed balance với live balance
    replayedBalance := getReadModelBalance(ctx, "account-001")
    
    assert.Equal(t, liveBalance, replayedBalance,
        "Replayed balance PHẢI khớp chính xác với live balance")
}

Test 3.2: Outbox Atomicity — DB Success, Kafka Fail

func TestOutboxAtomicityKafkaFailure(t *testing.T) {
    ctx := context.Background()
    
    // Mock Kafka để fail publish
    mockKafka := NewFailingKafkaPublisher()
    
    initialBalanceA := getAccountBalance("account-A")
    initialOutboxCount := countPendingOutboxEvents()
    
    // Execute transfer — Kafka bị mocked để fail
    err := transferService.WithKafka(mockKafka).Transfer(ctx, TransferRequest{
        From: "account-A", To: "account-B", Amount: 500_000,
    })
    
    // Transfer database THÀNH CÔNG (vì trong cùng DB transaction)
    assert.NoError(t, err, "Transfer phải succeed ở database level")
    
    // Balance đã thay đổi
    newBalanceA := getAccountBalance("account-A")
    assert.Equal(t, initialBalanceA-500_000, newBalanceA)
    
    // Outbox event vẫn ở PENDING (Kafka fail)
    pendingCount := countPendingOutboxEvents()
    assert.Greater(t, pendingCount, initialOutboxCount,
        "Phải có PENDING outbox events do Kafka fail")
    
    // Sau khi Kafka recover → outbox worker sẽ retry publish
    mockKafka.Recover()
    waitForOutboxWorkerProcessing(5 * time.Second)
    
    // Verify: Kafka đã nhận event
    assert.True(t, mockKafka.HasPublished("MoneyTransferred"),
        "Outbox worker phải retry và publish event lên Kafka")
    
    // Final balance check
    assert.Equal(t, initialBalanceA-500_000, getAccountBalance("account-A"))
    assert.Equal(t, int64(500_000), getAccountBalance("account-B"))
}

Category 4: Saga Compensation Testing

Test 4.1: Step 2 Failure → Compensation Verification

func TestSagaStep2FailCompensation(t *testing.T) {
    ctx := context.Background()
    
    initialBalanceA := getAccountBalance("account-A")
    initialBalanceB := getAccountBalance("account-B")
    
    // Mock Payment Gateway để fail ở step 2
    mockGateway := NewFailingPaymentGateway(
        FailAt: PaymentSubmission,
        Error:  "NAPAS_TIMEOUT",
    )
    
    // Execute saga
    err := sagaOrchestrator.WithGateway(mockGateway).ExecuteTransfer(ctx, TransferRequest{
        From: "account-A", To: "account-B", Amount: 1_000_000,
    })
    
    // Saga phải return error
    assert.Error(t, err)
    assert.Contains(t, err.Error(), "NAPAS_TIMEOUT")
    
    // Chờ compensation hoàn thành
    waitForCompensation(5 * time.Second)
    
    // CRITICAL: Balance A phải được hoàn về nguyên trạng
    finalBalanceA := getAccountBalance("account-A")
    assert.Equal(t, initialBalanceA, finalBalanceA,
        "Compensation PHẢI hoàn tiền về account-A")
    
    // Balance B phải không thay đổi
    finalBalanceB := getAccountBalance("account-B")
    assert.Equal(t, initialBalanceB, finalBalanceB,
        "Account-B không được nhận tiền khi saga failed")
}

Test 4.2: Compensation Chain Failure → DLQ Routing

func TestCompensationFailureRoutesToDLQ(t *testing.T) {
    ctx := context.Background()
    
    // Mock: Step 2 fail VÀ RefundActivity cũng fail
    mockGateway := NewFailingPaymentGateway(FailAt: PaymentSubmission)
    mockRefund := NewFailingRefundService() // Compensation cũng fail
    
    dlqCapture := NewDLQCapture()
    alertCapture := NewAlertCapture()
    
    sagaOrchestrator.
        WithGateway(mockGateway).
        WithRefund(mockRefund).
        WithDLQ(dlqCapture).
        WithAlerts(alertCapture).
        ExecuteTransfer(ctx, validTransferRequest)
    
    // Chờ retry exhaust (3 attempts × exponential backoff)
    waitForRetryExhaustion(30 * time.Second)
    
    // Phải có event trong DLQ
    assert.Greater(t, dlqCapture.Count(), 0,
        "Failed compensation PHẢI route vào DLQ")
    
    // DLQ event phải chứa đủ context để ops team xử lý
    dlqEvent := dlqCapture.Last()
    assert.NotEmpty(t, dlqEvent.TransferID)
    assert.NotEmpty(t, dlqEvent.Reason)
    
    // Phải có P1 alert
    assert.True(t, alertCapture.HasFiredP1(),
        "P1 alert PHẢI được fired cho failed compensation")
}

Category 5: Idempotency & API Security Testing

Test 5.1: Concurrent Double-Submit

func TestConcurrentDoubleSubmit(t *testing.T) {
    const idempotencyKey = "transfer-idempotency-key-xyz-001"
    
    type result struct {
        statusCode int
        body       string
    }
    
    results := make(chan result, 2)
    
    // Fire 2 requests đồng thời với cùng idempotency key
    for i := 0; i < 2; i++ {
        go func() {
            resp, _ := httpClient.Post("/v1/transfers",
                "application/json",
                buildTransferBody(idempotencyKey, 1_000_000),
            )
            body, _ := io.ReadAll(resp.Body)
            results <- result{resp.StatusCode, string(body)}
        }()
    }
    
    r1 := <-results
    r2 := <-results
    
    codes := []int{r1.statusCode, r2.statusCode}
    
    // Đúng 1 request nhận 201 Created
    createdCount := 0
    for _, code := range codes {
        if code == 201 {
            createdCount++
        }
    }
    assert.Equal(t, 1, createdCount, "Chỉ một request được phép 201 Created")
    
    // Request còn lại phải nhận 200 (cached response) hoặc 409 (conflict)
    for _, code := range codes {
        if code != 201 {
            assert.True(t, code == 200 || code == 409,
                "Duplicate request phải nhận 200 (cached) hoặc 409 (conflict)")
        }
    }
    
    // Tài khoản chỉ bị debit một lần
    totalDebit := getTotalDebitForIdempotencyKey(idempotencyKey)
    assert.Equal(t, int64(1_000_000), totalDebit,
        "Chỉ 1,000,000 VND được trừ, không phải 2,000,000")
}

Test 5.2: DPoP Token Replay Attack

func TestDPoPTokenReplayAttack(t *testing.T) {
    // Lấy valid access token và DPoP proof
    accessToken, dpopProof := getValidDPoPTokenAndProof("POST", "/v1/transfers")
    
    // Request 1: Hợp lệ
    resp1 := makeAuthenticatedRequest(accessToken, dpopProof, "POST", "/v1/transfers", validBody)
    assert.Equal(t, 200, resp1.StatusCode, "Request 1 phải thành công")
    
    // Request 2: Replay CÙNG dpopProof (attacker intercepted)
    resp2 := makeAuthenticatedRequest(accessToken, dpopProof, "POST", "/v1/transfers", validBody)
    
    // Phải bị từ chối: jti đã được dùng
    assert.Equal(t, 401, resp2.StatusCode,
        "Replay attack PHẢI bị từ chối với 401")
    
    var errorResp map[string]string
    json.Unmarshal(resp2.Body, &errorResp)
    assert.Equal(t, "dpop_replay_detected", errorResp["error"])
}

Test 5.3: Idempotency Key Payload Mismatch

func TestIdempotencyPayloadMismatch(t *testing.T) {
    const key = "idem-key-mismatch-001"
    
    // Request 1: Amount = 1,000,000 VND
    resp1 := sendTransferWithKey(key, 1_000_000)
    assert.Equal(t, 201, resp1.StatusCode)
    
    // Request 2: Cùng key, amount KHÁC = 2,000,000 VND
    resp2 := sendTransferWithKey(key, 2_000_000)
    
    // Phải từ chối với 422 Unprocessable Entity
    assert.Equal(t, 422, resp2.StatusCode,
        "Payload mismatch với existing key PHẢI trả 422")
    
    var errorResp map[string]interface{}
    json.Unmarshal(resp2.Body, &errorResp)
    assert.Equal(t, "idempotency_key_payload_mismatch", errorResp["error"])
}

@Test
public void testFraudDetectorWithTestHarness() throws Exception {
    // TestHarness: không cần Flink cluster thật
    KeyedOneInputStreamOperatorTestHarness<String, Event, AlertEvent> harness =
        new KeyedOneInputStreamOperatorTestHarness<>(
            new FraudDetectorOperator(),
            Event::getUserId,
            Types.STRING
        );
    harness.open();

    long now = System.currentTimeMillis();

    // Scenario A: 3 failed logins → high-value tx trong 5 phút → SHOULD ALERT
    harness.processElement(new Event("user-001", "login_failed", 0.0), now);
    harness.processElement(new Event("user-001", "login_failed", 0.0), now + 30_000);
    harness.processElement(new Event("user-001", "login_failed", 0.0), now + 60_000);
    harness.processElement(new Event("user-001", "transaction", 5_000_000.0), now + 120_000);

    // Advance watermark để trigger timer
    harness.processWatermark(now + 300_001);

    List<StreamRecord<AlertEvent>> output = harness.extractOutputStreamRecords();
    assertEquals(1, output.size(), "PHẢI có 1 fraud alert cho Scenario A");
    assertEquals("user-001", output.get(0).getValue().getUserId());

    // Scenario B: 3 failed logins → small tx (< 1M) → SHOULD NOT ALERT
    harness.processElement(new Event("user-002", "login_failed", 0.0), now);
    harness.processElement(new Event("user-002", "login_failed", 0.0), now + 30_000);
    harness.processElement(new Event("user-002", "login_failed", 0.0), now + 60_000);
    harness.processElement(new Event("user-002", "transaction", 50_000.0), now + 120_000); // 50K VND
    harness.processWatermark(now + 300_002);

    // Scenario B không tạo thêm alert
    assertEquals(1, harness.extractOutputStreamRecords().size(),
        "KHÔNG nên có alert cho Scenario B (small transaction)");

    harness.close();
}

Test 6.2: RocksDB State Recovery After Failure

@Test
public void testRocksDBStateRecoveryAfterTaskFailure() throws Exception {
    MiniClusterWithClientResource cluster = setupFlinkMiniCluster();

    // Run pipeline và manually trigger checkpoint
    String checkpointDir = "/tmp/flink-test-checkpoint";
    env.setStateBackend(new EmbeddedRocksDBStateBackend(true));
    env.setDefaultSavepointDirectory(checkpointDir);
    env.enableCheckpointing(1000);

    // Inject test events
    List<Event> testEvents = generateHighVolumeEvents(10_000);
    DataStream<Event> input = env.fromCollection(testEvents);
    DataStream<AlertEvent> output = buildFraudDetectionPipeline(input);

    // Start job, wait for first checkpoint
    JobClient job = env.executeAsync("test-fraud-pipeline");
    waitForCheckpoint(job, checkpointDir);

    // Simulate task failure (kill one TaskManager)
    cluster.getMiniCluster().getTaskManagers().get(0).shutDown();

    // Restart — should recover from checkpoint
    cluster.getMiniCluster().startNewTaskManager();

    // Verify: pipeline continues, no duplicate alerts
    List<AlertEvent> alerts = collectAlerts(output, 30_000);
    long duplicateAlerts = alerts.stream()
        .collect(Collectors.groupingBy(AlertEvent::getTransferId, Collectors.counting()))
        .values().stream()
        .filter(count -> count > 1)
        .count();

    assertEquals(0, duplicateAlerts,
        "Sau failure recovery, KHÔNG được có duplicate fraud alerts (Exactly-Once)");
}

Chaos Engineering Checklist

## Pre-Production Chaos Engineering Gates

### Category 1: Ledger Integrity
- [ ] Concurrent withdraw test: 100 goroutines, 1 account → chỉ đủ requests succeed
- [ ] Reconciliation job: 0 rows trả về từ imbalance query
- [ ] Deadlock-free transfers: 10,000 cross-transfers hoàn thành < 30s

### Category 2: Distributed SQL
- [ ] 3+2 partition test: majority write success, minority write rejected
- [ ] Clock drift 600ms: database detect và handle correctly
- [ ] Leader failover: new leader tiếp nhận trong <30s, data intact

### Category 3: Event Sourcing
- [ ] Event replay: replayed balance = live balance (zero diff)
- [ ] Outbox atomicity: Kafka fail không mất event, retry thành công
- [ ] Snapshot consistency: load từ snapshot + partial replay = full replay result

### Category 4: Saga
- [ ] Step 2 fail: compensation executed, source account restored
- [ ] Compensation fail: DLQ có event, P1 alert fired
- [ ] Timeout: pending transfer auto-void sau TTL

### Category 5: API Security
- [ ] DPoP replay: 401 on second use of same JTI
- [ ] Stolen token + wrong key: 401 thumbprint mismatch
- [ ] Double-submit: 201 once, 409 or 200 cached second time
- [ ] Payload mismatch: 422 when idempotency key reused with different payload

### Category 6: Fraud Detection
- [ ] CEP alert: ≥3 failed logins + high-value tx → alert generated
- [ ] SLA: P50 <50ms, P99 <100ms fraud scoring
- [ ] State recovery: zero duplicate alerts after TaskManager restart
- [ ] False positive rate: <30% in staging with real transaction patterns

Category 7: Load Testing & Performance Validation

Load testing là bước bắt buộc cuối cùng trước khi ship Core Banking system ra production — không có benchmark tự động thì SLA chỉ là con số trên giấy.

Các tool phù hợp cho Core Banking load testing:

ToolDùng choƯu điểm
k6API-level load test (REST/gRPC)Scripting bằng JavaScript, threshold assertions, cloud execution
GatlingJVM-based high-concurrency simulationScala DSL, built-in HTML reports, phù hợp với Java/Kotlin services
ArtilleryLightweight API load testYAML config, phù hợp quick spike tests
LocustPython-based distributed load testFlexible custom scenarios, phù hợp ML-heavy workflows

Test 1: Ledger Throughput Benchmark (k6)

Target: ≥10,000 transfers/giây với P99 latency ≤50ms trên single-node PostgreSQL.

// k6/ledger-throughput.js
import http from 'k6/http';
import { check, sleep } from 'k6';
import { Counter, Rate, Trend } from 'k6/metrics';

// Custom metrics
const transferErrors    = new Counter('transfer_errors');
const transferSuccesses = new Counter('transfer_successes');
const transferLatency   = new Trend('transfer_p99_latency', true);
const errorRate         = new Rate('error_rate');

export const options = {
  scenarios: {
    // Ramp up: 0 → 10,000 VUs trong 2 phút, giữ 5 phút, ramp down
    ledger_stress: {
      executor: 'ramping-vus',
      startVUs: 0,
      stages: [
        { duration: '2m', target: 500  },   // Warm-up
        { duration: '3m', target: 2000 },   // Moderate load
        { duration: '5m', target: 5000 },   // Target load
        { duration: '2m', target: 0    },   // Ramp down
      ],
    },
  },
  // SLA thresholds — test FAILS if violated
  thresholds: {
    'http_req_duration{scenario:ledger_stress}': [
      'p(95)<30',   // P95 < 30ms
      'p(99)<50',   // P99 < 50ms ← SLA gate
    ],
    'error_rate': ['rate<0.001'], // < 0.1% error rate
    'http_req_failed': ['rate<0.005'],
  },
};

// Seed data: pre-generated account pairs (avoid hot accounts)
const ACCOUNT_PAIRS = JSON.parse(open('./fixtures/account-pairs.json'));

export default function () {
  // Pick random account pair to distribute load evenly
  const pair = ACCOUNT_PAIRS[Math.floor(Math.random() * ACCOUNT_PAIRS.length)];

  const payload = JSON.stringify({
    idempotency_key: `load-test-${__VU}-${__ITER}`,  // Unique per VU+iteration
    source_account:  pair.source,
    target_account:  pair.target,
    amount:          Math.floor(Math.random() * 100000) + 1000, // 1,000–100,000 VND
    currency:        'VND',
  });

  const res = http.post(
    `${__ENV.BASE_URL}/api/v1/transfers`,
    payload,
    {
      headers: {
        'Content-Type': 'application/json',
        'Authorization': `Bearer ${__ENV.API_TOKEN}`,
      },
      timeout: '5s',
    }
  );

  const success = check(res, {
    'status 201': (r) => r.status === 201,
    'has transaction_id': (r) => JSON.parse(r.body).transaction_id !== undefined,
    'response < 50ms': (r) => r.timings.duration < 50,
  });

  transferLatency.add(res.timings.duration);

  if (success) {
    transferSuccesses.add(1);
    errorRate.add(0);
  } else {
    transferErrors.add(1);
    errorRate.add(1);
    console.error(`Transfer failed: ${res.status}${res.body.substring(0, 200)}`);
  }

  sleep(0.01); // 10ms think time giữa các requests
}

// Teardown: log summary
export function handleSummary(data) {
  return {
    'load-test-results/ledger-throughput.json': JSON.stringify(data),
  };
}

Chạy test:

# Tạo fixture data trước
node scripts/generate-account-pairs.js --count 10000 > fixtures/account-pairs.json

# Run k6 với 500 VUs ban đầu
k6 run \
  --env BASE_URL=https://staging.core-banking.internal \
  --env API_TOKEN=$(vault read -field=token secret/staging/api) \
  --out json=results/ledger-throughput-$(date +%Y%m%d).json \
  k6/ledger-throughput.js

# View results
k6 inspect results/ledger-throughput-$(date +%Y%m%d).json

Test 2: Idempotency Stress Test — Duplicate Request Bombardment

Mục tiêu: Đảm bảo cùng idempotency_key không bao giờ tạo ra 2 transactions khác nhau, dù bị gọi đồng thời 100 lần.

// k6/idempotency-stress.js
import http from 'k6/http';
import { check } from 'k6';
import { SharedArray } from 'k6/data';

export const options = {
  // 100 VUs cùng gọi 1 idempotency_key — không có success nào là duplicate
  scenarios: {
    duplicate_storm: {
      executor: 'shared-iterations',
      vus: 100,
      iterations: 1000,
      maxDuration: '30s',
    },
  },
  thresholds: {
    'checks': ['rate==1.0'], // 100% checks phải pass
  },
};

// Một idempotency_key duy nhất cho tất cả VUs
const IDEMPOTENCY_KEY = `idem-stress-test-${Date.now()}`;
const results = new SharedArray('results', () => []);

export default function () {
  const res = http.post(
    `${__ENV.BASE_URL}/api/v1/transfers`,
    JSON.stringify({
      idempotency_key: IDEMPOTENCY_KEY,  // Cùng key!
      source_account:  'ACC-001',
      target_account:  'ACC-002',
      amount:          50000,
      currency:        'VND',
    }),
    { headers: { 'Content-Type': 'application/json' } }
  );

  const body = JSON.parse(res.body);

  check(res, {
    // Chỉ chấp nhận 201 (first create) hoặc 200 (idempotent return)
    'valid status (201 or 200)': (r) => r.status === 201 || r.status === 200,
    // transaction_id phải nhất quán — không thể có 2 giá trị khác nhau
    'same transaction_id always': () => {
      const txId = body.transaction_id;
      if (results.length === 0) {
        results.push(txId);
        return true;
      }
      return txId === results[0]; // Mọi response phải cùng transaction_id
    },
  });
}

Test 3: Payment Gateway Latency Profile (k6 + thresholds per endpoint)

// k6/gateway-latency-profile.js
import http from 'k6/http';
import { check, group } from 'k6';

export const options = {
  vus: 200,
  duration: '5m',
  thresholds: {
    // Per-endpoint SLA từ ISO 20022 integration requirements
    'http_req_duration{endpoint:pacs008_parse}':  ['p(99)<100'], // XML parse < 100ms
    'http_req_duration{endpoint:transfer_submit}': ['p(99)<200'], // Submit < 200ms
    'http_req_duration{endpoint:status_query}':    ['p(95)<20'],  // Status check < 20ms (hot path)
    'http_req_failed': ['rate<0.001'],
  },
};

export default function () {
  group('pacs.008 Parse', () => {
    const res = http.post(
      `${__ENV.BASE_URL}/api/v1/payments/parse`,
      open('./fixtures/pacs008-sample.xml'),
      {
        headers: { 'Content-Type': 'application/xml' },
        tags: { endpoint: 'pacs008_parse' },
      }
    );
    check(res, { 'parse ok': (r) => r.status === 200 });
  });

  group('Transfer Submit', () => {
    const res = http.post(
      `${__ENV.BASE_URL}/api/v1/transfers`,
      JSON.stringify({ /* transfer payload */ }),
      {
        headers: { 'Content-Type': 'application/json' },
        tags: { endpoint: 'transfer_submit' },
      }
    );
    check(res, { 'submit ok': (r) => r.status === 201 });
  });

  group('Status Query', () => {
    const txId = `test-${__VU}-${__ITER - 1}`;
    const res = http.get(
      `${__ENV.BASE_URL}/api/v1/transfers/${txId}/status`,
      { tags: { endpoint: 'status_query' } }
    );
    check(res, { 'status ok': (r) => r.status === 200 || r.status === 404 });
  });
}

Pre-Production Load Testing Gates

#!/bin/bash
# scripts/pre-prod-load-gate.sh
# Chạy trong CI/CD pipeline trước deploy production

set -e

echo "=== Core Banking Load Testing Gate ==="

# 1. Ledger throughput SLA
k6 run --quiet \
  --env BASE_URL=$STAGING_URL \
  --env API_TOKEN=$STAGING_TOKEN \
  k6/ledger-throughput.js
echo "✅ Ledger throughput: P99 < 50ms"

# 2. Idempotency stress
k6 run --quiet \
  --env BASE_URL=$STAGING_URL \
  k6/idempotency-stress.js
echo "✅ Idempotency: no duplicate transactions under 100-VU storm"

# 3. Gateway latency profile
k6 run --quiet \
  --env BASE_URL=$STAGING_URL \
  k6/gateway-latency-profile.js
echo "✅ Gateway latency: pacs.008 parse < 100ms, transfer < 200ms, query < 20ms"

echo ""
echo "All load testing gates PASSED — safe to deploy to production"

KPI của Load Testing phase:

MetricPass ThresholdFail → Action
Transfer P99≤50msInvestigate DB locking, connection pool
Error Rate≤0.1%Check idempotency logic, retry policy
Idempotency100% same tx_idBug in unique constraint / cache logic
pacs.008 parse P99≤100msProfile XML streaming parser
Status query P95≤20msCheck Redis cache hit rate

Phụ Lục: Testing Tools & Libraries

ToolDùng choLanguage
libfaketimeClock drift injectionC/Linux
tc (traffic control)Network partition simulationLinux
toxiproxyProgrammable network conditionsMulti-language
Flink TestHarnessOperator unit testingJava
Flink MiniClusterIntegration testingJava
Go testing/iotestI/O failure injectionGo
testcontainers-goDB containers for integration testsGo
k6Load testing HTTP APIsJavaScript
chaos-meshKubernetes chaos engineeringYAML/Go

FAQ

Bao nhiêu coverage là đủ cho một Core Banking system?

Không có con số tuyệt đối, nhưng nguyên tắc 3-layer:

  • Unit tests: ≥90% coverage cho business logic (balance calculations, state machines)
  • Integration tests: Toàn bộ happy path + top 5 failure scenarios cho mỗi API
  • Chaos engineering: Ít nhất 1 lần mỗi sprint với network partition và clock skew

Quan trọng hơn coverage % là coverage của failure modes — đặc biệt là concurrent scenarios không thể test bằng sequential unit tests.

TestHarness tốt cho operator-level unit tests (kiểm tra một operator riêng lẻ với mock inputs). Nhưng để test toàn bộ pipeline (Kafka source → CEP → ML inference → Kafka sink), cần dùng MiniCluster hoặc environment staging với real Kafka/Flink cluster.

Nên mock hay integration test cho database trong ledger tests?

Không mock database cho ledger invariant tests. Sử dụng testcontainers-go để spin up real PostgreSQL/TiDB instance trong Docker — kiểm tra thực sự các race conditions, deadlocks, và ACID properties mà mock không thể reproduce. Mock chỉ phù hợp cho external services (Kafka, NAPAS gateway, notification service).

Làm thế nào detect silent data corruption trong production?

Chạy continuous reconciliation — một background job đọc từ event store và recompute balance, so sánh với CQRS read model. Bất kỳ difference nào → P1 alert. Interval phụ thuộc vào transaction volume: 5 phút cho hệ thống lớn, 1 giờ cho hệ thống nhỏ. Đây là “immune system” của Core Banking.


Tổng Kết Series: Core Banking Architecture

Qua 8 phần của series, chúng ta đã đi qua toàn bộ stack của một hệ thống Core Banking production-grade:

PhầnKiến thức Cốt lõiBenchmark Quan Trọng
1Double-Entry Ledger Schema, TigerBeetle Zig1M TPS single-threaded
2Distributed SQL, TrueTime, HLC, Percolator1-3ms TSO overhead
3Event Sourcing, CQRS, Outbox Pattern<1ms vs 200ms balance lookup
4Saga Orchestration, Temporal, DLQ10-50ms per orchestration hop
5ISO 20022, XML streaming parser10-30x JSON faster than XML
6FAPI 2.0, DPoP, mTLS<0.1ms pooled mTLS overhead
7Flink CEP, RocksDB, ML inference50-100ms fraud scoring SLA
8SDET handbook, chaos engineering0 double-spends, 0 imbalances

Nội dung liên quan để đọc thêm: