444 lines
16 KiB
Go
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)
|
|
}
|
|
}
|