Files
Suriyakumarvijayanayagam dc785b917c
Some checks failed
CI / test (push) Failing after 4m41s
CI / fixture (push) Failing after 8s
Separate what a worker does from what a company needs filled
Owliver could offer neither create. The Create Position flow worked and no chip
anywhere suggested it, because the chip row is entirely the backend's static
catalogue and no intent in it wrote anything. The gap was never in the
frontend's trigger matching — every phrasing already routed.

`employee_roles` is the supply side of `job_postings`. A posting is what the
ORGANIZATION needs filled; this is what a WORKER says they do. They share a
vocabulary and almost nothing else: "3 years" on a posting is a minimum an
applicant must clear, and the same words here are what the person has. There is
deliberately no foreign key between them — supply and demand already meet
through `job_applications`, which carries the funnel, the interview and the
outcome, and a second weaker link would disagree with it the first time
somebody withdrew.

NO NEW COMPANY ENTITY, AND THAT IS THE LOAD-BEARING DECISION. "Create a company
position" reads like it needs a client record. `organizations` is the TENANT —
absent from the resource table, absent from the policy map, written only by the
seeder — so creating a row there from a chat flow would provision a new tenant,
and the position would carry an org_id the operator's session cannot see. The
operator could never view the record they just created. That breaks I5 and I1
to add a feature nobody asked for. The client stays free text on the posting,
per blueprint decision D2, and the flow simply offers the clients this
organization already staffs for as chips. No schema change, no endpoint change.

Create is operators-only, and that is an I1 decision rather than a deferral.
The worker is named explicitly on the row and is deliberately NOT derived from
the session, because an operator recording a role on somebody's behalf is the
whole point of the flow. Granting talent the same Create would let a talent
caller write a role under any worker_email in the tenant — the attribution hole
Phase 3D closed elsewhere. Talent reads its own via a ScopeEmail predicate,
which is in place now so the grant is one line when a talent console exists.

`created_by` is in gen_resources.py's SERVER_OWNED as well as the policy's
Derived list. Both are required and the pairing is easy to miss: Derived fills
the column from the session, SERVER_OWNED is what makes the descriptor ReadOnly
so a request body cannot set it in the first place. Without it,
TestDerivedColumnsAreReadOnlyOrTalentScoped fails — verified by mutation, not
by reading.

The two catalogue intents carry PHRASE terms only. A bare "position" or "role"
term scores 10, the same as every reading on that page, and wins the tie on
declaration order — so a create chip would have arrived by evicting
`positions-attention` from the exact ordered result TestPositionsSuggestions
asserts. An offer to create something must not displace the reading a person
actually asked for. Neither declares a Subject, on the precedent of
`position-spec-steps`: a Subject would let the bare query "summarize" match
through matchShape and survive filterOnTopic. Neither declares a Signal, so an
empty composer still reports what the organization needs rather than proposing
paperwork.

Chip text is the coupling with nothing else holding it together: no page
context declares `capabilities`, so every server suggestion dispatches as its
own TEXT and is answered by whichever skill's trigger that text matches. A
renamed chip would open nothing, silently. Asserted on the frontend side.

The down migration drops `employee_role_status` and keeps `english_level`,
which is shared with job_postings.english_required and
job_applications.english_level. Rolled back and re-applied against the
database to prove it, not asserted.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PJvibeSc1JYXjatankqM1g
2026-09-02 15:29:25 +05:30

1212 lines
42 KiB
Go

