Stage 7 · Master
Phase 13 — Staff Service
Testing
Regression-test the transition map with table-driven cases and prove tenant isolation with a real Postgres, not a mock that would hide a missing WHERE clause.
Every Legal and Illegal Move Gets Its Own Row
The transition map from the first lesson is small enough to test exhaustively. A table-driven test enumerates every (from, event) pair once, so adding a new state later means adding rows to the table, not writing a new test function.
package domain_test
import (
"errors"
"testing"
"time"
"github.com/hoa-platform/backend/services/staff-service/internal/domain"
)
func TestStaffTransitions(t *testing.T) {
cases := []struct {
name string
from domain.Status
attempt func(*domain.Staff) error
wantErr error
}{
{"active to on_leave is allowed", domain.StatusActive, (*domain.Staff).PutOnLeave, nil},
{"terminated cannot reactivate", domain.StatusTerminated, (*domain.Staff).Reactivate, domain.ErrInvalidTransition},
{"suspended can reactivate", domain.StatusSuspended, (*domain.Staff).Reactivate, nil},
{"on_leave cannot suspend directly", domain.StatusOnLeave, (*domain.Staff).Suspend, domain.ErrInvalidTransition},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
staff := domain.Rehydrate("s1", "org1", "Test Staff", "+910000000000", domain.RoleGuard, tc.from, time.Now(), nil)
err := tc.attempt(staff)
if !errors.Is(err, tc.wantErr) {
t.Fatalf("got error %v, want %v", err, tc.wantErr)
}
})
}
}Each case names the pair it exercises so a failing test reads as a sentence, not a stack trace to decode.
A Mock Repository Cannot Catch a Missing organization_id
The Testcontainers harness, the build tag that keeps these tests out of the default run, and the scoped go test invocation are all inherited from Organization Service's integration-testing lesson and are not re-derived. What is specific to staff-service is which claim needs a real database at all: an in-memory fake keyed by staffID would happily return org A's record when queried with org B's tenant ID, because the fake has no WHERE clause to get wrong. That one bug class is the entire justification for the slower test below.
package postgresadapter_test
import (
"context"
"errors"
"testing"
"time"
postgresadapter "github.com/hoa-platform/backend/services/staff-service/internal/adapter/postgres"
"github.com/hoa-platform/backend/services/staff-service/internal/domain"
)
func TestFindByID_DoesNotCrossTenants(t *testing.T) {
pool := openTestPool(t) // helper: connects to the migrated test database
repo, err := postgresadapter.NewStaffRepository(pool)
if err != nil {
t.Fatal(err)
}
orgA, orgB := seedOrganization(t, pool), seedOrganization(t, pool)
staffInA, _ := domain.Hire("staff-a1", orgA, "Asha Rao", "+919876500001", domain.RoleGuard, time.Now())
if err := repo.Insert(context.Background(), staffInA); err != nil {
t.Fatal(err)
}
_, err = repo.FindByID(context.Background(), orgB, staffInA.ID())
if !errors.Is(err, postgresadapter.ErrNotFound) {
t.Fatalf("org B fetched org A's staff member: got err %v, want ErrNotFound", err)
}
}seedOrganization and openTestPool are small helpers shared across this service's repository tests; they are not shown again in later lessons once established here.
Lock the Security Property Into a Handler Test
The previous lessons established that cross-tenant access must answer 404, and role violations must answer 403. A handler test using httptest and Gin's test mode turns that written rule into something CI enforces on every future change to the handler.
package httpadapter_test
import (
"net/http"
"net/http/httptest"
"testing"
)
func TestTerminate_CrossTenantReturns404NotForbidden(t *testing.T) {
router := newTestRouter(t) // wires the real handler against a seeded test database
req := httptest.NewRequest(http.MethodPost, "/v1/staff/"+staffIDInOrgA+"/terminate", nil)
req.Header.Set("Authorization", "Bearer "+adminTokenForOrgB)
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, req)
if recorder.Code != http.StatusNotFound {
t.Fatalf("got status %d, want 404 — a 403 here would confirm the staff ID exists in another tenant", recorder.Code)
}
}This test would fail loudly the day someone 'simplifies' the handler by checking existence before tenant scope — exactly the regression a written rule alone cannot prevent.
Separate Fast and Slow Feedback
Applied exercise
Write a regression test for the phone uniqueness conflict
Nobody has yet written a test proving ErrDuplicatePhone actually surfaces when two staff members in the same organization share a phone number.
- Seed one organization and insert a staff member with a known phone number.
- Attempt to insert a second staff member in the same organization with the same phone.
- Assert the repository returns ErrDuplicatePhone, not a raw pgconn.PgError.
- Add a second case proving the same phone number is allowed across two different organizations.
Deliverable
A new test function alongside staff_repository_test.go covering both the conflict and the cross-tenant allowance.
Completion checks
- The conflict case asserts on the sentinel error, not the underlying PostgreSQL error code.
- The cross-tenant case proves the uniqueness rule is tenant-scoped, not global.
Why would an in-memory fake repository fail to catch a missing `organization_id` filter in FindByID?
Mark this lesson complete to store local progress and unlock a cleaner resume path the next time you visit.