Stage 7 · Master
Phase 8 — Maintenance Service
Testing
Proving money arithmetic, determinism, idempotency, and concurrency safety
Four Different Claims, Four Different Test Styles
Prerequisites: Organization Service owns table-driven unit-test structure and integration-test wiring; Resident Service's ownership lesson owns adversarial transaction tests. This lesson uses those tools only to prove four Maintenance claims: integer money never drifts, invoice output is deterministic, reruns stay idempotent, and concurrent payments cannot overfill an invoice.
Money Arithmetic: No Drift Over Many Operations
package domain
import "testing"
func TestProrateBySqft_RoundsHalfAwayFromZero(t *testing.T) {
tests := []struct {
name string
rate Paise
areaSqft float64
want Paise
}{
{"exact multiple", 100, 10, 1000},
{"rounds up at .5", 100, 10.005, 1001},
{"rounds down below .5", 100, 10.004, 1000},
{"zero area", 250, 0, 0},
{"fractional rate times fractional area", 33, 12.5, 413},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := ProrateBySqft(tt.rate, tt.areaSqft)
if got != tt.want {
t.Errorf("ProrateBySqft(%d, %v) = %d, want %d", tt.rate, tt.areaSqft, got, tt.want)
}
})
}
}
// TestPaise_NoDriftOverManyAdditions guards against the accumulation bug
// that float64 money would have: adding the same small amount ten
// thousand times must equal that amount multiplied by ten thousand,
// exactly, every time.
func TestPaise_NoDriftOverManyAdditions(t *testing.T) {
var total Paise
const iterations = 10000
const perOp Paise = 7
for i := 0; i < iterations; i++ {
total = total.Add(perOp)
}
want := Paise(iterations * int64(perOp))
if total != want {
t.Fatalf("after %d additions: got %d, want %d (drift of %d)", iterations, total, want, total-want)
}
}The drift test would be meaningless for float64 money too — floats can pass a test like this for certain values by coincidence. The point isn't that this specific test would fail on floats; it's that no amount of testing can make float64 money exact, while Paise is exact by construction.
Invoice Generation: Determinism
package service
import (
"testing"
"time"
"github.com/google/uuid"
"github.com/hoa-platform/backend/services/maintenance-service/internal/domain"
)
func TestGenerateMonthlyInvoice_Deterministic(t *testing.T) {
tenantID, flatID, residentID := uuid.New(), uuid.New(), uuid.New()
billingPeriod := time.Date(2024, 6, 1, 0, 0, 0, 0, time.UTC)
charges := []domain.ActiveCharge{
{ChargeTypeID: uuid.New(), ChargeName: "General Maintenance", RateBasis: domain.RateBasisPerSqft, RatePaise: 250},
{ChargeTypeID: uuid.New(), ChargeName: "Sinking Fund", RateBasis: domain.RateBasisFlat, RatePaise: 50000},
}
inv1, items1 := GenerateMonthlyInvoice(tenantID, flatID, residentID, billingPeriod, 950.5, charges)
inv2, items2 := GenerateMonthlyInvoice(tenantID, flatID, residentID, billingPeriod, 950.5, charges)
if inv1.TotalPaise != inv2.TotalPaise {
t.Fatalf("total mismatch across identical calls: %d vs %d", inv1.TotalPaise, inv2.TotalPaise)
}
if len(items1) != len(items2) {
t.Fatalf("line item count mismatch: %d vs %d", len(items1), len(items2))
}
for i := range items1 {
if items1[i].AmountPaise != items2[i].AmountPaise || items1[i].Description != items2[i].Description {
t.Fatalf("line item %d differs across identical calls: %+v vs %+v", i, items1[i], items2[i])
}
}
const wantPerSqft = domain.Paise(237625) // ProrateBySqft(250, 950.5) = 250 * 950.5, no rounding needed
const wantTotal = wantPerSqft + 50000
if inv1.TotalPaise != wantTotal {
t.Errorf("total = %d, want %d", inv1.TotalPaise, wantTotal)
}
}
func TestGenerateMonthlyInvoice_NoChargesProducesZeroTotalInvoice(t *testing.T) {
inv, items := GenerateMonthlyInvoice(uuid.New(), uuid.New(), uuid.New(), time.Now(), 500, nil)
if inv.TotalPaise != 0 {
t.Errorf("total = %d, want 0", inv.TotalPaise)
}
if len(items) != 0 {
t.Errorf("line items = %d, want 0", len(items))
}
if inv.Status != domain.InvoiceStatusIssued {
t.Errorf("status = %s, want issued", inv.Status)
}
}The two calls share every input except the internally generated UUIDs, so the assertions compare everything but the id fields — the same distinction the gen-preview tool's diff relied on in the previous lesson.
Billing Runner: Rerun Idempotency (Integration)
Rerun idempotency is a database claim, not a fake-repository claim: the invoices unique index and ON CONFLICT path must be exercised together, then the billing_runs counters must prove StartRun reset the second invocation instead of accumulating stale totals.
//go:build integration
package postgres
import (
"context"
"testing"
"time"
"github.com/google/uuid"
"github.com/hoa-platform/backend/pkg/tenantctx"
"github.com/hoa-platform/backend/services/maintenance-service/internal/domain"
"github.com/hoa-platform/backend/services/maintenance-service/internal/service"
)
// fakeFlatLister and fakeOwnerResolver stand in for the HTTP clients —
// this test's job is to prove the database layer's idempotency guarantee,
// not to re-test HTTP behavior already covered elsewhere.
type fakeFlatLister struct{ flats []domain.FlatSummary }
func (f fakeFlatLister) ListActive(ctx context.Context) ([]domain.FlatSummary, error) {
return f.flats, nil
}
type fakeOwnerResolver struct{ residentID uuid.UUID }
func (f fakeOwnerResolver) CurrentOwner(ctx context.Context, flatID uuid.UUID) (uuid.UUID, error) {
return f.residentID, nil
}
func TestBillingRunner_RerunIsIdempotent(t *testing.T) {
pool := newTestPool(t)
tenantID := uuid.New()
flatID := uuid.New()
ctx := tenantctx.WithTenantID(context.Background(), tenantID)
billingPeriod := time.Date(2024, 6, 1, 0, 0, 0, 0, time.UTC)
chargeTypeRepo := NewChargeTypeRepository(pool)
chargeVersionRepo := NewChargeVersionRepository(pool)
ct, err := chargeTypeRepo.Create(ctx, domain.ChargeType{TenantID: tenantID, Code: "GEN", Name: "General", RateBasis: domain.RateBasisFlat})
if err != nil {
t.Fatalf("seeding charge type: %v", err)
}
if _, err := chargeVersionRepo.ReviseRate(ctx, tenantID, ct.ID, 50000, billingPeriod); err != nil {
t.Fatalf("seeding charge version: %v", err)
}
runner := service.NewBillingRunner(
fakeFlatLister{flats: []domain.FlatSummary{{ID: flatID, AreaSqft: 800}}},
fakeOwnerResolver{residentID: uuid.New()},
chargeVersionRepo,
NewBillingRunRepository(pool),
NewInvoiceRepository(pool),
)
if _, err := runner.GenerateForPeriod(ctx, billingPeriod); err != nil {
t.Fatalf("first run: %v", err)
}
if _, err := runner.GenerateForPeriod(ctx, billingPeriod); err != nil {
t.Fatalf("second run: %v", err)
}
var count int
if err := pool.QueryRow(ctx, `SELECT count(*) FROM invoices WHERE tenant_id = $1 AND flat_id = $2 AND billing_period = $3`,
tenantID, flatID, billingPeriod).Scan(&count); err != nil {
t.Fatalf("counting invoices: %v", err)
}
if count != 1 {
t.Fatalf("expected exactly 1 invoice after two runs, got %d", count)
}
var flatsTotal int
if err := pool.QueryRow(ctx, `SELECT flats_total FROM billing_runs WHERE tenant_id = $1 AND billing_period = $2`,
tenantID, billingPeriod).Scan(&flatsTotal); err != nil {
t.Fatalf("reading billing run: %v", err)
}
if flatsTotal != 1 {
t.Fatalf("expected flats_total 1 after rerun (StartRun resets counters), got %d", flatsTotal)
}
}This test would have caught the GetOrCreate counting bug discussed in the Transactions lesson directly: flatsTotal asserts 1, not 2, after two runs — a naive accumulating design fails this assertion immediately.
Concurrent Payments Never Exceed the Invoice Balance
The FOR UPDATE lock in PaymentRepository.RecordPayment is a claim about behavior under concurrency, which by definition cannot be verified by calling the method once. This test fires many goroutines at the same invoice simultaneously and checks the final paid total never exceeds the total, run under -race to also catch any accidental shared mutable state in the client code path.
//go:build integration
package postgres
import (
"context"
"fmt"
"sync"
"testing"
"github.com/google/uuid"
"github.com/hoa-platform/backend/services/maintenance-service/internal/domain"
)
// TestPaymentRepository_ConcurrentPayments_NeverExceedsBalance fires 20
// goroutines, each attempting to pay Rs.100 (10000 paise) against a
// Rs.1000 (100000 paise) invoice — only 10 of them should succeed in
// applying their payment; the rest must observe ErrPaymentExceedsInvoiceBalance
// once the balance is exhausted, and none may observe an invoice that
// briefly appears to exceed its total.
func TestPaymentRepository_ConcurrentPayments_NeverExceedsBalance(t *testing.T) {
pool := newTestPool(t)
tenantID, flatID, residentID := uuid.New(), uuid.New(), uuid.New()
var invoiceID uuid.UUID
err := pool.QueryRow(context.Background(), `
INSERT INTO invoices (tenant_id, flat_id, resident_id, billing_period, status, total_paise)
VALUES ($1, $2, $3, '2024-06-01', 'issued', 100000)
RETURNING id`,
tenantID, flatID, residentID,
).Scan(&invoiceID)
if err != nil {
t.Fatalf("seeding invoice: %v", err)
}
repo := NewPaymentRepository(pool)
const attempts = 20
const perPayment = domain.Paise(10000)
var wg sync.WaitGroup
successes := make([]bool, attempts)
for i := 0; i < attempts; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
_, _, err := repo.RecordPayment(context.Background(), tenantID, invoiceID, perPayment, domain.PaymentMethodUPI, fmt.Sprintf("concurrent-%d", i))
successes[i] = err == nil
}(i)
}
wg.Wait()
successCount := 0
for _, ok := range successes {
if ok {
successCount++
}
}
if successCount != 10 {
t.Fatalf("expected exactly 10 successful payments (100000 / 10000), got %d", successCount)
}
var paidPaise int64
if err := pool.QueryRow(context.Background(), `SELECT paid_paise FROM invoices WHERE id = $1`, invoiceID).Scan(&paidPaise); err != nil {
t.Fatalf("reading final invoice: %v", err)
}
if paidPaise != 100000 {
t.Fatalf("paid_paise = %d, want exactly 100000 (never more, never less)", paidPaise)
}
}Every goroutine uses a distinct idempotency key — this test is specifically about the FOR UPDATE serialization, not about the idempotency guard from the previous lesson, which has its own dedicated test.
Executing the Four Maintenance Claims
A repository test appears only where the guarantee lives in Postgres; a domain or service test appears where pure Go owns the outcome. The suite is organized around Maintenance failure modes rather than package boundaries.
Applied exercise
Write a test proving the write-off state machine guard
PaymentRepository.WriteOff is guarded by CanTransition but has no dedicated test yet — the payment-status lesson only verified it manually with curl.
- Write an integration test that seeds an invoice directly at InvoiceStatusPaid (bypassing GenerateMonthlyInvoice, via a direct INSERT).
- Call WriteOff on that invoice and assert it returns domain.ErrIllegalStatusTransition.
- Write a second case seeding an invoice at InvoiceStatusIssued and assert WriteOff succeeds and the persisted status becomes written_off.
- Confirm neither case leaves the invoices row in an inconsistent state by re-reading it after the call.
Deliverable
A new TestPaymentRepository_WriteOff integration test file with both the illegal-transition and legal-transition cases passing.
Completion checks
- go test -tags=integration ./services/maintenance-service/internal/repository/postgres/... -run TestPaymentRepository_WriteOff -v passes both subtests.
- The illegal case's assertion checks errors.Is(err, domain.ErrIllegalStatusTransition), not a generic non-nil error check.
Why does TestBillingRunner_RerunIsIdempotent use real Testcontainers Postgres instead of fake FlatLister/OwnerResolver/BillingRunRepo/InvoiceRepo implementations?
Mark this lesson complete to store local progress and unlock a cleaner resume path the next time you visit.