package httpserver_test import ( "context" "net/http" "testing" ) // The multi-record endpoints from api-contract.md §12.1. // // The property worth testing here is not that the happy path works — it is that // a failure part-way through leaves NOTHING behind. Every rollback test below // counts rows before and after, because "the request returned an error" and // "the request changed nothing" are different claims and only the second one is // what a transaction is for. // applicationFor creates an application that can be hired. func applicationFor(t *testing.T, r *rbac, posting, name, email string) string { t.Helper() return mustCreate(t, r, r.admin, "/api/v1/job-applications", map[string]any{ "job_posting_id": posting, "applicant_name": name, "email": email, "status": "shortlisted", "ai_score": 77, }) } func countRows(t *testing.T, r *rbac, table string) int { t.Helper() var n int if err := r.h.Pool.QueryRow(context.Background(), `SELECT count(*) FROM `+table+` WHERE org_id = $1::uuid`, r.orgID).Scan(&n); err != nil { t.Fatalf("count %s: %v", table, err) } return n } /* ── Hire ───────────────────────────────────────────────────────────────── */ func TestHireCreatesStaffAndMovesApplication(t *testing.T) { r := newRBAC(t) app := applicationFor(t, r, r.activePosting, "Hire Me", "hire-me@example.test") before := countRows(t, r, "staff") got := r.as(r.admin, "POST", "/api/v1/job-applications/"+app+"/hire", map[string]any{ "role": "Event Server", "profile_tier": "Skilled", }) if got.code != http.StatusCreated { t.Fatalf("hire: got %d, want 201 (%v)", got.code, got.body) } data, _ := got.body["data"].(map[string]any) application, _ := data["application"].(map[string]any) staff, _ := data["staff"].(map[string]any) if application == nil || staff == nil { t.Fatalf("hire response is missing application or staff: %v", got.body) } if application["status"] != "hired" { t.Errorf("application.status = %v, want hired", application["status"]) } if staff["name"] != "Hire Me" { t.Errorf("staff.name = %v, want the applicant's name", staff["name"]) } if staff["email"] != "hire-me@example.test" { t.Errorf("staff.email = %v, want the application's email", staff["email"]) } // The caller's overrides win over the derived defaults. if staff["role"] != "Event Server" { t.Errorf("staff.role = %v, want the supplied role", staff["role"]) } if staff["profile_tier"] != "Skilled" { t.Errorf("staff.profile_tier = %v, want the supplied tier", staff["profile_tier"]) } // ai_score is carried across from the application. It is an `int` column, // so it arrives from pgx as int32 — the case repo.bindValue did not handle // until this endpoint existed to read a record and write it elsewhere. if score, ok := staff["ai_score"].(float64); !ok || int(score) != 77 { t.Errorf("staff.ai_score = %v, want 77 carried from the application", staff["ai_score"]) } if staff["application_id"] != app { t.Errorf("staff.application_id = %v, want %s", staff["application_id"], app) } if after := countRows(t, r, "staff"); after != before+1 { t.Errorf("staff rows: %d -> %d, want exactly one more", before, after) } } // Hiring the same application twice would create a second employment record for // one person, so the second attempt is a conflict rather than a repeat. func TestHireIsNotRepeatable(t *testing.T) { r := newRBAC(t) app := applicationFor(t, r, r.activePosting, "Twice", "twice@example.test") if got := r.as(r.admin, "POST", "/api/v1/job-applications/"+app+"/hire", map[string]any{}); got.code != http.StatusCreated { t.Fatalf("first hire: got %d, want 201 (%v)", got.code, got.body) } before := countRows(t, r, "staff") got := r.as(r.admin, "POST", "/api/v1/job-applications/"+app+"/hire", map[string]any{}) if got.code != http.StatusConflict { t.Fatalf("second hire: got %d, want 409 (%v)", got.code, got.body) } if after := countRows(t, r, "staff"); after != before { t.Errorf("a refused hire still wrote a staff row: %d -> %d", before, after) } } // The whole point of the endpoint: the two writes succeed together or not at // all. A staff insert that violates a constraint must leave the application // untouched, not merely report an error. func TestHireRollsBackTheApplicationWhenStaffFails(t *testing.T) { r := newRBAC(t) app := applicationFor(t, r, r.activePosting, "Rollback", "rollback@example.test") staffBefore := countRows(t, r, "staff") // profile_tier is a native enum; a value outside it fails the staff INSERT // after the application UPDATE has already been issued in this transaction. got := r.as(r.admin, "POST", "/api/v1/job-applications/"+app+"/hire", map[string]any{ "profile_tier": "NotARealTier", }) if got.code == http.StatusCreated { t.Fatalf("an invalid profile_tier was accepted: %v", got.body) } if after := countRows(t, r, "staff"); after != staffBefore { t.Errorf("staff rows changed despite a failed hire: %d -> %d", staffBefore, after) } // The decisive assertion: the application must NOT be hired. reread := r.as(r.admin, "GET", "/api/v1/job-applications?limit=500", nil) if reread.code != http.StatusOK { t.Fatalf("re-read applications: %d", reread.code) } for _, raw := range reread.body["data"].([]any) { rec := raw.(map[string]any) if rec["id"] == app && rec["status"] == "hired" { t.Fatal("the application was left hired after the staff insert failed — " + "the two writes are not in one transaction") } } } // Hiring is an operator action. A talent user must not be able to hire anyone, // including themselves. func TestHireIsRefusedToTalent(t *testing.T) { r := newRBAC(t) app := applicationFor(t, r, r.activePosting, "Self", r.talA.email) before := countRows(t, r, "staff") got := r.as(r.talA, "POST", "/api/v1/job-applications/"+app+"/hire", map[string]any{}) if got.code != http.StatusForbidden { t.Fatalf("talent hire: got %d, want 403 (%v)", got.code, got.body) } if after := countRows(t, r, "staff"); after != before { t.Errorf("a refused hire still wrote a staff row: %d -> %d", before, after) } } func TestHireRejectsUnknownApplication(t *testing.T) { r := newRBAC(t) got := r.as(r.admin, "POST", "/api/v1/job-applications/00000000-0000-0000-0000-000000000000/hire", map[string]any{}) if got.code != http.StatusNotFound { t.Fatalf("hire unknown application: got %d, want 404 (%v)", got.code, got.body) } } // Another organization's application is absent, not forbidden — the same 404 a // nonexistent id gets, so existence does not leak across tenants. func TestHireCannotReachAnotherOrganization(t *testing.T) { r := newRBAC(t) app := applicationFor(t, r, r.activePosting, "Ours", "ours@example.test") got := r.as(r.outsider, "POST", "/api/v1/job-applications/"+app+"/hire", map[string]any{}) if got.code != http.StatusNotFound { t.Fatalf("cross-tenant hire: got %d, want 404 (%v)", got.code, got.body) } } /* ── Assign ─────────────────────────────────────────────────────────────── */ func TestAssignPlacesWorkersAndUpdatesApplications(t *testing.T) { r := newRBAC(t) a1 := applicationFor(t, r, r.activePosting, "Worker One", "w1@example.test") a2 := applicationFor(t, r, r.activePosting, "Worker Two", "w2@example.test") before := countRows(t, r, "assignments") got := r.as(r.admin, "POST", "/api/v1/job-postings/"+r.activePosting+"/assignments", map[string]any{ "workers": []map[string]any{ {"worker_email": "w1@example.test", "worker_name": "Worker One", "starts_at": "2026-09-01T09:00:00Z", "application_id": a1, "match_score": 91}, {"worker_email": "w2@example.test", "worker_name": "Worker Two", "starts_at": "2026-09-01T09:00:00Z", "application_id": a2}, }, }) if got.code != http.StatusCreated { t.Fatalf("assign: got %d, want 201 (%v)", got.code, got.body) } data, _ := got.body["data"].(map[string]any) if count, ok := data["count"].(float64); !ok || int(count) != 2 { t.Errorf("count = %v, want 2", data["count"]) } if after := countRows(t, r, "assignments"); after != before+2 { t.Errorf("assignment rows: %d -> %d, want two more", before, after) } // Both applications must now read as assigned. list := r.as(r.admin, "GET", "/api/v1/job-applications?status=assigned", nil) assigned := map[string]bool{} for _, raw := range list.body["data"].([]any) { assigned[raw.(map[string]any)["id"].(string)] = true } if !assigned[a1] || !assigned[a2] { t.Errorf("applications were not moved to assigned: a1=%v a2=%v", assigned[a1], assigned[a2]) } } // A worker with no application is legitimate — that is what the talent pool is // for — and must not be invented one. func TestAssignAcceptsWorkerWithoutApplication(t *testing.T) { r := newRBAC(t) got := r.as(r.admin, "POST", "/api/v1/job-postings/"+r.activePosting+"/assignments", map[string]any{ "workers": []map[string]any{ {"worker_email": "pool@example.test", "worker_name": "Pool Worker", "starts_at": "2026-09-01T09:00:00Z"}, }, }) if got.code != http.StatusCreated { t.Fatalf("assign without application: got %d, want 201 (%v)", got.code, got.body) } } // The batch is all-or-nothing. A bad reference on the SECOND worker must undo // the first worker's assignment, not leave it stranded. func TestAssignRollsBackTheWholeBatch(t *testing.T) { r := newRBAC(t) before := countRows(t, r, "assignments") got := r.as(r.admin, "POST", "/api/v1/job-postings/"+r.activePosting+"/assignments", map[string]any{ "workers": []map[string]any{ {"worker_email": "first@example.test", "worker_name": "First", "starts_at": "2026-09-01T09:00:00Z"}, {"worker_email": "second@example.test", "worker_name": "Second", "starts_at": "2026-09-01T09:00:00Z", "application_id": "00000000-0000-0000-0000-000000000000"}, }, }) if got.code == http.StatusCreated { t.Fatalf("a batch naming a nonexistent application was accepted: %v", got.body) } if after := countRows(t, r, "assignments"); after != before { t.Fatalf("the first worker survived the second's failure: %d -> %d — "+ "the batch is not one transaction", before, after) } } func TestAssignIsRefusedToTalent(t *testing.T) { r := newRBAC(t) before := countRows(t, r, "assignments") got := r.as(r.talA, "POST", "/api/v1/job-postings/"+r.activePosting+"/assignments", map[string]any{ "workers": []map[string]any{ {"worker_email": r.talA.email, "worker_name": "Self", "starts_at": "2026-09-01T09:00:00Z"}, }, }) if got.code != http.StatusForbidden { t.Fatalf("talent assign: got %d, want 403 (%v)", got.code, got.body) } if after := countRows(t, r, "assignments"); after != before { t.Errorf("a refused assign still wrote a row: %d -> %d", before, after) } } func TestAssignValidatesTheBatchBeforeWriting(t *testing.T) { r := newRBAC(t) before := countRows(t, r, "assignments") cases := []struct { name string body map[string]any }{ {"no workers", map[string]any{"workers": []map[string]any{}}}, {"missing email", map[string]any{"workers": []map[string]any{ {"worker_name": "No Email", "starts_at": "2026-09-01T09:00:00Z"}}}}, {"missing starts_at", map[string]any{"workers": []map[string]any{ {"worker_email": "x@example.test", "worker_name": "No Start"}}}}, {"malformed application_id", map[string]any{"workers": []map[string]any{ {"worker_email": "x@example.test", "starts_at": "2026-09-01T09:00:00Z", "application_id": "not-a-uuid"}}}}, } for _, tc := range cases { got := r.as(r.admin, "POST", "/api/v1/job-postings/"+r.activePosting+"/assignments", tc.body) if got.code != http.StatusUnprocessableEntity { t.Errorf("%s: got %d, want 422 (%v)", tc.name, got.code, got.body) } } if after := countRows(t, r, "assignments"); after != before { t.Errorf("a rejected batch wrote rows: %d -> %d", before, after) } } func TestAssignRejectsUnknownPosting(t *testing.T) { r := newRBAC(t) got := r.as(r.admin, "POST", "/api/v1/job-postings/00000000-0000-0000-0000-000000000000/assignments", map[string]any{ "workers": []map[string]any{ {"worker_email": "x@example.test", "starts_at": "2026-09-01T09:00:00Z"}, }, }) if got.code != http.StatusNotFound { t.Fatalf("assign to unknown posting: got %d, want 404 (%v)", got.code, got.body) } } // Both endpoints are behind the session like everything else. func TestWorkflowEndpointsRequireASession(t *testing.T) { r := newRBAC(t) for _, path := range []string{ "/api/v1/job-applications/00000000-0000-0000-0000-000000000000/hire", "/api/v1/job-postings/00000000-0000-0000-0000-000000000000/assignments", } { if got := r.doAnon("POST", path, map[string]any{}); got.code != http.StatusUnauthorized { t.Errorf("%s unauthenticated: got %d, want 401", path, got.code) } } }