Stage 7 · Master
Phase 14 — Notification Service
Testing
Replace every provider with an in-memory fake that satisfies the same port, and prove the SKIP LOCKED claim query actually prevents duplicate sends under real concurrency.
A Fake Is Not a Mock That Asserts Call Order
Because EmailSender, SMSSender, and PushSender are small interfaces, a test-only implementation that simply records what it was asked to send is enough to verify application logic without any network call, and without asserting brittle details like exact call order.
package testfakes
import (
"context"
"sync"
"github.com/hoa-platform/backend/services/notification-service/internal/domain"
)
type RecordingSMSSender struct {
mu sync.Mutex
sent []domain.SMSMessage
}
func (r *RecordingSMSSender) Send(ctx context.Context, msg domain.SMSMessage) (domain.ProviderMessageID, error) {
r.mu.Lock()
defer r.mu.Unlock()
r.sent = append(r.sent, msg)
return "fake-message-id", nil
}
func (r *RecordingSMSSender) Sent() []domain.SMSMessage {
r.mu.Lock()
defer r.mu.Unlock()
return append([]domain.SMSMessage{}, r.sent...)
}Sent() returns a copy, not the internal slice, so a test cannot accidentally mutate the recorder's state through the value it reads back — a small discipline that keeps concurrent tests honest.
Every Override/Default Combination, One Table
package postgresadapter_test
import (
"context"
"testing"
)
func TestResolve_OrganizationOverrideAndPlatformFallback(t *testing.T) {
pool := openTestPool(t)
seedPlatformDefault(t, pool, "payment_receipt", "email", "Your payment was received")
orgWithOverride := seedOrganization(t, pool)
seedOrganizationTemplate(t, pool, orgWithOverride, "payment_receipt", "email", "Thanks from Meridian Gardens!")
orgWithoutOverride := seedOrganization(t, pool)
repo := newTemplateRepository(t, pool)
cases := []struct {
name string
orgID string
wantSubject string
}{
{"organization with its own template", orgWithOverride, "Thanks from Meridian Gardens!"},
{"organization falling back to platform default", orgWithoutOverride, "Your payment was received"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
tmpl, err := repo.Resolve(context.Background(), tc.orgID, "payment_receipt", "email")
if err != nil {
t.Fatal(err)
}
if tmpl.Subject != tc.wantSubject {
t.Fatalf("got subject %q, want %q", tmpl.Subject, tc.wantSubject)
}
})
}
}Both cases run against the same seeded platform default, so the test also implicitly proves the two organizations don't interfere with each other's resolution.
Prove the Claim Query's Safety Property Directly
package postgresadapter_test
import (
"context"
"sync"
"testing"
)
func TestClaim_TwoConcurrentWorkers(t *testing.T) {
pool := openTestPool(t)
seedPendingJobs(t, pool, 20)
repo := newJobRepository(t, pool)
var wg sync.WaitGroup
claimedIDs := make(chan string, 40)
worker := func() {
defer wg.Done()
jobs, finish, err := repo.Claim(context.Background(), 10)
if err != nil {
t.Error(err)
return
}
for _, j := range jobs {
claimedIDs <- j.ID
}
_ = finish(true)
}
wg.Add(2)
go worker()
go worker()
wg.Wait()
close(claimedIDs)
seen := map[string]bool{}
total := 0
for id := range claimedIDs {
if seen[id] {
t.Fatalf("job %s was claimed by more than one worker", id)
}
seen[id] = true
total++
}
if total != 20 {
t.Fatalf("got %d claimed jobs total, want 20", total)
}
}This test would fail immediately if SKIP LOCKED were removed and replaced with a plain FOR UPDATE, since one worker would then block until the other committed rather than genuinely running concurrently.
Fast Fakes First, Real Postgres Second
Applied exercise
Write a regression test for the retry-then-dead transition
Nobody has yet asserted that a job exhausts its max_attempts and lands in 'dead' rather than retrying forever.
- Seed a job with max_attempts set to 2.
- Simulate two failed delivery attempts using a fake sender that always returns ErrRetryable.
- Assert the job's status is 'dead' after the second failure, not 'failed' awaiting a third attempt.
- Assert notification_deliveries has exactly two recorded attempts, both marked as errors.
Deliverable
A new test in worker_test.go covering the exhausted-retries path end to end.
Completion checks
- The test fails if MarkFailed is called a third time instead of MarkDead on the second failure.
- Both delivery attempts are visible in the evidence table, not just the final one.
Why does TestClaim_TwoConcurrentWorkers assert on the total count of claimed jobs in addition to checking for duplicates?
Mark this lesson complete to store local progress and unlock a cleaner resume path the next time you visit.