Files
krow_backend/go-api/internal/tools/applications_test.go
2026-08-28 12:21:44 +05:30

475 lines
18 KiB
Go

package tools_test
import (
"context"
"encoding/json"
"fmt"
"strings"
"testing"
"github.com/krow/krow-backend/go-api/internal/authctx"
"github.com/krow/krow-backend/go-api/internal/testutil"
"github.com/krow/krow-backend/go-api/internal/tools"
)
// The second write tool.
//
// Half of what is asserted here is the same as assign_worker's, and that is the
// point rather than duplication: the confirmation gate was built once, and a
// second tool that has to re-earn "cannot write without approval" would mean it
// had been special-cased into the first. It is not — this tool declares an
// effect and a renderer, and everything else comes from the registry.
//
// The other half is this tool's own: a funnel has an order, terminal outcomes
// are different from steps along it, and moving somebody backwards is not a
// move at all.
type funnelFixture struct {
orgID string
admin authctx.Identity
talent authctx.Identity
appID string
name string
}
func seedFunnel(t *testing.T, h *testutil.Harness, slug string) funnelFixture {
t.Helper()
ctx := context.Background()
org := freshOrg(t, h, slug)
f := funnelFixture{orgID: org, name: "Dana Okonkwo"}
f.admin = authctx.Identity{
UserID: seedUser(t, h, org, fmt.Sprintf("boss-%s@example.test", slug), "admin"),
OrgID: org, Role: "admin", Email: fmt.Sprintf("boss-%s@example.test", slug),
}
candidateEmail := fmt.Sprintf("dana-%s@example.test", slug)
f.talent = authctx.Identity{
UserID: seedUser(t, h, org, candidateEmail, "talent"),
OrgID: org, Role: "talent", Email: candidateEmail,
}
var postingID string
if err := h.Pool.QueryRow(ctx, `
INSERT INTO job_postings (org_id, title, status, headcount)
VALUES ($1::uuid, 'Sous Chef', 'active', 1) RETURNING id::text`, org).Scan(&postingID); err != nil {
t.Fatalf("seed posting: %v", err)
}
if err := h.Pool.QueryRow(ctx, `
INSERT INTO job_applications (org_id, job_posting_id, applicant_name, email, status, ai_score)
VALUES ($1::uuid, $2::uuid, $3, $4, 'ai_screened', 88) RETURNING id::text`,
org, postingID, f.name, candidateEmail).Scan(&f.appID); err != nil {
t.Fatalf("seed application: %v", err)
}
return f
}
func stageOf(t *testing.T, h *testutil.Harness, appID string) string {
t.Helper()
var s string
if err := h.Pool.QueryRow(context.Background(),
`SELECT status::text FROM job_applications WHERE id = $1::uuid`, appID).Scan(&s); err != nil {
t.Fatalf("read stage: %v", err)
}
return s
}
func moveArgs(appID, stage string) string {
return fmt.Sprintf(`{"application_id":%q,"stage":%q}`, appID, stage)
}
/* ── The gate, again ────────────────────────────────────────────────────── */
func TestMovingACandidateNeedsApproval(t *testing.T) {
h := testutil.New(t)
f := seedFunnel(t, h, "funnel-gate")
reg := liveRegistry(t, h)
tc := tools.Context{Principal: f.admin, RunID: "run_m1"}
args := moveArgs(f.appID, "interview")
res := reg.Dispatch(context.Background(), tc, "move_application", json.RawMessage(args))
if res.Confirmation == nil {
t.Fatalf("expected a confirmation, got %+v", res)
}
if got := stageOf(t, h, f.appID); got != "ai_screened" {
t.Fatalf("the candidate moved to %q before anyone approved anything", got)
}
// Names and readable stages, not enum values and uuids.
body, _ := json.Marshal(res.Confirmation)
for _, want := range []string{"Dana Okonkwo", "Sous Chef", "Interview"} {
if !strings.Contains(string(body), want) {
t.Errorf("the confirmation does not mention %q: %s", want, body)
}
}
if strings.Contains(res.Confirmation.Title, "ai_screened") {
t.Error("the confirmation shows an enum value where it should show a label")
}
tc.Confirmation = res.Confirmation.Token
if out := reg.Dispatch(context.Background(), tc, "move_application", json.RawMessage(args)); out.Error != nil {
t.Fatalf("an approved move should run: %+v", out.Error)
}
if got := stageOf(t, h, f.appID); got != "interview" {
t.Fatalf("stage is %q after approval, want interview", got)
}
}
func TestACandidateCannotMoveThemselves(t *testing.T) {
// `job-applications` lists to everyone — a talent caller may read their own
// — and updates for operators only. Asking the policy the READ question
// here would let a candidate advance themselves to interview.
h := testutil.New(t)
f := seedFunnel(t, h, "funnel-self")
reg := liveRegistry(t, h)
res := reg.Dispatch(context.Background(),
tools.Context{Principal: f.talent, RunID: "run_m2"},
"move_application", json.RawMessage(moveArgs(f.appID, "interview")))
if res.Confirmation != nil {
t.Fatal("a candidate was asked to approve their own advancement")
}
if res.Error == nil || res.Error.Code != tools.CodeDenied {
t.Fatalf("want the standard denial, got %+v", res.Error)
}
if got := stageOf(t, h, f.appID); got != "ai_screened" {
t.Fatalf("a candidate moved themselves to %q", got)
}
}
func TestAnotherTenantsCandidateIsAbsentRatherThanForbidden(t *testing.T) {
h := testutil.New(t)
mine := seedFunnel(t, h, "funnel-mine")
theirs := seedFunnel(t, h, "funnel-theirs")
reg := liveRegistry(t, h)
tc := tools.Context{Principal: mine.admin, RunID: "run_m3"}
real := reg.Dispatch(context.Background(), tc, "move_application",
json.RawMessage(moveArgs(theirs.appID, "interview")))
fake := reg.Dispatch(context.Background(), tc, "move_application",
json.RawMessage(moveArgs("00000000-0000-0000-0000-0000000000ff", "interview")))
if real.Error == nil || fake.Error == nil {
t.Fatal("a cross-tenant or invented application id was accepted")
}
if real.Error.Code != fake.Error.Code || real.Error.Message != fake.Error.Message {
t.Errorf("a real-but-forbidden candidate is distinguishable from an imaginary one:\n"+
" other tenant: %s\n invented: %s", real.Error.Message, fake.Error.Message)
}
if got := stageOf(t, h, theirs.appID); got != "ai_screened" {
t.Fatal("a write crossed a tenant boundary")
}
}
/* ── The funnel's own rules ─────────────────────────────────────────────── */
func TestATerminalOutcomeIsWarnedAbout(t *testing.T) {
// Hiring and rejecting are the hardest decisions to walk back, and a model
// reaching them early is the most expensive mistake available here. They
// get a warning above the button, not a footnote.
h := testutil.New(t)
f := seedFunnel(t, h, "funnel-terminal")
reg := liveRegistry(t, h)
for stage, word := range map[string]string{"hired": "final outcome", "rejected": "final outcome"} {
res := reg.Dispatch(context.Background(),
tools.Context{Principal: f.admin, RunID: "run_m4"},
"move_application", json.RawMessage(moveArgs(f.appID, stage)))
if res.Confirmation == nil {
t.Fatalf("%s: expected a confirmation, got %+v", stage, res)
}
var found bool
for _, w := range res.Confirmation.Warnings {
if strings.Contains(w, word) {
found = true
}
}
if !found {
t.Errorf("moving to %s was described without warning it is final: %v",
stage, res.Confirmation.Warnings)
}
}
}
func TestSkippingStagesIsWarnedAbout(t *testing.T) {
// Legitimate — a strong candidate can go straight to interview — but a
// model misreading which stage somebody is at produces exactly this shape,
// so the person approving should be told.
h := testutil.New(t)
f := seedFunnel(t, h, "funnel-skip")
reg := liveRegistry(t, h)
// ai_screened → interview skips shortlisted.
res := reg.Dispatch(context.Background(),
tools.Context{Principal: f.admin, RunID: "run_m5"},
"move_application", json.RawMessage(moveArgs(f.appID, "interview")))
if res.Confirmation == nil {
t.Fatalf("expected a confirmation, got %+v", res)
}
var found bool
for _, w := range res.Confirmation.Warnings {
if strings.Contains(w, "skips") {
found = true
}
}
if !found {
t.Errorf("skipping a stage was not mentioned: %v", res.Confirmation.Warnings)
}
}
func TestACandidateCannotBeMovedBackToApplied(t *testing.T) {
// Not a funnel action but an undo — and one that would erase the record of
// having been screened. Refused as invalid input rather than described.
h := testutil.New(t)
f := seedFunnel(t, h, "funnel-back")
reg := liveRegistry(t, h)
res := reg.Dispatch(context.Background(),
tools.Context{Principal: f.admin, RunID: "run_m6"},
"move_application", json.RawMessage(moveArgs(f.appID, "applied")))
if res.Confirmation != nil {
t.Fatal("moving a candidate backwards was offered for approval")
}
if res.Error == nil || res.Error.Code != tools.CodeInvalidInput {
t.Fatalf("want invalid input, got %+v", res.Error)
}
}
func TestMovingSomebodyToWhereTheyAlreadyAreChangesNothing(t *testing.T) {
h := testutil.New(t)
f := seedFunnel(t, h, "funnel-noop")
reg := liveRegistry(t, h)
tc := tools.Context{Principal: f.admin, RunID: "run_m7"}
args := moveArgs(f.appID, "ai_screened")
res := reg.Dispatch(context.Background(), tc, "move_application", json.RawMessage(args))
if res.Confirmation == nil {
t.Fatalf("expected a confirmation, got %+v", res)
}
// Said out loud rather than silently doing nothing.
var warned bool
for _, w := range res.Confirmation.Warnings {
if strings.Contains(w, "changes nothing") {
warned = true
}
}
if !warned {
t.Errorf("a no-op move was not flagged as one: %v", res.Confirmation.Warnings)
}
tc.Confirmation = res.Confirmation.Token
out := reg.Dispatch(context.Background(), tc, "move_application", json.RawMessage(args))
if out.Error != nil {
t.Fatalf("a no-op move should succeed: %+v", out.Error)
}
body, _ := json.Marshal(out.Data)
if !strings.Contains(string(body), `"changed":false`) {
t.Errorf("a no-op did not report itself as one: %s", body)
}
}
/* ── The lookup ─────────────────────────────────────────────────────────── */
func TestCandidatesAwaitingReturnsIdsAndReadableStages(t *testing.T) {
// §4: a tool that requires the model to guess an id is a design bug. This
// is the lookup that makes move_application usable without guessing.
h := testutil.New(t)
f := seedFunnel(t, h, "funnel-lookup")
reg := liveRegistry(t, h)
res := reg.Dispatch(context.Background(),
tools.Context{Principal: f.admin, RunID: "run_m8"},
"candidates_awaiting", json.RawMessage(`{}`))
if res.Error != nil {
t.Fatalf("candidates_awaiting failed: %+v", res.Error)
}
body, _ := json.Marshal(res.Data)
for _, want := range []string{f.appID, "Dana Okonkwo", "Screened", "matchScore"} {
if !strings.Contains(string(body), want) {
t.Errorf("the lookup does not carry %q: %s", want, body)
}
}
}
func TestCandidatesAwaitingExcludesSettledCandidates(t *testing.T) {
// "Still in the running" is the useful default: a person asking who is
// waiting does not mean the people already hired or rejected.
h := testutil.New(t)
f := seedFunnel(t, h, "funnel-settled")
reg := liveRegistry(t, h)
tc := tools.Context{Principal: f.admin, RunID: "run_m9"}
if _, err := h.Pool.Exec(context.Background(),
`UPDATE job_applications SET status = 'rejected' WHERE id = $1::uuid`, f.appID); err != nil {
t.Fatalf("settle: %v", err)
}
res := reg.Dispatch(context.Background(), tc, "candidates_awaiting", json.RawMessage(`{}`))
if body, _ := json.Marshal(res.Data); strings.Contains(string(body), f.appID) {
t.Errorf("a rejected candidate was listed as still in the running: %s", body)
}
// But asked for explicitly, they are there.
res = reg.Dispatch(context.Background(), tc, "candidates_awaiting", json.RawMessage(`{"stage":"rejected"}`))
if body, _ := json.Marshal(res.Data); !strings.Contains(string(body), f.appID) {
t.Errorf("asking for rejected candidates did not return one: %s", body)
}
}
func TestScreenedIsDerivedFromStageNotFromAVestigialColumn(t *testing.T) {
// A live run reported five candidates sitting at the Screened stage all
// carrying screened:false, and read it as the stage label running ahead of
// the work. The data was fine; the tool was wrong. `screened` was read from
// job_applications.screened_at, a column migration 000003 established that
// nothing in the product ever writes — so it was null for every real row and
// the flag was false for everyone, forever. The product's own definition,
// in eight places, is status = 'applied'. This asserts that definition.
//
// The old test fixture hid this by inserting screened_at itself, which no
// production path does; the seed below deliberately does not.
h := testutil.New(t)
f := seedFunnel(t, h, "funnel-screened")
reg := liveRegistry(t, h)
ctx := context.Background()
var freshID string
if err := h.Pool.QueryRow(ctx, `
INSERT INTO job_applications (org_id, job_posting_id, applicant_name, email, status, ai_score)
SELECT org_id, job_posting_id, 'Ivo Brandt', 'ivo-screened@example.test', 'applied', 0
FROM job_applications WHERE id = $1::uuid
RETURNING id::text`, f.appID).Scan(&freshID); err != nil {
t.Fatalf("seed applied candidate: %v", err)
}
res := reg.Dispatch(ctx,
tools.Context{Principal: f.admin, RunID: "run_m10"},
"candidates_awaiting", json.RawMessage(`{}`))
if res.Error != nil {
t.Fatalf("candidates_awaiting failed: %+v", res.Error)
}
var payload struct {
Candidates []struct {
ID string `json:"applicationId"`
Screened bool `json:"screened"`
} `json:"candidates"`
}
body, _ := json.Marshal(res.Data)
if err := json.Unmarshal(body, &payload); err != nil {
t.Fatalf("decode %s: %v", body, err)
}
seen := map[string]bool{}
for _, c := range payload.Candidates {
seen[c.ID] = c.Screened
}
if got, ok := seen[f.appID]; !ok {
t.Fatalf("the ai_screened candidate was not returned: %s", body)
} else if !got {
t.Errorf("a candidate at the Screened stage reported screened:false — " +
"the flag is being read from something other than the stage")
}
if got, ok := seen[freshID]; !ok {
t.Fatalf("the applied candidate was not returned: %s", body)
} else if got {
t.Errorf("a candidate still at Applied reported screened:true")
}
}
func TestMovingACandidateDoesNotWriteTheVestigialColumn(t *testing.T) {
// Keeping this tool as the sole writer of screened_at would quietly redefine
// the column to mean "an agent touched this row". status carries the stage
// and the trajectory carries the who and when.
h := testutil.New(t)
f := seedFunnel(t, h, "funnel-vestigial")
reg := liveRegistry(t, h)
ctx := context.Background()
tc := tools.Context{Principal: f.admin, RunID: "run_m11"}
args := moveArgs(f.appID, "interview")
res := reg.Dispatch(ctx, tc, "move_application", json.RawMessage(args))
if res.Confirmation == nil {
t.Fatalf("expected a confirmation, got %+v", res)
}
out, ok := reg.DispatchApproved(ctx, tc, res.Confirmation.Token)
if !ok {
t.Fatal("a valid token could not be redeemed")
}
if out.Result.Error != nil {
t.Fatalf("approved move failed: %+v", out.Result.Error)
}
if got := stageOf(t, h, f.appID); got != "interview" {
t.Fatalf("the approved move did not land: stage is %q", got)
}
var written bool
if err := h.Pool.QueryRow(ctx,
`SELECT screened_at IS NOT NULL FROM job_applications WHERE id = $1::uuid`,
f.appID).Scan(&written); err != nil {
t.Fatalf("read screened_at: %v", err)
}
if written {
t.Errorf("move_application wrote screened_at, making it the column's only writer")
}
}
// A candidate placed on a shift is not waiting on a decision. `assigned` is in
// the application_status enum but was in neither of this file's stage lists, so
// the "still in the running" predicate — written as NOT IN ('hired','rejected')
// — let them through, and candidates_awaiting listed somebody already working
// as somebody to chase. The same omission on the frontend dropped `assigned`
// out of every funnel bucket; here it fell into the wrong one instead.
func TestAnAssignedCandidateIsNotStillInTheRunning(t *testing.T) {
h := testutil.New(t)
f := seedFunnel(t, h, "funnel-assigned")
reg := liveRegistry(t, h)
ctx := context.Background()
tc := tools.Context{Principal: f.admin, RunID: "run_m12"}
if _, err := h.Pool.Exec(ctx,
`UPDATE job_applications SET status = 'assigned' WHERE id = $1::uuid`, f.appID); err != nil {
t.Fatalf("assign: %v", err)
}
res := reg.Dispatch(ctx, tc, "candidates_awaiting", json.RawMessage(`{}`))
if res.Error != nil {
t.Fatalf("candidates_awaiting failed: %+v", res.Error)
}
if body, _ := json.Marshal(res.Data); strings.Contains(string(body), f.appID) {
t.Errorf("a candidate already assigned to a shift was listed as awaiting a decision: %s", body)
}
// Asked for by name they are there — the read tool can show every status,
// even the one move_application is not allowed to set.
res = reg.Dispatch(ctx, tc, "candidates_awaiting", json.RawMessage(`{"stage":"assigned"}`))
if res.Error != nil {
t.Fatalf("listing assigned candidates failed: %+v", res.Error)
}
body, _ := json.Marshal(res.Data)
if !strings.Contains(string(body), f.appID) {
t.Errorf("asking for assigned candidates did not return one: %s", body)
}
if !strings.Contains(string(body), "Assigned") {
t.Errorf("the stage label is not readable: %s", body)
}
}
// move_application must still refuse to *set* assigned: that state means an
// assignment row exists, and this tool writes none.
func TestMoveApplicationStillRefusesToSetAssigned(t *testing.T) {
h := testutil.New(t)
f := seedFunnel(t, h, "funnel-noassign")
reg := liveRegistry(t, h)
res := reg.Dispatch(context.Background(),
tools.Context{Principal: f.admin, RunID: "run_m13"},
"move_application", json.RawMessage(moveArgs(f.appID, "assigned")))
if res.Error == nil {
t.Fatalf("move_application accepted 'assigned'; it would mark a candidate assigned to nothing: %+v", res)
}
if res.Confirmation != nil {
t.Errorf("it even offered a confirmation for it")
}
}