Files
krow_backend/go-api/internal/httpserver/api_test.go
2026-08-24 13:06:29 +05:30

1115 lines
39 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",
} {
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)
}
}
// Assignments are empty by design in the source dataset. An empty collection is
// 200 with an empty array, never a 404. api-contract.md §8.
func TestEmptyCollectionIs200(t *testing.T) {
a := newAPI(t)
r := a.do("GET", "/api/v1/assignments", 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},
} {
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)
recs := a.do("GET", "/api/v1/job-applications?status=hired&status=interview", nil).records(t)
if len(recs) != 8 {
t.Errorf("hired+interview = %d, want 8 (3 hired, 5 interview)", len(recs))
}
for _, r := range recs {
if s := r["status"].(string); s != "hired" && s != "interview" {
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
}