475 lines
18 KiB
Go
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")
|
|
}
|
|
}
|