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") } }