1115 lines
39 KiB
Go
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
|
|
}
|