Stage 7 · Master
Phase 9 — Payment Service
Testing
Test the payment module with raw-byte fixtures, concurrent initiation races, and Postgres-backed transition assertions that match production failure modes.
Table-Driven Tests Lock the Transition Lattice in Place
The transition guard is tiny and easy to break with an innocent refactor. That is exactly why it deserves table-driven tests against a real local Postgres instance. A unit test against pure Go maps proves ranks exist; a database-backed test proves that equal-rank and regressive transitions actually result in zero updated rows under the same SQL path your webhook handler uses in production.
package payment
import (
"context"
"fmt"
"os"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/rs/zerolog"
)
type fakeProvider struct {
calls atomic.Int32
delay time.Duration
}
type fakeChargeDirectory struct {
calls atomic.Int32
snapshot ChargeSnapshot
}
func (d *fakeChargeDirectory) ReserveUnpaid(
_ context.Context,
orgID uuid.UUID,
chargeIDs []uuid.UUID,
) ([]ChargeSnapshot, error) {
d.calls.Add(1)
if orgID == uuid.Nil || len(chargeIDs) != 1 || chargeIDs[0] != d.snapshot.ID {
return nil, ErrChargeSelectionInvalid
}
return []ChargeSnapshot{d.snapshot}, nil
}
func (p *fakeProvider) Name() string {
return "razorpay"
}
func (p *fakeProvider) Initiate(ctx context.Context, req InitiateRequest) (InitiateResult, error) {
p.calls.Add(1)
if p.delay > 0 {
time.Sleep(p.delay)
}
return InitiateResult{
Provider: "razorpay",
ProviderReference: "order_" + req.PaymentID,
CheckoutURL: "https://pay.example/checkout/" + req.PaymentID,
ExpiresAt: time.Now().Add(15 * time.Minute),
RawResponse: []byte("{"ok":true}"),
}, nil
}
func (p *fakeProvider) VerifyWebhookSignature([]byte, string) bool {
return false
}
func (p *fakeProvider) ParseEvent([]byte) (Event, error) {
return Event{}, nil
}
func openTestPool(t *testing.T) *pgxpool.Pool {
t.Helper()
dsn := os.Getenv("PAYMENT_SERVICE_DATABASE_URL")
if dsn == "" {
t.Skip("PAYMENT_SERVICE_DATABASE_URL is not set")
}
pool, err := pgxpool.New(context.Background(), dsn)
if err != nil {
t.Fatalf("open pgx pool: %v", err)
}
t.Cleanup(pool.Close)
return pool
}
func mustExec(t *testing.T, ctx context.Context, pool *pgxpool.Pool, sql string, args ...any) {
t.Helper()
if _, err := pool.Exec(ctx, sql, args...); err != nil {
t.Fatalf("exec %q: %v", sql, err)
}
}
func seedPaymentFixture(t *testing.T, ctx context.Context, pool *pgxpool.Pool) (uuid.UUID, uuid.UUID, uuid.UUID, uuid.UUID) {
t.Helper()
orgID := uuid.New()
flatID := uuid.New()
residentID := uuid.New()
userID := uuid.New()
chargeID := uuid.New()
// This container runs payment-service's migrations only. organizations,
// flats, residents and maintenance_charges do not exist here — the ids
// above are the same logical UUIDs production carries, and the services
// that own those rows are represented by stubs in the test's composition.
_ = userID
return orgID, flatID, residentID, chargeID
}
func insertPaymentRow(t *testing.T, ctx context.Context, pool *pgxpool.Pool, orgID, flatID, residentID uuid.UUID, status Status) uuid.UUID {
t.Helper()
paymentID := uuid.New()
rank, err := RankFor(status)
if err != nil {
t.Fatalf("rank lookup: %v", err)
}
mustExec(t, ctx, pool, "INSERT INTO payments (id, org_id, flat_id, resident_id, provider, provider_reference, status, status_rank, amount_paise, currency, initiated_at) VALUES ($1, $2, $3, $4, 'razorpay', $5, $6, $7, 250000, 'INR', now())", paymentID, orgID, flatID, residentID, "order_"+paymentID.String(), status, rank)
return paymentID
}
func TestApplyTransitionTableDriven(t *testing.T) {
ctx := context.Background()
pool := openTestPool(t)
store := NewTransitionStore(zerolog.Nop())
orgID, flatID, residentID, _ := seedPaymentFixture(t, ctx, pool)
cases := []struct {
name string
initial Status
next Status
wantAccepted bool
wantFinal Status
}{
{name: "forward transition", initial: PaymentStatusCreated, next: PaymentStatusInitiated, wantAccepted: true, wantFinal: PaymentStatusInitiated},
{name: "regressive transition rejected", initial: PaymentStatusCaptured, next: PaymentStatusAuthorized, wantAccepted: false, wantFinal: PaymentStatusCaptured},
{name: "equal rank rejected", initial: PaymentStatusAuthorized, next: PaymentStatusAuthorized, wantAccepted: false, wantFinal: PaymentStatusAuthorized},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
paymentID := insertPaymentRow(t, ctx, pool, orgID, flatID, residentID, tc.initial)
tx, err := pool.Begin(ctx)
if err != nil {
t.Fatalf("begin tx: %v", err)
}
accepted, err := store.Apply(ctx, tx, orgID, paymentID, tc.next, "evt-"+tc.name)
if err != nil {
t.Fatalf("apply transition: %v", err)
}
if err := tx.Commit(ctx); err != nil {
t.Fatalf("commit tx: %v", err)
}
if accepted != tc.wantAccepted {
t.Fatalf("accepted=%v want %v", accepted, tc.wantAccepted)
}
var got Status
if err := pool.QueryRow(ctx, "SELECT status FROM payments WHERE org_id = $1 AND id = $2", orgID, paymentID).Scan(&got); err != nil {
t.Fatalf("select payment status: %v", err)
}
if got != tc.wantFinal {
t.Fatalf("final status=%s want %s", got, tc.wantFinal)
}
})
}
}
func TestInitiatePaymentConcurrentIdempotency(t *testing.T) {
ctx := context.Background()
pool := openTestPool(t)
provider := &fakeProvider{delay: 20 * time.Millisecond}
orgID, flatID, residentID, chargeID := seedPaymentFixture(t, ctx, pool)
charges := &fakeChargeDirectory{
snapshot: ChargeSnapshot{
ID: chargeID,
FlatID: flatID,
ResidentID: residentID,
Description: "August maintenance",
BillingMonth: time.Date(2026, time.August, 1, 0, 0, 0, 0, time.UTC),
AmountDuePaise: 250000,
},
}
service := NewService(pool, provider, charges)
body := []byte(fmt.Sprintf("{"charge_ids":["%s"],"amount_paise":250000,"currency":"INR"}", chargeID))
input := InitiatePaymentInput{
ChargeIDs: []uuid.UUID{chargeID},
AmountPaise: 250000,
Currency: "INR",
}
responses := make([]InitiatePaymentResponse, 2)
errorsOut := make([]error, 2)
var wg sync.WaitGroup
for i := 0; i < 2; i++ {
wg.Add(1)
go func(index int) {
defer wg.Done()
responses[index], errorsOut[index] = service.InitiatePayment(ctx, orgID, residentID, "idem-concurrent-001", body, input)
}(i)
}
wg.Wait()
for i, err := range errorsOut {
if err != nil {
t.Fatalf("call %d returned error: %v", i, err)
}
}
if responses[0].PaymentID != responses[1].PaymentID {
t.Fatalf("payment ids differ: %s vs %s", responses[0].PaymentID, responses[1].PaymentID)
}
if provider.calls.Load() != 1 {
t.Fatalf("provider calls=%d want 1", provider.calls.Load())
}
if charges.calls.Load() != 1 {
t.Fatalf("charge reservations=%d want 1", charges.calls.Load())
}
var paymentCount int
if err := pool.QueryRow(ctx, "SELECT count(*) FROM payments WHERE org_id = $1", orgID).Scan(&paymentCount); err != nil {
t.Fatalf("count payments: %v", err)
}
if paymentCount != 1 {
t.Fatalf("payment rows=%d want 1", paymentCount)
}
}The race test is the proof that matters: two goroutines, one tenant-scoped advisory lock, one charge reservation, one provider call, and one payment row. The fake directory is not generic mocking boilerplate; it proves an idempotent retry cannot reserve the same maintenance debt twice.
Golden Raw Payload Fixtures Catch Signature Regressions
Webhook tests are only trustworthy if they preserve the exact raw bytes the provider signed. Golden fixtures let you test three sharp cases cheaply: a valid payload with a matching HMAC, a tampered payload reusing the old signature, and a valid signature whose timestamp is too old and must be rejected as a replay. Freezing the clock removes time-of-day flakes and makes exact HTTP status assertions stable.
package payment
import (
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/gin-gonic/gin"
"github.com/rs/zerolog"
)
type hmacTestProvider struct {
secret string
}
func (p *hmacTestProvider) Name() string {
return "razorpay"
}
func (p *hmacTestProvider) Initiate(context.Context, InitiateRequest) (InitiateResult, error) {
panic("unused in webhook tests")
}
func (p *hmacTestProvider) VerifyWebhookSignature(payload []byte, signature string) bool {
mac := hmac.New(sha256.New, []byte(p.secret))
mac.Write(payload)
expected := hex.EncodeToString(mac.Sum(nil))
return hmac.Equal([]byte(expected), []byte(signature))
}
func (p *hmacTestProvider) ParseEvent(payload []byte) (Event, error) {
var raw map[string]any
if err := json.Unmarshal(payload, &raw); err != nil {
return Event{}, err
}
occurredAt, err := time.Parse(time.RFC3339, raw["occurred_at"].(string))
if err != nil {
return Event{}, err
}
return Event{
Provider: "razorpay",
EventID: raw["event_id"].(string),
PaymentReference: raw["payment_reference"].(string),
EventType: EventType(raw["event_type"].(string)),
AmountPaise: int64(raw["amount_paise"].(float64)),
Currency: raw["currency"].(string),
OccurredAt: occurredAt,
}, nil
}
func signPayload(secret string, payload []byte) string {
mac := hmac.New(sha256.New, []byte(secret))
mac.Write(payload)
return hex.EncodeToString(mac.Sum(nil))
}
func TestWebhookHandler_RejectsTamperedOrExpiredPayloads(t *testing.T) {
gin.SetMode(gin.TestMode)
ctx := context.Background()
pool := openTestPool(t)
orgID, flatID, residentID, _ := seedPaymentFixture(t, ctx, pool)
paymentID := insertPaymentRow(t, ctx, pool, orgID, flatID, residentID, PaymentStatusInitiated)
providerRef := "order_" + paymentID.String()
mustExec(t, ctx, pool, "UPDATE payments SET provider_reference = $3 WHERE org_id = $1 AND id = $2", orgID, paymentID, providerRef)
provider := &hmacTestProvider{secret: "topsecret"}
handler := NewWebhookHandler(pool, provider, NewInboxStore(), NewTransitionStore(zerolog.Nop()), func() time.Time {
return time.Date(2026, 8, 2, 4, 0, 0, 0, time.UTC)
})
router := gin.New()
router.POST("/payments/webhooks/razorpay", handler.Handle)
payload := []byte("{"event_id":"evt_001","payment_reference":"" + providerRef + "","event_type":"captured","amount_paise":250000,"currency":"INR","occurred_at":"2026-08-02T03:59:00Z"}")
validSignature := signPayload(provider.secret, payload)
cases := []struct {
name string
body []byte
signature string
timestamp string
wantStatusCode int
}{
{name: "valid payload", body: payload, signature: validSignature, timestamp: "2026-08-02T03:59:00Z", wantStatusCode: http.StatusOK},
{name: "tampered payload", body: []byte(strings.Replace(string(payload), "250000", "260000", 1)), signature: validSignature, timestamp: "2026-08-02T03:59:00Z", wantStatusCode: http.StatusUnauthorized},
{name: "expired timestamp", body: payload, signature: validSignature, timestamp: "2026-08-01T20:00:00Z", wantStatusCode: http.StatusBadRequest},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/payments/webhooks/razorpay", strings.NewReader(string(tc.body)))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Payment-Signature", tc.signature)
req.Header.Set("X-Payment-Timestamp", tc.timestamp)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != tc.wantStatusCode {
t.Fatalf("status=%d want %d body=%s", w.Code, tc.wantStatusCode, w.Body.String())
}
})
}
}The golden fixture is the raw string assigned to payload. The tampered case changes only one amount digit while reusing the original signature, which is exactly the attack or transport corruption you want to catch.
Concurrent Initiation Tests Prove Only One Provider Call Escapes
The most payment-specific race is two identical initiation requests landing at the same time. The test double counts provider calls, the database count proves only one payment row was persisted, and the shared response ID proves callers receive the same business outcome. If the test shows two provider calls, your idempotency record is being claimed too late.
Webhook expiry and reconciliation windows are time-based. If you let tests call time.Now() directly, a slow CI runner will eventually turn a healthy fixture into a flaky failure that tells you nothing about the real code path.
Targeted Local Test Commands Should Fail Fast on the Payment Package
- Failure mode: an idempotency race that only appears under two simultaneous goroutines will be invisible to happy-path unit tests.
- Security concern: the webhook fixture suite should prove tampered or replayed payloads are rejected before any database write occurs.
- Verification target: run the narrow payment package tests first so failures are isolated to the transition, webhook, or initiation path you just changed.
Applied exercise
Design a failure-first payment regression pack
A teammate is about to refactor payment SQL into a shared repository package, and you want a compact regression pack that catches the most expensive mistakes before code review even begins.
- Choose the minimum set of payment tests you would make blocking in CI and explain why each one earns that slot.
- Add one assertion that would catch a missing org_id predicate in a payment query.
- Describe how you would surface provider call counts from the fake provider when the concurrency test fails.
- Propose one additional negative webhook fixture that is not already in the lesson and explain the bug it protects against.
Deliverable
A CI-oriented payment test plan listing the blocking tests, the tenant-isolation assertion, and one extra negative fixture.
Completion checks
- The selected tests cover transition monotonicity, webhook authenticity, and concurrent initiation idempotency.
- At least one check would fail if org_id were accidentally dropped from a repository query.
- The added webhook fixture targets a distinct bug instead of repeating the tampered-body case.
Why is the concurrent initiation test more valuable than a single-threaded happy-path test for idempotency?
Mark this lesson complete to store local progress and unlock a cleaner resume path the next time you visit.