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

444 lines
16 KiB
Go

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