Stage 7 · Master
Phase 10 — Complaint Service
Testing
Test the complaint module like a production workflow engine: exhaust the transition matrix, inject fake time into the SLA watcher, and prove RBAC decisions with a role-by-transition table.
How do you prove the state machine accepts only the listed edges?
A complaint workflow is small enough to test as a total relation rather than by example, but there is a trap in how you do it. Restating the adjacency table inside the test only proves the copy agrees with itself; the moment someone edits state.go and the test's private copy in the same commit, the safety net is gone. The test in this lesson reads allowedTransitions directly and then pins the resulting edge list, so a new edge is a deliberate, reviewable change rather than a silent one.
The failure mode is under-testing by example. If you only test open -> acknowledged and resolved -> closed, a bug that accidentally permits open -> resolved can ship unnoticed because the table itself was never exercised as a total relation. Matrix tests are boring in exactly the right way: they make the full legal surface obvious.
package complaint
import (
"errors"
"slices"
"sort"
"testing"
"github.com/google/uuid"
)
func TestCanTransitionMatrix(t *testing.T) {
statuses := []Status{
StatusOpen,
StatusAcknowledged,
StatusInProgress,
StatusResolved,
StatusClosed,
StatusReopened,
}
// The adjacency table in state.go is the single source of truth; copying it
// into the test would only prove the copy matches itself. Instead assert
// that CanTransition never disagrees with the table it reads from...
for _, from := range statuses {
for _, to := range statuses {
_, want := allowedTransitions[from][to]
if got := CanTransition(from, to); got != want {
t.Fatalf("CanTransition(%s, %s) = %t, want %t", from, to, got, want)
}
}
}
// ...and pin the exact edge set, so adding or deleting an edge is a
// deliberate act that fails this test until someone updates the list.
var edges []string
for from, targets := range allowedTransitions {
for to := range targets {
edges = append(edges, string(from)+"->"+string(to))
}
}
sort.Strings(edges)
want := []string{
"acknowledged->in_progress",
"acknowledged->resolved",
"closed->reopened",
"in_progress->resolved",
"open->acknowledged",
"reopened->acknowledged",
"resolved->closed",
"resolved->reopened",
}
if !slices.Equal(edges, want) {
t.Fatalf("transition edges changed:\ngot %v\nwant %v", edges, want)
}
}
func TestAuthorizeTransitionByRole(t *testing.T) {
raiser := uuid.MustParse("11111111-1111-1111-1111-111111111111")
staff := uuid.MustParse("22222222-2222-2222-2222-222222222222")
admin := uuid.MustParse("33333333-3333-3333-3333-333333333333")
resident := uuid.MustParse("44444444-4444-4444-4444-444444444444")
base := Complaint{
RaisedBy: raiser,
Category: CategoryPlumbing,
Severity: SeverityHigh,
}
cases := []struct {
name string
from Status
to Status
actor Actor
wantErr error
}{
{
name: "staff can acknowledge",
from: StatusOpen,
to: StatusAcknowledged,
actor: Actor{UserID: staff, Role: RoleStaff},
},
{
name: "resident cannot resolve",
from: StatusInProgress,
to: StatusResolved,
actor: Actor{UserID: resident, Role: RoleResident},
wantErr: ErrTransitionForbidden,
},
{
name: "raising resident can close",
from: StatusResolved,
to: StatusClosed,
actor: Actor{UserID: raiser, Role: RoleResident},
},
{
name: "org admin can close",
from: StatusResolved,
to: StatusClosed,
actor: Actor{UserID: admin, Role: RoleOrgAdmin},
},
{
name: "non-raiser resident cannot close",
from: StatusResolved,
to: StatusClosed,
actor: Actor{UserID: resident, Role: RoleResident},
wantErr: ErrTransitionForbidden,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
complaint := base
complaint.Status = tc.from
err := authorizeTransition(complaint, tc.to, tc.actor)
if !errors.Is(err, tc.wantErr) {
t.Fatalf("authorizeTransition() error = %v, want %v", err, tc.wantErr)
}
})
}
}One file can validate both the pure graph and the role matrix because both belong to workflow correctness, not repository behavior.
How does a fake clock make SLA watcher tests deterministic?
Never sleep in a unit test to wait for due_at to pass. A fake clock lets you set 'now' precisely, advance it instantly, and assert whether the watcher marked a breach before and after the deadline. That keeps the suite fast and avoids CI flakes caused by scheduler jitter. The watcher in lesson 2 was built with an injectable now function precisely for this reason.
package complaint
import (
"context"
"testing"
"time"
"github.com/google/uuid"
)
type fakeClock struct {
current time.Time
}
func (c *fakeClock) Now() time.Time {
return c.current
}
func (c *fakeClock) Advance(d time.Duration) {
c.current = c.current.Add(d)
}
type fakeSweepRepo struct {
complaints []Complaint
marked []uuid.UUID
}
func (r *fakeSweepRepo) FindBreachedComplaints(_ context.Context, now time.Time, limit int) ([]Complaint, error) {
out := make([]Complaint, 0, len(r.complaints))
for _, complaint := range r.complaints {
if complaint.DueAt == nil {
continue
}
if complaint.DueAt.After(now) {
continue
}
if complaint.SLABreached || IsTerminal(complaint.Status) {
continue
}
out = append(out, complaint)
}
if len(out) > limit {
out = out[:limit]
}
return out, nil
}
func (r *fakeSweepRepo) MarkSLABreached(_ context.Context, _ uuid.UUID, complaintID uuid.UUID) error {
r.marked = append(r.marked, complaintID)
for i := range r.complaints {
if r.complaints[i].ID == complaintID {
r.complaints[i].SLABreached = true
}
}
return nil
}
type fakePublisher struct {
published []uuid.UUID
}
func (p *fakePublisher) PublishSLABreached(_ context.Context, complaint Complaint) error {
p.published = append(p.published, complaint.ID)
return nil
}
func TestWatcherSweepMarksBreachAfterDueAt(t *testing.T) {
clock := &fakeClock{current: time.Date(2026, time.January, 12, 9, 0, 0, 0, time.UTC)}
orgID := uuid.MustParse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa")
complaintID := uuid.MustParse("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb")
due := clock.Now().Add(30 * time.Minute)
repo := &fakeSweepRepo{
complaints: []Complaint{
{
ID: complaintID,
OrgID: orgID,
Category: CategorySecurity,
Severity: SeverityCritical,
Status: StatusInProgress,
DueAt: &due,
},
},
}
publisher := &fakePublisher{}
watcher := NewWatcher(repo, publisher, time.Minute, 100, clock.Now)
if err := watcher.Sweep(context.Background()); err != nil {
t.Fatalf("first Sweep() error = %v", err)
}
if len(repo.marked) != 0 {
t.Fatalf("expected no breach before due_at, got %d", len(repo.marked))
}
clock.Advance(31 * time.Minute)
if err := watcher.Sweep(context.Background()); err != nil {
t.Fatalf("second Sweep() error = %v", err)
}
if len(repo.marked) != 1 {
t.Fatalf("expected one breach after due_at, got %d", len(repo.marked))
}
if len(publisher.published) != 1 || publisher.published[0] != complaintID {
t.Fatalf("expected publisher to emit complaint %s, got %#v", complaintID, publisher.published)
}
}The test jumps across time instantly, so it validates deadline behavior without waiting thirty real minutes or relying on ticker sleeps.
Which authorization cases belong in the complaint test matrix?
At minimum, test role × transition decisions that could hide abuse: resident resolving work, non-raising resident closing a complaint, staff acknowledging or resolving inside the correct org, and ORG_ADMIN overriding close after the grace path. This is a security test as much as a workflow test, because the most expensive complaint bugs are often 'the wrong person was allowed to move the ticket' rather than pure state-machine mistakes.
A test matrix also guards tenant isolation indirectly. Your service fakes should always feed actor.OrgID and complaint.OrgID through the same code paths you use in production. Otherwise you can have a beautiful role table that never exercises the repository-scoping branch where cross-org reads are supposed to die.
What is the smallest useful local test loop while building this module?
Applied exercise
Turn the complaint tests into a living policy table
A teammate adds a new SECURITY_GUARD role path for acknowledging security complaints only, and you want the tests to express that rule without making the matrix unreadable.
- Refactor the authorization test cases so role, from-state, to-state, category, and expected result are visible in one table-driven structure.
- Add a case showing SECURITY_GUARD may acknowledge a security complaint but may not resolve it.
- Add a negative case proving the same SECURITY_GUARD cannot acknowledge a plumbing complaint if policy says only staff can.
- Keep the transition graph tests separate so category-specific policy does not leak into CanTransition itself.
Deliverable
A cleaner table-driven test suite that expresses the new role rule without weakening the pure state-machine tests.
Completion checks
- Category-specific role logic appears in authorization tests, not in CanTransition tests.
- Positive and negative SECURITY_GUARD cases are both present.
- The suite stays deterministic and does not add sleeps or external dependencies.
Why is an exhaustive from/to transition test better than a couple of happy-path examples for complaints?
Mark this lesson complete to store local progress and unlock a cleaner resume path the next time you visit.