//go:build integration package integration import ( "context" "errors" "testing" "github.com/google/uuid" "github.com/missionworkforce/missionmatch/internal/modules/recruiting" "github.com/missionworkforce/missionmatch/internal/platform/audit" "github.com/missionworkforce/missionmatch/internal/platform/tenant" ) // seedRecruiter creates a real user so ownership and audit actors resolve. func seedRecruiter(t *testing.T, org uuid.UUID, name string) (uuid.UUID, context.Context) { t.Helper() var id uuid.UUID if err := migrator.QueryRow(context.Background(), `INSERT INTO users (organization_id, email, full_name, role, status) VALUES ($1, $2, $3, 'recruiter', 'active') RETURNING id`, org, name+"-"+uuid.New().String()[:8]+"@example.com", name).Scan(&id); err != nil { t.Fatalf("seed recruiter: %v", err) } ctx := tenant.WithUser(tenant.WithOrg(context.Background(), org.String()), tenant.Principal{UserID: id.String(), Role: "recruiter"}) return id, ctx } func reqSvc() *recruiting.Service { return recruiting.NewService(appDB, audit.New(appDB)) } // TestRequisitionLifecycleMovesEveryClock walks a job order through the full lifecycle against a real // database and checks the clocks after each step. The pure unit tests prove the arithmetic; this proves // the SQL performs the same arithmetic, which is the half a pure test cannot reach. func TestRequisitionLifecycleMovesEveryClock(t *testing.T) { org := seedOrg(t) _, ctx := seedRecruiter(t, org, "Lifecycle Recruiter") svc := reqSvc() client, err := svc.CreateClient(ctx, recruiting.CreateClientInput{Name: "Lifecycle Client"}) if err != nil { t.Fatalf("create client: %v", err) } contact, err := svc.CreateContact(ctx, uuid.MustParse(client.ID), recruiting.CreateContactInput{FullName: "Hiring Mgr", IsHiringManager: true}) if err != nil { t.Fatalf("create contact: %v", err) } r, err := svc.CreateRequisition(ctx, recruiting.CreateRequisitionInput{ ClientID: client.ID, HiringManagerID: contact.ID, Title: "Cleared Systems Engineer", Openings: 2, Priority: "high", BillRateMin: "145.5000", BillRateMax: "165.0000", }) if err != nil { t.Fatalf("create requisition: %v", err) } if r.Status != "open" { t.Errorf("new requisition status = %q, want open", r.Status) } if r.ClientName != "Lifecycle Client" || r.HiringManagerName != "Hiring Mgr" { t.Errorf("create did not resolve names: client=%q manager=%q", r.ClientName, r.HiringManagerName) } // Money must survive the round trip exactly. A float would not represent 145.5 and 165.0 as the same // values that went in. if r.BillRateMin == nil || r.BillRateMin.String() != "145.5" { t.Errorf("bill_rate_min came back as %v, want 145.5", r.BillRateMin) } if r.OwnerName != "Lifecycle Recruiter" { t.Errorf("the creating recruiter did not take ownership, owner = %q", r.OwnerName) } id := uuid.MustParse(r.ID) // open to on_hold. The hold bracket opens and the order stops accruing workable age. r, err = svc.ChangeStatus(ctx, id, recruiting.ChangeStatusInput{ ExpectedUpdatedAt: r.UpdatedAt, Status: "on_hold", Note: "client froze the program"}) if err != nil { t.Fatalf("open to on_hold: %v", err) } if r.Status != "on_hold" || r.OnHoldSince == "" { t.Fatalf("on_hold did not open a hold bracket: status=%q since=%q", r.Status, r.OnHoldSince) } if r.ClosedAt != "" { t.Error("a held order must not carry a closed_at") } // on_hold to filled. The hold must be SETTLED even though the destination is terminal, or the bracket // stays open forever and the accrued hold grows without bound against a dead row. r, err = svc.ChangeStatus(ctx, id, recruiting.ChangeStatusInput{ ExpectedUpdatedAt: r.UpdatedAt, Status: "filled", Note: "both seats placed"}) if err != nil { t.Fatalf("on_hold to filled: %v", err) } if r.OnHoldSince != "" { t.Error("closing while on hold left the hold bracket open, so hold time will grow forever") } if r.ClosedAt == "" { t.Error("a filled order must record closed_at") } if r.WorkingDays > r.AgeDays { t.Errorf("working days %v exceeds age %v, hold time was credited twice", r.WorkingDays, r.AgeDays) } if r.WorkingDays < 0 || r.AgeDays < 0 || r.HeldDays < 0 { t.Errorf("a negative duration is never a real measurement: age=%v working=%v held=%v", r.AgeDays, r.WorkingDays, r.HeldDays) } // filled to open. Reopening must clear closed_at and must NOT reset opened_at, or the history of the // role is lost and time to fill restarts from zero. openedBefore := r.OpenedAt r, err = svc.ChangeStatus(ctx, id, recruiting.ChangeStatusInput{ ExpectedUpdatedAt: r.UpdatedAt, Status: "open", Note: "candidate fell off"}) if err != nil { t.Fatalf("filled to open: %v", err) } if r.ClosedAt != "" { t.Error("reopening did not clear closed_at") } if r.OpenedAt != openedBefore { t.Errorf("reopening moved opened_at from %s to %s, losing the age of the role", openedBefore, r.OpenedAt) } // The history must record every one of those transitions, in order, with the first link having no // predecessor. d, err := svc.GetRequisition(ctx, id) if err != nil { t.Fatalf("get requisition: %v", err) } if len(d.Events) != 4 { t.Fatalf("history has %d events, want 4 (opened, on_hold, filled, open)", len(d.Events)) } if d.Events[0].FromStatus != "" { t.Errorf("the first event has a predecessor %q, want none", d.Events[0].FromStatus) } want := []string{"open", "on_hold", "filled", "open"} for i, w := range want { if d.Events[i].ToStatus != w { t.Errorf("event %d moved to %q, want %q", i, d.Events[i].ToStatus, w) } if d.Events[i].DaysInFrom < 0 { t.Errorf("event %d spent negative time in its previous state", i) } } if d.Events[1].ActorUserName != "Lifecycle Recruiter" { t.Errorf("event actor name = %q, want the recruiter's name stored at write time", d.Events[1].ActorUserName) } if d.Events[1].Note != "client froze the program" { t.Errorf("the note was not recorded: %q", d.Events[1].Note) } } // TestIllegalTransitionIsRefusedByTheService. The graph is proved exhaustively by unit tests; this // confirms the service consults it rather than writing whatever it was handed. func TestIllegalTransitionIsRefusedByTheService(t *testing.T) { org := seedOrg(t) _, ctx := seedRecruiter(t, org, "Graph Recruiter") svc := reqSvc() r, err := svc.CreateRequisition(ctx, recruiting.CreateRequisitionInput{Title: "Draft Role", Status: "draft"}) if err != nil { t.Fatalf("create draft: %v", err) } id := uuid.MustParse(r.ID) for _, to := range []string{"on_hold", "filled", "closed"} { if _, err := svc.ChangeStatus(ctx, id, recruiting.ChangeStatusInput{ ExpectedUpdatedAt: r.UpdatedAt, Status: to}); err == nil { t.Errorf("a draft was allowed to move to %s", to) } } // And a status that does not exist at all. if _, err := svc.ChangeStatus(ctx, id, recruiting.ChangeStatusInput{ ExpectedUpdatedAt: r.UpdatedAt, Status: "archived"}); err == nil { t.Error("an unknown status was accepted") } } // TestRequisitionOwnershipIsTheLock. Two recruiters must not both be editing one job order, and the // refusal must name who holds it so the answer is a conversation rather than a retry. func TestRequisitionOwnershipIsTheLock(t *testing.T) { org := seedOrg(t) _, alice := seedRecruiter(t, org, "Alice Holder") _, bob := seedRecruiter(t, org, "Bob Other") svc := reqSvc() r, err := svc.CreateRequisition(alice, recruiting.CreateRequisitionInput{Title: "Contested Role"}) if err != nil { t.Fatalf("create: %v", err) } id := uuid.MustParse(r.ID) title := "Bob's Edit" _, err = svc.UpdateRequisition(bob, id, recruiting.UpdateRequisitionInput{ ExpectedUpdatedAt: r.UpdatedAt, Title: &title}) var notOwner *recruiting.ErrNotOwner if !errors.As(err, ¬Owner) { t.Fatalf("a second recruiter was allowed to edit, got %v", err) } if notOwner.OwnerName != "Alice Holder" { t.Errorf("the refusal names %q, want Alice Holder", notOwner.OwnerName) } if notOwner.Entity != "job order" { t.Errorf("the refusal calls this a %q, want job order", notOwner.Entity) } // Released, it becomes claimable and Bob's edit takes it. if _, err := svc.ReleaseRequisition(alice, id); err != nil { t.Fatalf("release: %v", err) } got, err := svc.ClaimRequisition(bob, id) if err != nil { t.Fatalf("claim after release: %v", err) } if got.OwnerName != "Bob Other" { t.Errorf("after claiming, owner = %q, want Bob Other", got.OwnerName) } } // TestCrossClientHiringManagerIsRefused. A submittal addressed to the wrong company is the failure this // prevents, and the database is the authority because only it can check without a race. func TestCrossClientHiringManagerIsRefused(t *testing.T) { org := seedOrg(t) _, ctx := seedRecruiter(t, org, "Manager Recruiter") svc := reqSvc() a, _ := svc.CreateClient(ctx, recruiting.CreateClientInput{Name: "Client A"}) b, _ := svc.CreateClient(ctx, recruiting.CreateClientInput{Name: "Client B"}) mgrB, err := svc.CreateContact(ctx, uuid.MustParse(b.ID), recruiting.CreateContactInput{FullName: "Mgr At B"}) if err != nil { t.Fatalf("create contact: %v", err) } if _, err := svc.CreateRequisition(ctx, recruiting.CreateRequisitionInput{ ClientID: a.ID, HiringManagerID: mgrB.ID, Title: "Wrong Manager Role"}); err == nil { t.Fatal("a hiring manager from a different client was accepted") } // And a manager with no client at all is refused by the service before the insert. if _, err := svc.CreateRequisition(ctx, recruiting.CreateRequisitionInput{ HiringManagerID: mgrB.ID, Title: "No Client Role"}); err == nil { t.Fatal("a hiring manager with no client was accepted") } } // TestRequisitionTenantIsolation. The whole product rests on this. One tenant must never see another's // job orders, and the control proves the query was not simply returning nothing. func TestRequisitionTenantIsolation(t *testing.T) { orgA, orgB := seedOrg(t), seedOrg(t) _, ctxA := seedRecruiter(t, orgA, "Tenant A") _, ctxB := seedRecruiter(t, orgB, "Tenant B") svc := reqSvc() made, err := svc.CreateRequisition(ctxA, recruiting.CreateRequisitionInput{Title: "Tenant A Secret Role"}) if err != nil { t.Fatalf("create in A: %v", err) } listB, err := svc.ListRequisitions(ctxB, "", "", "", "", 100, 0) if err != nil { t.Fatalf("list in B: %v", err) } for _, r := range listB.Requisitions { if r.ID == made.ID { t.Fatal("tenant B can see tenant A's job order") } } if _, err := svc.GetRequisition(ctxB, uuid.MustParse(made.ID)); err == nil { t.Fatal("tenant B fetched tenant A's job order directly by id") } // Control: A still sees its own, so the isolation above is not just an empty result. listA, err := svc.ListRequisitions(ctxA, "", "", "", "", 100, 0) if err != nil || listA.Total != 1 { t.Fatalf("tenant A sees %d of its own orders, want 1 (err=%v)", listA.Total, err) } } // TestOptimisticConcurrencyRefusesAStaleWrite. Ownership stops two people editing; the token stops one // person editing from two stale tabs. func TestOptimisticConcurrencyRefusesAStaleWrite(t *testing.T) { org := seedOrg(t) _, ctx := seedRecruiter(t, org, "Token Recruiter") svc := reqSvc() r, err := svc.CreateRequisition(ctx, recruiting.CreateRequisitionInput{Title: "Token Role"}) if err != nil { t.Fatalf("create: %v", err) } id := uuid.MustParse(r.ID) stale := r.UpdatedAt first := "First Edit" if _, err := svc.UpdateRequisition(ctx, id, recruiting.UpdateRequisitionInput{ ExpectedUpdatedAt: stale, Title: &first}); err != nil { t.Fatalf("first edit: %v", err) } second := "Second Edit" _, err = svc.UpdateRequisition(ctx, id, recruiting.UpdateRequisitionInput{ ExpectedUpdatedAt: stale, Title: &second}) if err == nil { t.Fatal("a write against a stale token was accepted, so the first edit was silently lost") } } // TestOwnerFilterIsAppliedInSQL. Filtering after pagination would show a recruiter part of their desk and // call it all of it. func TestOwnerFilterIsAppliedInSQL(t *testing.T) { org := seedOrg(t) _, alice := seedRecruiter(t, org, "Filter Alice") _, bob := seedRecruiter(t, org, "Filter Bob") svc := reqSvc() for i := 0; i < 3; i++ { if _, err := svc.CreateRequisition(alice, recruiting.CreateRequisitionInput{ Title: "Alice Role " + string(rune('A'+i))}); err != nil { t.Fatalf("seed: %v", err) } } r, _ := svc.CreateRequisition(bob, recruiting.CreateRequisitionInput{Title: "Bob Role"}) if _, err := svc.ReleaseRequisition(bob, uuid.MustParse(r.ID)); err != nil { t.Fatalf("release: %v", err) } for _, c := range []struct { filter string ctx context.Context want int }{ {"", alice, 4}, {"me", alice, 3}, {"me", bob, 0}, {"unassigned", alice, 1}, } { got, err := svc.ListRequisitions(c.ctx, "", "", c.filter, "", 100, 0) if err != nil { t.Fatalf("filter %q: %v", c.filter, err) } if got.Total != c.want { t.Errorf("filter %q returned %d, want %d", c.filter, got.Total, c.want) } } // An unrecognised filter must be refused, not ignored. Silently returning everyone's orders when the // caller asked for one person's looks like an answer. if _, err := svc.ListRequisitions(alice, "", "", "not-a-filter", "", 100, 0); err == nil { t.Error("an invalid owner filter was ignored rather than refused") } } // TestActiveStatusFilterIncludesHeldOrders. A recruiter asking for open roles does not mean to exclude // the paused ones, and silently doing so understates their desk. func TestActiveStatusFilterIncludesHeldOrders(t *testing.T) { org := seedOrg(t) _, ctx := seedRecruiter(t, org, "Active Recruiter") svc := reqSvc() open, _ := svc.CreateRequisition(ctx, recruiting.CreateRequisitionInput{Title: "Still Open"}) held, _ := svc.CreateRequisition(ctx, recruiting.CreateRequisitionInput{Title: "On Hold"}) if _, err := svc.ChangeStatus(ctx, uuid.MustParse(held.ID), recruiting.ChangeStatusInput{ ExpectedUpdatedAt: held.UpdatedAt, Status: "on_hold"}); err != nil { t.Fatalf("hold: %v", err) } closed, _ := svc.CreateRequisition(ctx, recruiting.CreateRequisitionInput{Title: "Closed"}) if _, err := svc.ChangeStatus(ctx, uuid.MustParse(closed.ID), recruiting.ChangeStatusInput{ ExpectedUpdatedAt: closed.UpdatedAt, Status: "closed"}); err != nil { t.Fatalf("close: %v", err) } _ = open got, err := svc.ListRequisitions(ctx, "", "active", "", "", 100, 0) if err != nil { t.Fatalf("list active: %v", err) } if got.Total != 2 { t.Errorf("active returned %d orders, want 2 (open and on_hold, not closed)", got.Total) } }