package tools_test import ( "context" "encoding/json" "fmt" "strings" "testing" "time" "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 write path, end to end, against the real schema and the real store. // // Everything in confirm_test.go is about the mechanism and runs against a spy. // This file is about the one tool that uses it, and the assertion that matters // throughout is the same one: count the rows in `assignments`. A test that only // checks what Dispatch returned cannot tell a refusal that wrote from a refusal // that did not. type assignFixture struct { orgID string admin authctx.Identity talent authctx.Identity postingID string worker string starts time.Time ends time.Time } func seedAssignable(t *testing.T, h *testutil.Harness, slug string) assignFixture { t.Helper() ctx := context.Background() org := freshOrg(t, h, slug) // Emails are globally unique, not merely unique per organization, so every // fixture scopes its own by slug. Two tenants in one test would otherwise // collide on the second seed. boss := fmt.Sprintf("boss-%s@example.test", slug) f := assignFixture{ orgID: org, worker: fmt.Sprintf("maya-%s@example.test", slug), starts: time.Date(2030, 9, 12, 18, 0, 0, 0, time.UTC), ends: time.Date(2030, 9, 12, 23, 0, 0, 0, time.UTC), } // Real user rows, not invented uuids. agent_confirmations references // users(id), so a synthetic principal cannot have a confirmation issued for // it — which is correct (a confirmation is asked OF somebody) and means the // fixture has to be honest about who is asking. f.admin = authctx.Identity{ UserID: seedUser(t, h, org, boss, "admin"), OrgID: org, Role: "admin", Email: boss, } f.talent = authctx.Identity{ UserID: seedUser(t, h, org, f.worker, "talent"), OrgID: org, Role: "talent", Email: f.worker, } if err := h.Pool.QueryRow(ctx, ` INSERT INTO job_postings (org_id, title, status, headcount, location) VALUES ($1::uuid, 'Bar Supervisor', 'active', 2, 'Shoreditch') RETURNING id::text`, org).Scan(&f.postingID); err != nil { t.Fatalf("seed posting: %v", err) } if _, err := h.Pool.Exec(ctx, ` INSERT INTO worker_profiles (org_id, full_name, email, krow_score) VALUES ($1::uuid, 'Maya Chen', $2, 88)`, org, f.worker); err != nil { t.Fatalf("seed worker: %v", err) } return f } func seedUser(t *testing.T, h *testutil.Harness, org, email, role string) string { t.Helper() var id string if err := h.Pool.QueryRow(context.Background(), ` INSERT INTO users (org_id, email, full_name, role) VALUES ($1::uuid, $2, $3, $4) RETURNING id::text`, org, email, email, role).Scan(&id); err != nil { t.Fatalf("seed user %s: %v", email, err) } return id } // liveRegistry is the shipped tool set over the real Postgres confirmation // store — the wiring the service actually runs, not the in-memory stand-in. func liveRegistry(t *testing.T, h *testutil.Harness) *tools.Registry { t.Helper() reg := tools.NewRegistryWithStore(tools.NewPostgresStore(h.Pool)) for _, tool := range everyTool(h.Pool) { reg.MustRegister(tool) } return reg } func assignmentCount(t *testing.T, h *testutil.Harness, org string) int { t.Helper() var n int if err := h.Pool.QueryRow(context.Background(), `SELECT count(*) FROM assignments WHERE org_id = $1::uuid`, org).Scan(&n); err != nil { t.Fatalf("count assignments: %v", err) } return n } func assignArgs(f assignFixture) string { return fmt.Sprintf(`{"job_posting_id":%q,"worker_email":%q,"starts_at":%q,"ends_at":%q}`, f.postingID, f.worker, f.starts.Format(time.RFC3339), f.ends.Format(time.RFC3339)) } /* ── The cycle ──────────────────────────────────────────────────────────── */ func TestAssignWorkerDescribesTheWriteInWordsAPersonCanCheck(t *testing.T) { h := testutil.New(t) f := seedAssignable(t, h, "assign-describe") reg := liveRegistry(t, h) res := reg.Dispatch(context.Background(), tools.Context{Principal: f.admin, RunID: "run_a"}, "assign_worker", json.RawMessage(assignArgs(f))) if res.Confirmation == nil { t.Fatalf("expected a confirmation, got %+v", res) } if n := assignmentCount(t, h, f.orgID); n != 0 { t.Fatalf("%d assignments were created before anyone approved anything", n) } c := res.Confirmation // The names, not the ids. A person asked to approve a pair of uuids is a // person clicking yes without reading, which is the failure mode the whole // renderer exists to avoid. body, _ := json.Marshal(c) for _, want := range []string{"Maya Chen", "Bar Supervisor"} { if !strings.Contains(string(body), want) { t.Errorf("the confirmation does not mention %q: %s", want, body) } } if strings.Contains(c.Title, f.postingID) || strings.Contains(c.Summary, f.postingID) { t.Error("the confirmation shows a raw id where it should show a name") } if !strings.Contains(string(body), "12 Sep 2030") { t.Errorf("the confirmation does not say when the work is: %s", body) } } func TestAssignWorkerWritesOnlyAfterApproval(t *testing.T) { h := testutil.New(t) f := seedAssignable(t, h, "assign-approve") reg := liveRegistry(t, h) args := assignArgs(f) tc := tools.Context{Principal: f.admin, RunID: "run_b"} c := reg.Dispatch(context.Background(), tc, "assign_worker", json.RawMessage(args)).Confirmation if c == nil { t.Fatal("no confirmation was raised") } if n := assignmentCount(t, h, f.orgID); n != 0 { t.Fatalf("%d assignments before approval", n) } tc.Confirmation = c.Token res := reg.Dispatch(context.Background(), tc, "assign_worker", json.RawMessage(args)) if res.Error != nil { t.Fatalf("an approved write should have run: %+v", res.Error) } if n := assignmentCount(t, h, f.orgID); n != 1 { t.Fatalf("%d assignments after approval, want 1", n) } // And the row says an agent did it, not a person. `source` is what an // operator reads when they ask why somebody is on a roster. var source, name string if err := h.Pool.QueryRow(context.Background(), `SELECT source, worker_name FROM assignments WHERE org_id = $1::uuid`, f.orgID, ).Scan(&source, &name); err != nil { t.Fatalf("read back: %v", err) } if source != "agent" { t.Errorf("assignment source is %q; an agent-created row must say so", source) } if name != "Maya Chen" { t.Errorf("worker_name is %q, want Maya Chen", name) } } func TestApprovingOneAssignmentDoesNotApproveAnother(t *testing.T) { // The end-to-end version of the binding test, against real rows: a person // approves Maya on the bar shift, and the token is then presented for a // different period. Nothing may be written. h := testutil.New(t) f := seedAssignable(t, h, "assign-swap") reg := liveRegistry(t, h) tc := tools.Context{Principal: f.admin, RunID: "run_c"} c := reg.Dispatch(context.Background(), tc, "assign_worker", json.RawMessage(assignArgs(f))).Confirmation if c == nil { t.Fatal("no confirmation was raised") } other := f other.starts = f.starts.AddDate(0, 0, 1) other.ends = f.ends.AddDate(0, 0, 1) tc.Confirmation = c.Token res := reg.Dispatch(context.Background(), tc, "assign_worker", json.RawMessage(assignArgs(other))) if n := assignmentCount(t, h, f.orgID); n != 0 { t.Fatalf("%d assignments written on a substituted approval", n) } // The substituted call is described rather than merely refused, so the // person is asked about the shift that is actually being proposed. if res.Confirmation == nil { t.Fatal("the substituted call raised no confirmation of its own") } if res.Confirmation.Token == c.Token { t.Fatal("the approval for one shift was handed back for another") } } /* ── Authorization ──────────────────────────────────────────────────────── */ func TestTalentCannotAssignThemselves(t *testing.T) { // `assignments` lists to everyone and creates for operators only. A talent // caller may read their own roster perfectly well, so asking the policy the // READ question here would have let them put themselves on a shift — which // is exactly the bug authorizeOp exists to prevent. h := testutil.New(t) f := seedAssignable(t, h, "assign-talent") reg := liveRegistry(t, h) res := reg.Dispatch(context.Background(), tools.Context{Principal: f.talent, RunID: "run_d"}, "assign_worker", json.RawMessage(assignArgs(f))) if res.Confirmation != nil { t.Fatal("a talent caller was asked to approve a write they may not make") } if res.Error == nil || res.Error.Code != tools.CodeDenied { t.Fatalf("want the standard denial, got %+v", res.Error) } if n := assignmentCount(t, h, f.orgID); n != 0 { t.Fatalf("%d assignments created by a talent caller", n) } } func TestAssignWorkerCannotReachAnotherTenantsRole(t *testing.T) { // The posting is resolved behind the caller's own read predicate, so a role // id from another organization is not merely refused — it is refused // identically to one that does not exist. Otherwise assign_worker becomes a // way to ask whether a given uuid is real. h := testutil.New(t) mine := seedAssignable(t, h, "assign-mine") theirs := seedAssignable(t, h, "assign-theirs") reg := liveRegistry(t, h) crossed := mine crossed.postingID = theirs.postingID real := reg.Dispatch(context.Background(), tools.Context{Principal: mine.admin, RunID: "run_e"}, "assign_worker", json.RawMessage(assignArgs(crossed))) invented := mine invented.postingID = "00000000-0000-0000-0000-0000000000ff" fake := reg.Dispatch(context.Background(), tools.Context{Principal: mine.admin, RunID: "run_e"}, "assign_worker", json.RawMessage(assignArgs(invented))) if real.Error == nil { t.Fatal("a role from another tenant was accepted") } if fake.Error == nil { t.Fatal("an invented role id was accepted") } if real.Error.Code != fake.Error.Code || real.Error.Message != fake.Error.Message { t.Errorf("a real-but-forbidden role is distinguishable from an imaginary one:\n"+ " other tenant: %s — %s\n invented: %s — %s", real.Error.Code, real.Error.Message, fake.Error.Code, fake.Error.Message) } if n := assignmentCount(t, h, theirs.orgID); n != 0 { t.Fatal("a write crossed a tenant boundary") } } /* ── What the description warns about ───────────────────────────────────── */ func TestADoubleBookingIsWarnedAboutAndThenRefused(t *testing.T) { // The renderer warns; the handler refuses. Both, because the gap between // asking and answering is unbounded, and a clash that appears inside it is // one nobody was shown. h := testutil.New(t) f := seedAssignable(t, h, "assign-clash") reg := liveRegistry(t, h) args := assignArgs(f) tc := tools.Context{Principal: f.admin, RunID: "run_f"} // First assignment, approved and written. c := reg.Dispatch(context.Background(), tc, "assign_worker", json.RawMessage(args)).Confirmation tc.Confirmation = c.Token if res := reg.Dispatch(context.Background(), tc, "assign_worker", json.RawMessage(args)); res.Error != nil { t.Fatalf("first assignment failed: %+v", res.Error) } // Second, over the same window. Now the renderer has something to say. tc.Confirmation = "" res := reg.Dispatch(context.Background(), tc, "assign_worker", json.RawMessage(args)) if res.Confirmation == nil { t.Fatal("no confirmation was raised for the clashing assignment") } if len(res.Confirmation.Warnings) == 0 { t.Fatal("a double-booking was described without a warning") } found := false for _, w := range res.Confirmation.Warnings { if strings.Contains(w, "double-book") { found = true } } if !found { t.Errorf("the warnings do not mention the clash: %v", res.Confirmation.Warnings) } // And approving it anyway is still refused, because the clash is real now // rather than merely predicted. tc.Confirmation = res.Confirmation.Token if out := reg.Dispatch(context.Background(), tc, "assign_worker", json.RawMessage(args)); out.Error == nil { t.Fatal("an approved double-booking was written") } if n := assignmentCount(t, h, f.orgID); n != 1 { t.Fatalf("%d assignments, want 1 — the clash was written anyway", n) } } func TestGoingOverHeadcountIsWarnedAbout(t *testing.T) { h := testutil.New(t) f := seedAssignable(t, h, "assign-headcount") reg := liveRegistry(t, h) ctx := context.Background() // The posting asks for 2. Fill both with other people, so the third is over // headcount without also being a clash for our worker. for i, email := range []string{"a@example.test", "b@example.test"} { if _, err := h.Pool.Exec(ctx, ` INSERT INTO assignments (org_id, job_posting_id, worker_email, worker_name, starts_at, ends_at) VALUES ($1::uuid, $2::uuid, $3, $4, $5, $6)`, f.orgID, f.postingID, email, fmt.Sprintf("Worker %d", i), f.starts, f.ends); err != nil { t.Fatalf("seed assignment: %v", err) } } res := reg.Dispatch(ctx, tools.Context{Principal: f.admin, RunID: "run_g"}, "assign_worker", json.RawMessage(assignArgs(f))) if res.Confirmation == nil { t.Fatalf("expected a confirmation, got %+v", res) } found := false for _, w := range res.Confirmation.Warnings { if strings.Contains(w, "headcount") { found = true } } if !found { t.Errorf("going over headcount was not warned about: %v", res.Confirmation.Warnings) } } /* ── The lookups ────────────────────────────────────────────────────────── */ func TestOpenPositionsReturnsIdsAndCountsWhatIsLeftToFill(t *testing.T) { // §4: a tool that requires the model to guess an id is a design bug. This // is the lookup that makes assign_worker usable without guessing. h := testutil.New(t) f := seedAssignable(t, h, "open-positions") reg := liveRegistry(t, h) res := reg.Dispatch(context.Background(), tools.Context{Principal: f.admin, RunID: "run_h"}, "open_positions", json.RawMessage(`{}`)) if res.Error != nil { t.Fatalf("open_positions failed: %+v", res.Error) } body, _ := json.Marshal(res.Data) if !strings.Contains(string(body), f.postingID) { t.Errorf("open_positions did not return the role's id: %s", body) } if !strings.Contains(string(body), "stillToFill") { t.Errorf("open_positions does not say how many are still needed: %s", body) } } func TestAvailableWorkersExcludesSomebodyAlreadyBooked(t *testing.T) { h := testutil.New(t) f := seedAssignable(t, h, "available") reg := liveRegistry(t, h) ctx := context.Background() window := fmt.Sprintf(`{"starts_at":%q,"ends_at":%q}`, f.starts.Format(time.RFC3339), f.ends.Format(time.RFC3339)) tc := tools.Context{Principal: f.admin, RunID: "run_i"} res := reg.Dispatch(ctx, tc, "available_workers", json.RawMessage(window)) if res.Error != nil { t.Fatalf("available_workers failed: %+v", res.Error) } if body, _ := json.Marshal(res.Data); !strings.Contains(string(body), f.worker) { t.Fatalf("a free worker was not listed: %s", body) } if _, err := h.Pool.Exec(ctx, ` INSERT INTO assignments (org_id, job_posting_id, worker_email, worker_name, starts_at, ends_at) VALUES ($1::uuid, $2::uuid, $3, 'Maya Chen', $4, $5)`, f.orgID, f.postingID, f.worker, f.starts, f.ends); err != nil { t.Fatalf("seed clash: %v", err) } res = reg.Dispatch(ctx, tc, "available_workers", json.RawMessage(window)) if body, _ := json.Marshal(res.Data); strings.Contains(string(body), f.worker) { t.Errorf("a booked worker was still reported as available: %s", body) } } func TestAvailableWorkersSaysWhatAvailableMeans(t *testing.T) { // The word carries more meaning to a reader than the query can support. A // model handed a tool called `available_workers` will otherwise report its // output as availability in the ordinary sense, and a manager will act on // it as though somebody had been asked. h := testutil.New(t) f := seedAssignable(t, h, "available-meaning") reg := liveRegistry(t, h) res := reg.Dispatch(context.Background(), tools.Context{Principal: f.admin, RunID: "run_j"}, "available_workers", json.RawMessage(fmt.Sprintf(`{"starts_at":%q}`, f.starts.Format(time.RFC3339)))) if res.Error != nil { t.Fatalf("available_workers failed: %+v", res.Error) } body, _ := json.Marshal(res.Data) if !strings.Contains(string(body), "not mean") && !strings.Contains(string(body), "Not a statement") { t.Errorf("the result does not qualify what availability means: %s", body) } }