package httpserver_test
import (
"bytes"
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"sort"
"strings"
"testing"
"time"
"github.com/krow/krow-backend/go-api/internal/db"
"github.com/krow/krow-backend/go-api/internal/httpserver"
"github.com/krow/krow-backend/go-api/internal/testutil"
)
type api struct {
t *testing.T
handler http.Handler
orgID string
h *testutil.Harness
srv *httpserver.Server
// The signed-in session every do() call carries, and who it belongs to.
cookie *http.Cookie
userID string
email string
}
// newAPI builds a server and signs in as the seeded user.
//
// The sign-in is part of the harness rather than part of each test because
// every endpoint below now requires one: without it the thirty-odd existing
// tests in this file would all assert 401 instead of what they were written to
// check. They are unchanged; the cookie travels in do().
func newAPI(t *testing.T, opts ...httpserver.Option) *api {
t.Helper()
h := testutil.New(t)
srv := newServer(t, h, nil, opts...)
a := &api{t: t, handler: srv.Handler(), orgID: h.OrgID, h: h, srv: srv}
a.userID, a.email = seededUser(t, h.Pool)
setPassword(t, h.Pool, a.userID)
result := signIn(t, a.handler, a.email, harnessPassword, false)
if result.code != http.StatusOK || result.cookie == nil {
t.Fatalf("the harness could not sign in: status %d, cookie %v", result.code, result.cookie)
}
a.cookie = result.cookie
return a
}
type response struct {
code int
body map[string]any
}
func (a *api) do(method, path string, payload any) response {
a.t.Helper()
var body io.Reader
if payload != nil {
raw, err := json.Marshal(payload)
if err != nil {
a.t.Fatalf("encode payload: %v", err)
}
body = bytes.NewReader(raw)
}
req := httptest.NewRequest(method, path, body)
if a.cookie != nil {
req.AddCookie(a.cookie)
}
rec := httptest.NewRecorder()
a.handler.ServeHTTP(rec, req)
out := response{code: rec.Code}
if rec.Body.Len() > 0 {
if err := json.Unmarshal(rec.Body.Bytes(), &out.body); err != nil {
a.t.Fatalf("%s %s: response is not JSON: %s", method, path, rec.Body.String())
}
}
return out
}
// doAnon is do() without the session cookie: the request a signed-out browser,
// or anyone who has never signed in, actually sends.
func (a *api) doAnon(method, path string, payload any) response {
a.t.Helper()
var body io.Reader
if payload != nil {
raw, err := json.Marshal(payload)
if err != nil {
a.t.Fatalf("encode payload: %v", err)
}
body = bytes.NewReader(raw)
}
req := httptest.NewRequest(method, path, body)
rec := httptest.NewRecorder()
a.handler.ServeHTTP(rec, req)
out := response{code: rec.Code}
if rec.Body.Len() > 0 {
_ = json.Unmarshal(rec.Body.Bytes(), &out.body)
}
return out
}
// as is do() performed by a specific actor, for the role and ownership tests.
func (a *api) as(act actor, method, path string, payload any) response {
a.t.Helper()
var body io.Reader
if payload != nil {
raw, err := json.Marshal(payload)
if err != nil {
a.t.Fatalf("encode payload: %v", err)
}
body = bytes.NewReader(raw)
}
req := httptest.NewRequest(method, path, body)
if act.cookie != nil {
req.AddCookie(act.cookie)
}
rec := httptest.NewRecorder()
a.handler.ServeHTTP(rec, req)
out := response{code: rec.Code}
if rec.Body.Len() > 0 {
_ = json.Unmarshal(rec.Body.Bytes(), &out.body)
}
return out
}
// codeOrEmpty reads the contract's error code, or "" when the response carried
// no error envelope. Distinct from errCode, which fails the test when there is
// no error: the role matrix needs to look at successes and refusals alike.
func (r response) codeOrEmpty() string {
body, _ := r.body["error"].(map[string]any)
if body == nil {
return ""
}
code, _ := body["code"].(string)
return code
}
// doWith is do() with a caller-supplied cookie, for tests that hold more than
// one session.
func (a *api) doWith(cookie *http.Cookie, method, path string) response {
a.t.Helper()
req := httptest.NewRequest(method, path, nil)
if cookie != nil {
req.AddCookie(cookie)
}
rec := httptest.NewRecorder()
a.handler.ServeHTTP(rec, req)
out := response{code: rec.Code}
if rec.Body.Len() > 0 {
_ = json.Unmarshal(rec.Body.Bytes(), &out.body)
}
return out
}
func (r response) records(t *testing.T) []map[string]any {
t.Helper()
raw, ok := r.body["data"].([]any)
if !ok {
t.Fatalf("expected a data array, got %#v", r.body)
}
out := make([]map[string]any, 0, len(raw))
for _, e := range raw {
out = append(out, e.(map[string]any))
}
return out
}
func (r response) record(t *testing.T) map[string]any {
t.Helper()
rec, ok := r.body["data"].(map[string]any)
if !ok {
t.Fatalf("expected a data object, got %#v", r.body)
}
return rec
}
func (r response) meta(t *testing.T) map[string]any {
t.Helper()
m, ok := r.body["meta"].(map[string]any)
if !ok {
t.Fatalf("expected meta, got %#v", r.body)
}
return m
}
func (r response) errCode(t *testing.T) string {
t.Helper()
e, ok := r.body["error"].(map[string]any)
if !ok {
t.Fatalf("expected an error envelope, got %#v", r.body)
}
return e["code"].(string)
}
/* ── Collections ────────────────────────────────────────────────────────── */
func TestListEveryResource(t *testing.T) {
a := newAPI(t)
// Every collection endpoint answers 200 with an envelope, seeded or not.
for _, path := range []string{
"job-postings", "job-applications", "ai-interviews", "staff", "worker-profiles",
"courses", "learning-paths", "role-categories", "certifications",
"user-activity", "evidence", "assignments", "shift-records",
"employee-roles",
} {
r := a.do("GET", "/api/v1/"+path, nil)
if r.code != http.StatusOK {
t.Errorf("GET %s = %d, want 200", path, r.code)
continue
}
r.records(t)
r.meta(t)
}
}
// An empty result is 200 with an empty array, never a 404. api-contract.md §8.
//
// Asked as a filter that matches nothing, rather than as a collection that
// happens to be empty. This used to read /assignments on the strength of the
// fixture shipping none, so seeding a single assignment broke a test about
// status codes. The contract is about the empty result, not about which
// collection is empty this week.
func TestEmptyCollectionIs200(t *testing.T) {
a := newAPI(t)
r := a.do("GET", "/api/v1/assignments?status=cancelled", nil)
if r.code != http.StatusOK {
t.Fatalf("code = %d, want 200", r.code)
}
if got := r.records(t); len(got) != 0 {
t.Fatalf("expected no assignments, got %d", len(got))
}
if total := r.meta(t)["total"].(float64); total != 0 {
t.Errorf("meta.total = %v, want 0", total)
}
}
// The default limit is the resource's own, taken from the frontend call site.
// job-applications is 200 sorted -ai_score; shift-records is 500.
func TestEndpointSpecificDefaults(t *testing.T) {
a := newAPI(t)
for _, tc := range []struct {
path string
limit float64
}{
{"job-postings", 100}, {"job-applications", 200}, {"shift-records", 500},
{"worker-profiles", 500}, {"courses", 200}, {"user-activity", 500},
{"ai-interviews", 100}, {"staff", 100}, {"role-categories", 100},
{"certifications", 200}, {"evidence", 200}, {"assignments", 500},
{"learning-paths", 100}, {"employee-roles", 200},
} {
m := a.do("GET", "/api/v1/"+tc.path, nil).meta(t)
if m["limit"] != tc.limit {
t.Errorf("%s default limit = %v, want %v", tc.path, m["limit"], tc.limit)
}
}
}
func TestLimitAndTruncationMeta(t *testing.T) {
a := newAPI(t)
r := a.do("GET", "/api/v1/job-applications?limit=5", nil)
recs, m := r.records(t), r.meta(t)
if len(recs) != 5 {
t.Fatalf("returned %d records, want 5", len(recs))
}
if m["returned"] != float64(5) {
t.Errorf("meta.returned = %v, want 5", m["returned"])
}
if m["total"] != float64(24) {
t.Errorf("meta.total = %v, want 24 (the total ignores the limit)", m["total"])
}
if m["truncated"] != true {
t.Error("meta.truncated should be true when the page does not reach the total")
}
full := a.do("GET", "/api/v1/job-applications", nil)
if full.meta(t)["truncated"] != false {
t.Error("meta.truncated should be false when everything fits")
}
}
func TestOffsetPaging(t *testing.T) {
a := newAPI(t)
first := a.do("GET", "/api/v1/job-applications?limit=10", nil).records(t)
second := a.do("GET", "/api/v1/job-applications?limit=10&offset=10", nil).records(t)
if len(first) != 10 || len(second) != 10 {
t.Fatalf("page sizes = %d, %d", len(first), len(second))
}
seen := map[string]bool{}
for _, r := range first {
seen[r["id"].(string)] = true
}
for _, r := range second {
if seen[r["id"].(string)] {
t.Fatalf("record %s appeared on both pages", r["id"])
}
}
}
/* ── Sorting ────────────────────────────────────────────────────────────── */
// The default sort is the resource's own: -ai_score for applications.
func TestDefaultSortIsResourceSpecific(t *testing.T) {
a := newAPI(t)
recs := a.do("GET", "/api/v1/job-applications", nil).records(t)
prev := 101.0
for _, r := range recs {
score := r["ai_score"].(float64)
if score > prev {
t.Fatalf("applications are not sorted by -ai_score: %v after %v", score, prev)
}
prev = score
}
profiles := a.do("GET", "/api/v1/worker-profiles", nil).records(t)
prev = 1e9
for _, r := range profiles {
score := r["krow_score"].(float64)
if score > prev {
t.Fatalf("profiles are not sorted by -krow_score: %v after %v", score, prev)
}
prev = score
}
}
// NULLS LAST in BOTH directions. store.js returns before applying the
// descending negation, so a null is greater than everything either way.
// PostgreSQL's default is NULLS FIRST on DESC, so the descending case is the
// one that breaks if the ordering is left implicit. api-contract.md §7.1.
func TestNullsSortLastInBothDirections(t *testing.T) {
a := newAPI(t)
// Every seeded posting has a null start_date, so a deliberate mix is built
// here — otherwise the assertion passes trivially and proves nothing.
for _, d := range []any{"2026-09-01", nil, "2026-07-15", nil, "2026-08-20"} {
payload := map[string]any{"title": "Nulls Test"}
if d != nil {
payload["start_date"] = d
}
if r := a.do("POST", "/api/v1/job-postings", payload); r.code != http.StatusCreated {
t.Fatalf("setup create = %d: %#v", r.code, r.body)
}
}
for _, sortSpec := range []string{"start_date", "-start_date"} {
recs := a.do("GET", "/api/v1/job-postings?sort="+sortSpec+"&limit=500", nil).records(t)
var values []any
for _, r := range recs {
values = append(values, r["start_date"])
}
firstNull := -1
for i, v := range values {
if v == nil {
firstNull = i
break
}
}
if firstNull == -1 {
t.Fatalf("sort=%s: no nulls present, the test is not exercising anything", sortSpec)
}
for i := firstNull; i < len(values); i++ {
if values[i] != nil {
t.Fatalf("sort=%s: %v appears at position %d, after a null at %d — NULLS LAST is not applied",
sortSpec, values[i], i, firstNull)
}
}
if firstNull < 3 {
t.Fatalf("sort=%s: only %d non-null values sorted before the nulls, expected 3",
sortSpec, firstNull)
}
// And the non-null values are genuinely ordered.
for i := 1; i < firstNull; i++ {
prev, cur := values[i-1].(string), values[i].(string)
if sortSpec == "start_date" && cur < prev {
t.Errorf("ascending order broken: %s after %s", cur, prev)
}
if sortSpec == "-start_date" && cur > prev {
t.Errorf("descending order broken: %s after %s", cur, prev)
}
}
}
}
// PostgreSQL does not guarantee a stable sort. Every ORDER BY appends `, id`
// so repeated identical requests return the same order. api-contract.md §7.3.
func TestStableSortWithIDTiebreaker(t *testing.T) {
a := newAPI(t)
// Many applications share ai_score 0, so the tiebreaker decides their order.
var first []string
for attempt := 0; attempt < 5; attempt++ {
recs := a.do("GET", "/api/v1/job-applications?sort=-ai_score", nil).records(t)
ids := make([]string, 0, len(recs))
for _, r := range recs {
ids = append(ids, r["id"].(string))
}
if attempt == 0 {
first = ids
continue
}
for i := range ids {
if ids[i] != first[i] {
t.Fatalf("order changed between identical requests at position %d", i)
}
}
}
// And the tiebreaker really is id: within a score group, ids ascend.
recs := a.do("GET", "/api/v1/job-applications?sort=-ai_score", nil).records(t)
for i := 1; i < len(recs); i++ {
if recs[i]["ai_score"] != recs[i-1]["ai_score"] {
continue
}
if recs[i]["id"].(string) < recs[i-1]["id"].(string) {
t.Fatalf("ids do not ascend within an equal-score group: %s after %s",
recs[i]["id"], recs[i-1]["id"])
}
}
}
func TestSortAscendingAndUnknownField(t *testing.T) {
a := newAPI(t)
recs := a.do("GET", "/api/v1/job-applications?sort=ai_score", nil).records(t)
prev := -1.0
for _, r := range recs {
if score := r["ai_score"].(float64); score < prev {
t.Fatalf("ascending sort broken: %v after %v", score, prev)
} else {
prev = score
}
}
r := a.do("GET", "/api/v1/job-applications?sort=-nonsense", nil)
if r.code != http.StatusBadRequest {
t.Errorf("unknown sort field = %d, want 400", r.code)
}
if code := r.errCode(t); code != "invalid_query" {
t.Errorf("error code = %q, want invalid_query", code)
}
}
/* ── Filtering ──────────────────────────────────────────────────────────── */
func TestFilterEquality(t *testing.T) {
a := newAPI(t)
postings := a.do("GET", "/api/v1/job-postings", nil).records(t)
var target string
for _, p := range postings {
if p["legacy_id"] == "job_security" {
target = p["id"].(string)
}
}
if target == "" {
t.Fatal("job_security posting not found")
}
recs := a.do("GET", "/api/v1/job-applications?job_posting_id="+target, nil).records(t)
if len(recs) != 6 {
t.Errorf("applications for job_security = %d, want 6", len(recs))
}
for _, r := range recs {
if r["job_posting_id"] != target {
t.Errorf("filter leaked a record from posting %v", r["job_posting_id"])
}
}
}
// An array-valued query parameter means membership, matching store.js's
// `Array.isArray(want) ? want.includes(got)`. api-contract.md §6.
func TestFilterArrayMeansIN(t *testing.T) {
a := newAPI(t)
// `assigned` is asked for deliberately: the fixture now carries one, and this
// filter is literal — it matches the stored value, not the product's rule
// that an assigned candidate also counts as hired.
recs := a.do("GET",
"/api/v1/job-applications?status=hired&status=interview&status=assigned", nil).records(t)
if len(recs) != 8 {
t.Errorf("hired+interview+assigned = %d, want 8 (2 hired, 5 interview, 1 assigned)", len(recs))
}
for _, r := range recs {
if s := r["status"].(string); s != "hired" && s != "interview" && s != "assigned" {
t.Errorf("membership filter leaked status %q", s)
}
}
}
// Email columns are citext, so matching is case-insensitive server-side.
// api-contract.md §6.1.
func TestFilterEmailIsCaseInsensitive(t *testing.T) {
a := newAPI(t)
lower := a.do("GET", "/api/v1/worker-profiles?email=maria.gonzalez@example.com", nil).records(t)
upper := a.do("GET", "/api/v1/worker-profiles?email=MARIA.GONZALEZ@EXAMPLE.COM", nil).records(t)
if len(lower) != 1 {
t.Fatalf("expected exactly one profile, got %d", len(lower))
}
if len(upper) != len(lower) {
t.Errorf("case-insensitive match failed: %d vs %d", len(upper), len(lower))
}
}
func TestFilterRejectsUnknownAndUnfilterableFields(t *testing.T) {
a := newAPI(t)
if r := a.do("GET", "/api/v1/job-postings?nonsense=1", nil); r.code != http.StatusBadRequest {
t.Errorf("unknown filter field = %d, want 400", r.code)
}
// Arrays are not filterable: store.js compares with === and matches nothing,
// so supporting containment here would be a silent behaviour change.
if r := a.do("GET", "/api/v1/job-postings?responsibilities=x", nil); r.code != http.StatusBadRequest {
t.Errorf("array filter = %d, want 400", r.code)
}
if r := a.do("GET", "/api/v1/job-postings?vetting_criteria=x", nil); r.code != http.StatusBadRequest {
t.Errorf("jsonb filter = %d, want 400", r.code)
}
}
/* ── Get ────────────────────────────────────────────────────────────────── */
func TestGetAndNotFound(t *testing.T) {
a := newAPI(t)
postings := a.do("GET", "/api/v1/job-postings", nil).records(t)
id := postings[0]["id"].(string)
r := a.do("GET", "/api/v1/job-postings/"+id, nil)
if r.code != http.StatusOK {
t.Fatalf("get = %d, want 200", r.code)
}
if r.record(t)["id"] != id {
t.Error("returned the wrong record")
}
missing := a.do("GET", "/api/v1/job-postings/00000000-0000-0000-0000-000000000000", nil)
if missing.code != http.StatusNotFound {
t.Errorf("missing record = %d, want 404", missing.code)
}
if code := missing.errCode(t); code != "not_found" {
t.Errorf("error code = %q, want not_found", code)
}
// store.js throws "<Entity> <id> not found" using the frontend entity name.
msg := missing.body["error"].(map[string]any)["message"].(string)
if want := "JobPosting 00000000-0000-0000-0000-000000000000 not found"; msg != want {
t.Errorf("message = %q, want %q", msg, want)
}
// A malformed id is simply an id that cannot be found.
if r := a.do("GET", "/api/v1/job-postings/not-a-uuid", nil); r.code != http.StatusNotFound {
t.Errorf("malformed id = %d, want 404", r.code)
}
}
/* ── Create ─────────────────────────────────────────────────────────────── */
func TestCreateAppliesDefaultsAndReturnsWholeRecord(t *testing.T) {
a := newAPI(t)
r := a.do("POST", "/api/v1/job-postings", map[string]any{"title": "Test Bartender"})
if r.code != http.StatusCreated {
t.Fatalf("create = %d, want 201: %#v", r.code, r.body)
}
rec := r.record(t)
if rec["title"] != "Test Bartender" {
t.Errorf("title = %v", rec["title"])
}
// The response is the complete record, defaults included.
for _, field := range []string{"id", "created_date", "updated_date", "status", "vetting_criteria", "responsibilities"} {
if _, ok := rec[field]; !ok {
t.Errorf("created record is missing %s", field)
}
}
if rec["status"] != "draft" {
t.Errorf("default status = %v, want draft", rec["status"])
}
if rec["headcount"] != float64(1) {
t.Errorf("default headcount = %v, want 1", rec["headcount"])
}
}
func TestCreateRejectsMissingRequiredAndBlank(t *testing.T) {
a := newAPI(t)
r := a.do("POST", "/api/v1/job-postings", map[string]any{})
if r.code != http.StatusUnprocessableEntity {
t.Fatalf("missing title = %d, want 422", r.code)
}
if code := r.errCode(t); code != "validation_failed" {
t.Errorf("code = %q, want validation_failed", code)
}
if r := a.do("POST", "/api/v1/job-postings", map[string]any{"title": " "}); r.code != http.StatusUnprocessableEntity {
t.Errorf("blank title = %d, want 422", r.code)
}
}
// Unknown fields are rejected, not ignored. Silently dropping them is exactly
// how interview_id, training_outline and score_breakdown would have been lost.
func TestCreateRejectsUnknownFields(t *testing.T) {
a := newAPI(t)
r := a.do("POST", "/api/v1/job-postings", map[string]any{"title": "X", "not_a_column": 1})
if r.code != http.StatusUnprocessableEntity {
t.Fatalf("unknown field = %d, want 422", r.code)
}
details := r.body["error"].(map[string]any)["details"].(map[string]any)
if details["not_a_column"] == nil {
t.Errorf("the offending field is not named in details: %#v", details)
}
}
// Server-owned fields are ignored rather than rejected. api-contract.md §3.1.
func TestCreateIgnoresServerOwnedFields(t *testing.T) {
a := newAPI(t)
r := a.do("POST", "/api/v1/job-postings", map[string]any{
"title": "Ignore Me",
"id": "11111111-1111-1111-1111-111111111111",
"created_date": "2001-01-01T00:00:00.000Z",
})
if r.code != http.StatusCreated {
t.Fatalf("create = %d, want 201: %#v", r.code, r.body)
}
rec := r.record(t)
if rec["id"] == "11111111-1111-1111-1111-111111111111" {
t.Error("a client-supplied id was honoured")
}
if rec["created_date"] == "2001-01-01T00:00:00.000Z" {
t.Error("a client-supplied created_date was honoured")
}
}
func TestCreateRejectsInvalidEnum(t *testing.T) {
a := newAPI(t)
r := a.do("POST", "/api/v1/job-postings", map[string]any{"title": "X", "status": "archived"})
if r.code != http.StatusUnprocessableEntity {
t.Fatalf("invalid enum = %d, want 422", r.code)
}
details := r.body["error"].(map[string]any)["details"].(map[string]any)
if details["status"] == nil {
t.Error("details should name the status field")
}
}
func TestCreateEnforcesForeignKeys(t *testing.T) {
a := newAPI(t)
r := a.do("POST", "/api/v1/job-applications", map[string]any{
"job_posting_id": "00000000-0000-0000-0000-000000000000",
"applicant_name": "Nobody",
"email": "nobody@example.com",
})
if r.code != http.StatusUnprocessableEntity {
t.Fatalf("dangling foreign key = %d, want 422: %#v", r.code, r.body)
}
}
func TestCreateEnforcesUniqueness(t *testing.T) {
a := newAPI(t)
apps := a.do("GET", "/api/v1/job-applications", nil).records(t)
existing := apps[0]
r := a.do("POST", "/api/v1/job-applications", map[string]any{
"job_posting_id": existing["job_posting_id"],
"applicant_name": "Duplicate",
"email": existing["email"],
})
if r.code != http.StatusConflict {
t.Fatalf("duplicate (job_posting_id, email) = %d, want 409: %#v", r.code, r.body)
}
if code := r.errCode(t); code != "conflict" {
t.Errorf("code = %q, want conflict", code)
}
}
/* ── Update ─────────────────────────────────────────────────────────────── */
// PATCH is a shallow merge: absent keys are untouched, and a supplied object
// REPLACES rather than merging into the stored one. api-contract.md §3.2.
func TestPatchIsShallowMerge(t *testing.T) {
a := newAPI(t)
created := a.do("POST", "/api/v1/job-postings", map[string]any{
"title": "Shallow", "company": "Acme", "location": "Nowhere",
"responsibilities": []string{"a", "b"},
}).record(t)
id := created["id"].(string)
patched := a.do("PATCH", "/api/v1/job-postings/"+id,
map[string]any{"location": "Somewhere"}).record(t)
if patched["location"] != "Somewhere" {
t.Errorf("location = %v, want Somewhere", patched["location"])
}
if patched["company"] != "Acme" {
t.Errorf("an untouched field changed: company = %v", patched["company"])
}
if patched["title"] != "Shallow" {
t.Errorf("an untouched field changed: title = %v", patched["title"])
}
// A nested object is replaced wholesale, not deep-merged. useSubmitChallenge
// depends on whole arrays being replaced rather than appended to.
withCriteria := a.do("PATCH", "/api/v1/job-postings/"+id,
map[string]any{"vetting_criteria": map[string]any{"experience": 30}}).record(t)
vc := withCriteria["vetting_criteria"].(map[string]any)
if len(vc) != 1 || vc["experience"] != float64(30) {
t.Errorf("vetting_criteria was deep-merged, not replaced: %#v", vc)
}
// Same for arrays.
withArray := a.do("PATCH", "/api/v1/job-postings/"+id,
map[string]any{"responsibilities": []string{"z"}}).record(t)
resp := withArray["responsibilities"].([]any)
if len(resp) != 1 || resp[0] != "z" {
t.Errorf("responsibilities were appended rather than replaced: %#v", resp)
}
}
func TestPatchUpdatesTimestampAndMissingIs404(t *testing.T) {
a := newAPI(t)
created := a.do("POST", "/api/v1/job-postings", map[string]any{"title": "Stamped"}).record(t)
id := created["id"].(string)
time.Sleep(5 * time.Millisecond)
patched := a.do("PATCH", "/api/v1/job-postings/"+id, map[string]any{"title": "Restamped"}).record(t)
if patched["updated_date"] == created["updated_date"] {
t.Error("updated_date did not move on PATCH")
}
if patched["created_date"] != created["created_date"] {
t.Error("created_date changed on PATCH")
}
missing := a.do("PATCH", "/api/v1/job-postings/00000000-0000-0000-0000-000000000000",
map[string]any{"title": "Ghost"})
if missing.code != http.StatusNotFound {
t.Errorf("patch on a missing record = %d, want 404", missing.code)
}
}
// The interview_id round trip: the exact write AIInterviewModal.jsx:180 makes.
func TestPatchApplicationInterviewID(t *testing.T) {
a := newAPI(t)
apps := a.do("GET", "/api/v1/job-applications?limit=1", nil).records(t)
interviews := a.do("GET", "/api/v1/ai-interviews", nil).records(t)
if len(apps) == 0 || len(interviews) == 0 {
t.Fatal("need a seeded application and interview")
}
id := apps[0]["id"].(string)
interviewID := interviews[0]["id"].(string)
rec := a.do("PATCH", "/api/v1/job-applications/"+id, map[string]any{
"status": "interview", "interview_id": interviewID, "ai_score": 81,
}).record(t)
if rec["interview_id"] != interviewID {
t.Errorf("interview_id = %v, want %v", rec["interview_id"], interviewID)
}
if rec["status"] != "interview" {
t.Errorf("status = %v", rec["status"])
}
if rec["ai_score"] != float64(81) {
t.Errorf("ai_score = %v", rec["ai_score"])
}
}
// The two other reconciliation columns, round-tripped.
func TestPatchTrainingOutlineAndProfileScoreBreakdown(t *testing.T) {
a := newAPI(t)
courses := a.do("GET", "/api/v1/courses?limit=1", nil).records(t)
course := a.do("PATCH", "/api/v1/courses/"+courses[0]["id"].(string), map[string]any{
"training_outline": []string{"Mise en place", "Service", "Close down"},
}).record(t)
outline, ok := course["training_outline"].([]any)
if !ok || len(outline) != 3 || outline[0] != "Mise en place" {
t.Errorf("training_outline did not round-trip: %#v", course["training_outline"])
}
profiles := a.do("GET", "/api/v1/worker-profiles?limit=1", nil).records(t)
profile := a.do("PATCH", "/api/v1/worker-profiles/"+profiles[0]["id"].(string), map[string]any{
"score_breakdown": map[string]any{"reliability": 88, "experience": 71},
}).record(t)
sb, ok := profile["score_breakdown"].(map[string]any)
if !ok || sb["reliability"] != float64(88) {
t.Errorf("score_breakdown did not round-trip: %#v", profile["score_breakdown"])
}
}
/* ── Delete ─────────────────────────────────────────────────────────────── */
// DELETE is idempotent and returns { id } whether or not a row went, because
// store.js never throws and both live callers delete inside loops without
// checking. api-contract.md §12.7.
func TestDeleteIsIdempotent(t *testing.T) {
a := newAPI(t)
apps := a.do("GET", "/api/v1/job-applications?limit=1", nil).records(t)
id := apps[0]["id"].(string)
first := a.do("DELETE", "/api/v1/job-applications/"+id, nil)
if first.code != http.StatusOK {
t.Fatalf("delete = %d, want 200", first.code)
}
if first.record(t)["id"] != id {
t.Error("delete did not return the id")
}
// Gone, and deleting again still succeeds.
if r := a.do("GET", "/api/v1/job-applications?limit=500", nil); len(r.records(t)) != 23 {
t.Errorf("after delete there are %d applications, want 23", len(r.records(t)))
}
second := a.do("DELETE", "/api/v1/job-applications/"+id, nil)
if second.code != http.StatusOK {
t.Errorf("second delete = %d, want 200 (idempotent)", second.code)
}
missing := a.do("DELETE", "/api/v1/job-applications/00000000-0000-0000-0000-000000000000", nil)
if missing.code != http.StatusOK {
t.Errorf("delete of a never-existing record = %d, want 200", missing.code)
}
malformed := a.do("DELETE", "/api/v1/job-applications/not-a-uuid", nil)
if malformed.code != http.StatusOK {
t.Errorf("delete with a malformed id = %d, want 200", malformed.code)
}
}
/* ── Route surface ──────────────────────────────────────────────────────── */
// The database having a table is never a reason for an endpoint to exist.
func TestUnsupportedOperationsAreNotRouted(t *testing.T) {
a := newAPI(t)
postings := a.do("GET", "/api/v1/job-postings", nil).records(t)
id := postings[0]["id"].(string)
// Nothing in the frontend deletes a job posting.
if r := a.do("DELETE", "/api/v1/job-postings/"+id, nil); r.code != http.StatusMethodNotAllowed {
t.Errorf("DELETE job-postings = %d, want 405", r.code)
}
// Shift records are read-only: U1 is unresolved, so there is no write path.
if r := a.do("POST", "/api/v1/shift-records", map[string]any{}); r.code != http.StatusMethodNotAllowed {
t.Errorf("POST shift-records = %d, want 405", r.code)
}
// Assignments are listed and created, never fetched by id or updated — so
// no item route exists for them at all, and a wrong method is a 404 rather
// than a 405 (405 needs the path pattern to exist under another method).
if r := a.do("PATCH", "/api/v1/assignments/"+id, map[string]any{}); r.code != http.StatusNotFound {
t.Errorf("PATCH assignments = %d, want 404", r.code)
}
// Badge has a table and is seeded, but useBadges has zero consumers.
if r := a.do("GET", "/api/v1/badges", nil); r.code != http.StatusNotFound {
t.Errorf("GET badges = %d, want 404 (no route registered)", r.code)
}
// The mux's own 404/405 replies are rewritten into the error envelope, so
// every response from the API is JSON.
if code := a.do("DELETE", "/api/v1/job-postings/"+id, nil).errCode(t); code != "method_not_allowed" {
t.Errorf("405 error code = %q, want method_not_allowed", code)
}
if code := a.do("GET", "/api/v1/badges", nil).errCode(t); code != "not_found" {
t.Errorf("404 error code = %q, want not_found", code)
}
// Job postings have no filter call site but are still gettable by id.
if r := a.do("GET", "/api/v1/job-postings/"+id, nil); r.code != http.StatusOK {
t.Errorf("GET job-postings/{id} = %d, want 200", r.code)
}
}
/* ── Current user ───────────────────────────────────────────────────────── */
func TestCurrentUserAndPreferences(t *testing.T) {
a := newAPI(t)
me := a.do("GET", "/api/v1/me", nil)
if me.code != http.StatusOK {
t.Fatalf("GET /me = %d", me.code)
}
user := me.record(t)
if user["email"] != "demo@krow.app" {
t.Errorf("email = %v, want demo@krow.app", user["email"])
}
// krowHooks.js:42 reads user?.preferences straight off this object.
prefs, ok := user["preferences"].(map[string]any)
if !ok {
t.Fatalf("preferences are not embedded in the user: %#v", user)
}
if prefs["owliverDefault"] != true {
t.Errorf("owliverDefault = %v, want true", prefs["owliverDefault"])
}
updated := a.do("PATCH", "/api/v1/me", map[string]any{"full_name": "Alex R."}).record(t)
if updated["full_name"] != "Alex R." {
t.Errorf("full_name = %v", updated["full_name"])
}
// Preferences shallow-merge, and unknown keys land in the extra blob —
// which is where customSkills and customAgents live.
merged := a.do("PATCH", "/api/v1/me/preferences", map[string]any{
"compactDensity": true,
"customSkills": []any{map[string]any{"id": "s1"}},
}).record(t)
if merged["compactDensity"] != true {
t.Errorf("compactDensity = %v, want true", merged["compactDensity"])
}
if merged["owliverDefault"] != true {
t.Errorf("an untouched preference changed: owliverDefault = %v", merged["owliverDefault"])
}
if merged["customSkills"] == nil {
t.Error("an arbitrary preference key was not preserved")
}
reread := a.do("GET", "/api/v1/me/preferences", nil).record(t)
if reread["compactDensity"] != true || reread["customSkills"] == nil {
t.Errorf("preferences did not survive a re-read: %#v", reread)
}
if r := a.do("PATCH", "/api/v1/me/preferences", map[string]any{"emailDigest": "yes"}); r.code != http.StatusUnprocessableEntity {
t.Errorf("non-boolean preference = %d, want 422", r.code)
}
}
/* ── Organization scoping ───────────────────────────────────────────────── */
// Reads are scoped: a record belonging to another organization is invisible,
// and is a 404 by id rather than a leak.
func TestOrganizationScoping(t *testing.T) {
a := newAPI(t)
ctx := t.Context()
var otherOrg string
if err := a.h.Pool.QueryRow(ctx,
`INSERT INTO organizations (name, slug) VALUES ('Other Co', 'other-co') RETURNING id::text`).
Scan(&otherOrg); err != nil {
t.Fatalf("create second organization: %v", err)
}
var hidden string
if err := a.h.Pool.QueryRow(ctx,
`INSERT INTO job_postings (org_id, title) VALUES ($1::uuid, 'Hidden Role') RETURNING id::text`,
otherOrg).Scan(&hidden); err != nil {
t.Fatalf("create foreign posting: %v", err)
}
for _, r := range a.do("GET", "/api/v1/job-postings?limit=500", nil).records(t) {
if r["id"] == hidden {
t.Fatal("a posting from another organization appeared in the list")
}
}
if r := a.do("GET", "/api/v1/job-postings/"+hidden, nil); r.code != http.StatusNotFound {
t.Errorf("foreign record by id = %d, want 404", r.code)
}
if r := a.do("PATCH", "/api/v1/job-postings/"+hidden, map[string]any{"title": "Stolen"}); r.code != http.StatusNotFound {
t.Errorf("patching a foreign record = %d, want 404", r.code)
}
// It is still there — scoping hid it, it did not delete it.
var still int
if err := a.h.Pool.QueryRow(ctx,
`SELECT count(*) FROM job_postings WHERE id = $1::uuid AND title = 'Hidden Role'`, hidden).
Scan(&still); err != nil {
t.Fatal(err)
}
if still != 1 {
t.Error("the foreign record was modified or removed")
}
}
// Courses with a NULL org_id are the shared platform library and must be
// visible to every organization.
func TestPlatformLibraryIsVisible(t *testing.T) {
a := newAPI(t)
var shared string
if err := a.h.Pool.QueryRow(t.Context(),
`INSERT INTO courses (org_id, title) VALUES (NULL, 'Platform Course') RETURNING id::text`).
Scan(&shared); err != nil {
t.Fatalf("insert shared course: %v", err)
}
found := false
for _, r := range a.do("GET", "/api/v1/courses?limit=500", nil).records(t) {
if r["id"] == shared {
found = true
}
}
if !found {
t.Error("a NULL-org course was not visible to the organization")
}
}
/* ── Representation ─────────────────────────────────────────────────────── */
// Field names and value shapes must match what the frontend has always seen.
func TestRecordRepresentation(t *testing.T) {
a := newAPI(t)
rec := a.do("GET", "/api/v1/job-applications?limit=1", nil).records(t)[0]
if _, ok := rec["id"].(string); !ok {
t.Errorf("id is %T, want a string", rec["id"])
}
created, ok := rec["created_date"].(string)
if !ok || len(created) != 24 || created[len(created)-1] != 'Z' {
t.Errorf("created_date = %v; want ISO-8601 with milliseconds", rec["created_date"])
}
if _, ok := rec["ai_score"].(float64); !ok {
t.Errorf("ai_score is %T, want a number", rec["ai_score"])
}
if _, ok := rec["skills"].([]any); !ok {
t.Errorf("skills is %T, want an array", rec["skills"])
}
if _, ok := rec["score_breakdown"].(map[string]any); !ok {
t.Errorf("score_breakdown is %T, want an object", rec["score_breakdown"])
}
if _, ok := rec["client_rating"].(float64); !ok {
t.Errorf("client_rating is %T, want a number", rec["client_rating"])
}
staff := a.do("GET", "/api/v1/staff?limit=1", nil).records(t)[0]
if hire, ok := staff["hire_date"].(string); !ok || len(hire) != 10 {
t.Errorf("hire_date = %v, want YYYY-MM-DD", staff["hire_date"])
}
}
func TestHealthEndpoint(t *testing.T) {
a := newAPI(t)
r := a.do("GET", "/health", nil)
if r.code != http.StatusOK {
t.Fatalf("health = %d, want 200", r.code)
}
if r.body["status"] != "ok" {
t.Errorf("status = %v, want ok", r.body["status"])
}
// The body is exactly one field. /health is unauthenticated, so anything
// added here is added to the public internet.
if len(r.body) != 1 {
t.Errorf("the health body has %d fields (%v), want exactly 1", len(r.body), keysOf(r.body))
}
}
// TestHealthLeaksNoInfrastructure is the assertion that has to survive future
// edits to the handler: whatever else /health says, it must not describe the
// machine it is running on.
//
// It checks the rendered body rather than the struct, because the leak that
// matters is the one a caller can read — a field added to an embedded type, or
// a struct swapped in wholesale, would pass a field-by-field test on
// healthResponse and fail this one.
func TestHealthLeaksNoInfrastructure(t *testing.T) {
a := newAPI(t)
rec := httptest.NewRecorder()
a.handler.ServeHTTP(rec, httptest.NewRequest("GET", "/health", nil))
body := rec.Body.String()
if rec.Code != http.StatusOK {
t.Fatalf("health = %d, want 200", rec.Code)
}
// Field names that would each be a disclosure on their own.
for _, key := range []string{
"version", "postgres", "database", "schema", "table_count",
"migration", "applied_migration", "dirty", "error", "env",
"host", "port", "dsn", "user", "password", "latency",
} {
if strings.Contains(strings.ToLower(body), key) {
t.Errorf("the health response mentions %q:\n%s", key, body)
}
}
// And the values themselves, taken from the live check rather than
// hardcoded, so this keeps working on a different server or database.
health := (&db.DB{Pool: a.h.Pool, Schema: "public"}).Check(context.Background())
if health.Database == "" || health.Version == "" {
t.Fatal("the internal check returned nothing to compare against")
}
for name, secret := range map[string]string{
"database name": health.Database,
"PostgreSQL version": health.Version,
"schema name": health.Schema,
} {
if strings.Contains(body, secret) {
t.Errorf("the health response contains the %s:\n%s", name, body)
}
}
// The internal check still gathers all of it — this change moved the
// audience, it did not remove the diagnostic.
if !health.Reachable || !health.SchemaPresent || health.TableCount == 0 {
t.Error("db.Check no longer reports the database detail it used to")
}
}
// An unreachable database must be reported as unserviceable without saying why:
// the connection error text names the host, port, user and database.
func TestHealthUnavailableSaysNothingAboutWhy(t *testing.T) {
h := testutil.New(t)
srv := newServer(t, h, nil)
// Closing the pool is the fastest honest way to make the database
// unreachable: every Acquire fails immediately, with no network involved.
// The harness drops its database over a separate admin connection, so
// cleanup is unaffected.
h.Pool.Close()
rec := httptest.NewRecorder()
srv.Handler().ServeHTTP(rec, httptest.NewRequest("GET", "/health", nil))
if rec.Code != http.StatusServiceUnavailable {
t.Fatalf("health with a dead database = %d, want 503", rec.Code)
}
var body map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("response is not JSON: %s", rec.Body.String())
}
if body["status"] != "unavailable" {
t.Errorf("status = %v, want unavailable", body["status"])
}
if len(body) != 1 {
t.Errorf("the unhealthy body has %d fields (%v), want exactly 1", len(body), keysOf(body))
}
if strings.Contains(strings.ToLower(rec.Body.String()), "error") {
t.Errorf("the unhealthy response carries the connection error:\n%s", rec.Body.String())
}
}
func keysOf(m map[string]any) []string {
out := make([]string, 0, len(m))
for k := range m {
out = append(out, k)
}
sort.Strings(out)
return out
}
// The build identifier has to be reachable, or "did my deploy land?" has no
// answer. It was reported nowhere: the Dockerfile declared a VERSION arg,
// compose passed it, and it reached no linker flag — so every deployment
// described itself as nothing at all.
//
// Under /api/v1 rather than on /health on purpose: /health is public and
// deliberately withholds its detail from the internet, and a build identifier
// tells an unauthenticated reader exactly which source to go and read.
func TestVersionEndpointReportsTheBuild(t *testing.T) {
a := newAPI(t)
r := a.do("GET", "/api/v1/version", nil)
if r.code != http.StatusOK {
t.Fatalf("code = %d, want 200", r.code)
}
data, _ := r.body["data"].(map[string]any)
if data == nil {
t.Fatalf("no data envelope: %v", r.body)
}
if v, _ := data["version"].(string); v == "" {
t.Errorf("version is empty; an unstamped build should still say \"dev\": %v", data)
}
if e, _ := data["env"].(string); e == "" {
t.Errorf("env is empty: %v", data)
}
if n, _ := data["endpoints"].(float64); n < 1 {
t.Errorf("endpoints = %v, want the served route count", data["endpoints"])
}
}
// It is behind the session like every other /api/v1 route.
func TestVersionEndpointNeedsASession(t *testing.T) {
a := newAPI(t)
r := a.doAnon("GET", "/api/v1/version", nil)
if r.code != http.StatusUnauthorized && r.code != http.StatusForbidden {
t.Errorf("anonymous GET /api/v1/version = %d, want 401/403", r.code)
}
}
// An agent author picks capabilities from the real tool set, not a copy of it
// kept in the frontend. A second list would drift, and the failure is silent:
// the author picks a tool that no longer exists and gets an agent that quietly
// cannot do the thing they picked.
func TestToolsCatalogueIsServed(t *testing.T) {
a := newAPI(t)
r := a.do("GET", "/api/v1/tools", nil)
if r.code != http.StatusOK {
t.Fatalf("code = %d, want 200", r.code)
}
list, _ := r.body["data"].([]any)
if len(list) == 0 {
t.Fatalf("no tools served: %v", r.body)
}
seenWrite := false
for _, raw := range list {
tool, _ := raw.(map[string]any)
name, _ := tool["name"].(string)
desc, _ := tool["description"].(string)
effect, _ := tool["effect"].(string)
if name == "" || desc == "" {
t.Errorf("a tool has no name or description: %v", tool)
}
if effect != "read" && effect != "write" {
t.Errorf("%s has effect %q, want read or write", name, effect)
}
if effect == "write" {
seenWrite = true
// An author must be able to see that this one proposes changes.
if confirm, _ := tool["requiresConfirmation"].(bool); !confirm {
t.Errorf("%s writes but does not report requiring confirmation", name)
}
}
}
if !seenWrite {
t.Error("no write tool in the catalogue; the effect distinction is untested")
}
}
func TestToolsCatalogueNeedsASession(t *testing.T) {
a := newAPI(t)
if r := a.doAnon("GET", "/api/v1/tools", nil); r.code != http.StatusUnauthorized && r.code != http.StatusForbidden {
t.Errorf("anonymous GET /api/v1/tools = %d, want 401/403", r.code)
}
}