Stage 7 · Master
Phase 12 — Visitor Service
Testing
Turn visitor safety claims into race-tested code by exercising the single-winner check-in path, the expiry boundary, and the flat-level invitation quota under realistic conditions.
Why the race detector matters specifically for visitor check-in
This module is full of logic that only breaks when the system is under concurrency pressure: two guards scanning together, one host approving as the timeout worker runs, or an attacker hammering invitation creation after stealing an account. go test -race matters here because it catches shared-memory mistakes in the Go code surrounding those database invariants — channels, counters, and fake stores — while your test suite is also verifying the SQL-side single-winner rule.
The race detector does not replace the atomic UPDATE from lesson 3. It complements it. One protects the database state transition, the other protects the test harness and any in-memory coordination code from lying to you about what really happened.
package visitor
import (
"context"
"errors"
"os"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgxpool"
)
func TestCheckInByTokenAllowsExactlyOneWinner(t *testing.T) {
pool := openVisitorTestPool(t)
truncateVisitorTables(t, pool)
orgID, flatID := newTenantIdentifiers(t)
hostUserID := uuid.New()
guardUserID := uuid.New()
rawToken := "same-token-for-all-goroutines"
seedInvitationRelativeToDatabaseTime(t, pool, orgID, flatID, hostUserID, rawToken, 1)
repo := NewHistoryRepository(pool)
service := NewHistoryService(repo)
const contenders = 32
var successes atomic.Int64
var replays atomic.Int64
errCh := make(chan error, contenders)
var wg sync.WaitGroup
for i := 0; i < contenders; i++ {
wg.Add(1)
go func() {
defer wg.Done()
_, err := service.CheckInByToken(context.Background(), orgID, guardUserID, rawToken)
switch {
case err == nil:
successes.Add(1)
case errors.Is(err, ErrInvitationAlreadyUsed):
replays.Add(1)
default:
errCh <- err
}
}()
}
wg.Wait()
close(errCh)
for err := range errCh {
t.Fatalf("unexpected error: %v", err)
}
if got := successes.Load(); got != 1 {
t.Fatalf("expected exactly one successful check-in, got %d", got)
}
if got := replays.Load(); got != contenders-1 {
t.Fatalf("expected %d replay outcomes, got %d", contenders-1, got)
}
var visitCount int
if err := pool.QueryRow(context.Background(), "SELECT count(*) FROM visitor_visits WHERE org_id = $1", orgID).Scan(&visitCount); err != nil {
t.Fatalf("count visits: %v", err)
}
if visitCount != 1 {
t.Fatalf("expected exactly one visit row, got %d", visitCount)
}
}
func TestCheckInByTokenHonorsInvitationValidityBoundary(t *testing.T) {
pool := openVisitorTestPool(t)
truncateVisitorTables(t, pool)
orgID, flatID := newTenantIdentifiers(t)
hostUserID := uuid.New()
guardUserID := uuid.New()
validToken := "boundary-valid-token"
seedInvitationRelativeToDatabaseTime(t, pool, orgID, flatID, hostUserID, validToken, 1)
service := NewHistoryService(NewHistoryRepository(pool))
if _, err := service.CheckInByToken(context.Background(), orgID, guardUserID, validToken); err != nil {
t.Fatalf("expected token with valid_until = now() + 1 second to succeed, got %v", err)
}
expiredToken := "boundary-expired-token"
seedInvitationRelativeToDatabaseTime(t, pool, orgID, flatID, hostUserID, expiredToken, -1)
if _, err := service.CheckInByToken(context.Background(), orgID, guardUserID, expiredToken); !errors.Is(err, ErrInvitationExpired) {
t.Fatalf("expected ErrInvitationExpired, got %v", err)
}
}
func openVisitorTestPool(t *testing.T) *pgxpool.Pool {
t.Helper()
dsn := os.Getenv("TEST_DATABASE_URL")
if dsn == "" {
t.Skip("TEST_DATABASE_URL is not set")
}
cfg, err := pgxpool.ParseConfig(dsn)
if err != nil {
t.Fatalf("parse test database config: %v", err)
}
cfg.MaxConns = 8
cfg.MinConns = 1
pool, err := pgxpool.NewWithConfig(context.Background(), cfg)
if err != nil {
t.Fatalf("connect test database: %v", err)
}
t.Cleanup(pool.Close)
return pool
}
func truncateVisitorTables(t *testing.T, pool *pgxpool.Pool) {
t.Helper()
_, err := pool.Exec(
context.Background(),
"TRUNCATE visitor_visits, visitor_approval_requests, visitor_invitations RESTART IDENTITY CASCADE",
)
if err != nil {
t.Fatalf("truncate visitor tables: %v", err)
}
}
// The container behind pool runs visitor-service's migrations and nothing
// else, so there is no organizations or flats table to seed. Both ids are the
// logical UUIDs production would carry; eligibility against them is answered
// by stubbed clients, never by a row this test inserted.
func newTenantIdentifiers(t *testing.T) (uuid.UUID, uuid.UUID) {
t.Helper()
return uuid.New(), uuid.New()
}
func seedInvitationRelativeToDatabaseTime(t *testing.T, pool *pgxpool.Pool, orgID, flatID, hostUserID uuid.UUID, rawToken string, validUntilOffsetSeconds int) {
t.Helper()
_, err := pool.Exec(
context.Background(),
"INSERT INTO visitor_invitations (org_id, flat_id, host_user_id, visitor_name, purpose, valid_from, valid_until, token_hash, used) "+
"VALUES ($1, $2, $3, 'Boundary Guest', 'Package handoff', now() - interval '5 minutes', now() + ($4 * interval '1 second'), $5, FALSE)",
orgID,
flatID,
hostUserID,
validUntilOffsetSeconds,
hashToken(rawToken),
)
if err != nil {
t.Fatalf("insert invitation: %v", err)
}
time.Sleep(10 * time.Millisecond)
}The concurrency assertion is exact on purpose: 1 success, 31 replay outcomes, and 1 inserted visit row. Anything fuzzier can hide a race that only fails intermittently.
package visitor
import (
"context"
"errors"
"testing"
"time"
"github.com/google/uuid"
)
type countingInvitationStore struct {
pendingCount int
lastInsert InsertInvitationParams
}
// The two eligibility answers now come from stubbed clients, which is the
// point: a unit test can no longer accidentally assert against another
// service's schema, only against its contract.
type stubFlatClient struct{ exists bool }
func (c stubFlatClient) ExistsInOrg(context.Context, uuid.UUID, uuid.UUID) (bool, error) {
return c.exists, nil
}
type stubResidentClient struct{ eligible bool }
func (c stubResidentClient) IsActiveOccupant(context.Context, uuid.UUID, uuid.UUID, uuid.UUID) (bool, error) {
return c.eligible, nil
}
func (s *countingInvitationStore) PendingInvitationCount(context.Context, uuid.UUID, uuid.UUID) (int, error) {
return s.pendingCount, nil
}
func (s *countingInvitationStore) InsertInvitation(_ context.Context, params InsertInvitationParams) (Invitation, error) {
s.pendingCount++
s.lastInsert = params
return Invitation{
ID: uuid.New(),
OrgID: params.OrgID,
FlatID: params.FlatID,
HostUserID: params.HostUserID,
VisitorName: params.VisitorName,
Purpose: params.Purpose,
ValidFrom: params.ValidFrom,
ValidUntil: params.ValidUntil,
TokenHash: params.TokenHash,
Used: false,
CreatedAt: time.Now().UTC(),
}, nil
}
type staticTokenGenerator struct {
value string
}
func (g staticTokenGenerator) New() (string, error) {
return g.value, nil
}
func TestCreateInvitationRejectsEleventhPendingInvitation(t *testing.T) {
orgID := uuid.New()
flatID := uuid.New()
hostUserID := uuid.New()
store := &countingInvitationStore{}
service := NewInvitationService(store, stubFlatClient{exists: true}, stubResidentClient{eligible: true}, staticTokenGenerator{value: "resident-guest-token"}, 10)
for i := 0; i < 10; i++ {
_, err := service.CreateInvitation(context.Background(), orgID, hostUserID, CreateInvitationInput{
FlatID: flatID,
VisitorName: "Guest",
Purpose: "Dinner",
ValidFrom: time.Date(2026, 8, 2, 18, 0, 0, 0, time.UTC),
ValidUntil: time.Date(2026, 8, 2, 22, 0, 0, 0, time.UTC),
})
if err != nil {
t.Fatalf("invitation %d should succeed, got %v", i+1, err)
}
}
if _, err := service.CreateInvitation(context.Background(), orgID, hostUserID, CreateInvitationInput{
FlatID: flatID,
VisitorName: "Guest 11",
Purpose: "Dinner",
ValidFrom: time.Date(2026, 8, 2, 18, 0, 0, 0, time.UTC),
ValidUntil: time.Date(2026, 8, 2, 22, 0, 0, 0, time.UTC),
}); !errors.Is(err, ErrInvitationQuotaExceeded) {
t.Fatalf("expected ErrInvitationQuotaExceeded, got %v", err)
}
}
func TestCreateInvitationRequiresActiveResidentForFlat(t *testing.T) {
store := &countingInvitationStore{}
service := NewInvitationService(store, stubFlatClient{exists: true}, stubResidentClient{eligible: false}, staticTokenGenerator{value: "resident-guest-token"}, 10)
_, err := service.CreateInvitation(context.Background(), uuid.New(), uuid.New(), CreateInvitationInput{
FlatID: uuid.New(),
VisitorName: "Plumber",
Purpose: "Repair",
ValidFrom: time.Date(2026, 8, 2, 10, 0, 0, 0, time.UTC),
ValidUntil: time.Date(2026, 8, 2, 12, 0, 0, 0, time.UTC),
})
if !errors.Is(err, ErrHostNotEligible) {
t.Fatalf("expected ErrHostNotEligible, got %v", err)
}
}
func TestCreateInvitationStoresOnlyTokenHash(t *testing.T) {
store := &countingInvitationStore{}
generator := staticTokenGenerator{value: "raw-token-that-must-not-be-stored"}
service := NewInvitationService(store, stubFlatClient{exists: true}, stubResidentClient{eligible: true}, generator, 10)
issued, err := service.CreateInvitation(context.Background(), uuid.New(), uuid.New(), CreateInvitationInput{
FlatID: uuid.New(),
VisitorName: "Tutor",
Purpose: "Lesson",
ValidFrom: time.Date(2026, 8, 2, 15, 0, 0, 0, time.UTC),
ValidUntil: time.Date(2026, 8, 2, 16, 0, 0, 0, time.UTC),
})
if err != nil {
t.Fatalf("create invitation: %v", err)
}
if store.lastInsert.TokenHash == issued.RawToken {
t.Fatalf("expected stored token hash to differ from raw token")
}
if len(store.lastInsert.TokenHash) != 64 {
t.Fatalf("expected SHA-256 hex digest length 64, got %d", len(store.lastInsert.TokenHash))
}
}These service tests are deliberately narrow: they pin the quota branch, the host-eligibility branch, and the token-hashing rule without needing a live database for all three.
What exactly the assertions prove about check-in safety
The concurrency test is not 'a bunch of goroutines and hope'. It asserts three facts simultaneously: one and only one service call returns nil, every loser returns ErrInvitationAlreadyUsed rather than some random transport failure, and the database ends with exactly one visitor_visits row for that org. If any of those three facts fails, the single-use guarantee is not trustworthy.
That is also why the test counts replay outcomes explicitly instead of just counting errors. A bag of 31 errors could hide token-not-found bugs, connection pool starvation, or a panic in one goroutine. The lesson's security claim is precise, so the assertion must be precise too.
How the boundary and quota tests avoid lying clocks
For expiry, the test inserts one invitation at now() + 1 second and another at now() - 1 second relative to the database clock. That is much better than sleeping for long periods or trusting the test process clock, because the production rule is 'database time decides' and the test should exercise that exact contract. For quota, the service fake increments the count only after an accepted insert, which reproduces the real branch that rejects the eleventh attempt.
It is tempting to print the whole invitation payload during a failing race test. Keep the failure output to counters and IDs. A test fixture that leaks raw visitor tokens into CI logs defeats the same protection you just implemented in code.
Which targeted commands should run before you ship this module
Applied exercise
Write the pre-ship visitor test matrix
Your team is ready to merge visitor management, but release engineering asks for a small, explicit test matrix instead of 'we ran go test'.
- List the exact three targeted tests that prove concurrency safety, expiry boundary behavior, and quota enforcement.
- Explain why the concurrency test must run with -race even though the database already serializes the atomic UPDATE.
- State which test uses a live database and which one can use a fake store, with a reason for each choice.
- Add one sentence describing what failure output would tell you if more than one goroutine reported success.
Deliverable
A concise test plan containing test names, commands, the reason for -race, and the storage or fake strategy behind each test.
Completion checks
- Your plan includes the exact go test -race invocation rather than a vague 'run tests' instruction.
- You distinguish the purpose of database-level serialization from Go memory-race detection.
- The explanation for fake versus live database usage is tied to the kind of invariant each test is proving.
Why is `go test -race` especially valuable for the visitor module?
Mark this lesson complete to store local progress and unlock a cleaner resume path the next time you visit.