655 lines
26 KiB
Go
655 lines
26 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|
|
|
|
/* ── Activity vocabulary ────────────────────────────────────────────────── */
|
|
|
|
// activityTypes returns the event types written about one worker, newest first.
|
|
//
|
|
// Read straight from the table rather than through GET /user-activity so the
|
|
// assertion is about what was STORED. The frontend's anomaly detection reads
|
|
// these strings — PRIVILEGED_EVENTS is ['hire_candidate', 'create_position'] —
|
|
// and a value the vocabulary does not contain is not a different label, it is
|
|
// an event that silently stops counting.
|
|
func activityTypes(t *testing.T, r *rbac, workerEmail string) []string {
|
|
t.Helper()
|
|
rows, err := r.h.Pool.Query(context.Background(),
|
|
`SELECT event_type FROM user_activity
|
|
WHERE org_id = $1::uuid AND worker_email = $2::citext
|
|
ORDER BY created_date DESC, id DESC`, r.orgID, workerEmail)
|
|
if err != nil {
|
|
t.Fatalf("read user_activity: %v", err)
|
|
}
|
|
defer rows.Close()
|
|
var out []string
|
|
for rows.Next() {
|
|
var s string
|
|
if err := rows.Scan(&s); err != nil {
|
|
t.Fatalf("scan user_activity: %v", err)
|
|
}
|
|
out = append(out, s)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
t.Fatalf("read user_activity: %v", err)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func TestHireWritesTheFrontendsActivityEvent(t *testing.T) {
|
|
r := newRBAC(t)
|
|
app := applicationFor(t, r, r.activePosting, "Evented", "evented@example.test")
|
|
|
|
if got := r.as(r.admin, "POST", "/api/v1/job-applications/"+app+"/hire",
|
|
map[string]any{}); got.code != http.StatusCreated {
|
|
t.Fatalf("hire: got %d, want 201 (%v)", got.code, got.body)
|
|
}
|
|
|
|
events := activityTypes(t, r, "evented@example.test")
|
|
if len(events) != 1 || events[0] != "hire_candidate" {
|
|
t.Errorf("activity = %v, want exactly [hire_candidate] — the vocabulary "+
|
|
"activitySignals.js reads, and the one the seed fixture uses", events)
|
|
}
|
|
}
|
|
|
|
func TestAssignWritesTheFrontendsActivityEvent(t *testing.T) {
|
|
r := newRBAC(t)
|
|
if got := r.as(r.admin, "POST", "/api/v1/job-postings/"+r.activePosting+"/assignments",
|
|
map[string]any{"workers": []map[string]any{
|
|
{"worker_email": "evented-assign@example.test", "worker_name": "Evented",
|
|
"starts_at": "2026-09-01T09:00:00Z"},
|
|
}}); got.code != http.StatusCreated {
|
|
t.Fatalf("assign: got %d, want 201 (%v)", got.code, got.body)
|
|
}
|
|
|
|
events := activityTypes(t, r, "evented-assign@example.test")
|
|
if len(events) != 1 || events[0] != "assign_employee" {
|
|
t.Errorf("activity = %v, want exactly [assign_employee]", events)
|
|
}
|
|
}
|
|
|
|
/* ── Assign: source ─────────────────────────────────────────────────────── */
|
|
|
|
// An unspecified source must mean what the column says it means. The default in
|
|
// 000001 is `owliver` and the frontend sends `owliver`; substituting `manual`
|
|
// made a row written through this endpoint disagree with a row written through
|
|
// POST /assignments about where the same action came from.
|
|
func TestAssignDefaultsSourceToTheColumnDefault(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": "default-source@example.test", "starts_at": "2026-09-01T09:00:00Z"},
|
|
{"worker_email": "explicit-source@example.test", "starts_at": "2026-09-01T09:00:00Z",
|
|
"source": "manual"},
|
|
}})
|
|
if got.code != http.StatusCreated {
|
|
t.Fatalf("assign: got %d, want 201 (%v)", got.code, got.body)
|
|
}
|
|
|
|
created := assignmentsOf(t, got)
|
|
if created[0]["source"] != "owliver" {
|
|
t.Errorf("source = %v, want owliver", created[0]["source"])
|
|
}
|
|
// An explicit value is still the caller's.
|
|
if created[1]["source"] != "manual" {
|
|
t.Errorf("source = %v, want the supplied manual", created[1]["source"])
|
|
}
|
|
}
|
|
|
|
// assignmentsOf reads the assignment records out of an assign response.
|
|
func assignmentsOf(t *testing.T, got response) []map[string]any {
|
|
t.Helper()
|
|
data, _ := got.body["data"].(map[string]any)
|
|
raw, _ := data["assignments"].([]any)
|
|
if raw == nil {
|
|
t.Fatalf("response carries no assignments: %v", got.body)
|
|
}
|
|
out := make([]map[string]any, 0, len(raw))
|
|
for _, rec := range raw {
|
|
out = append(out, rec.(map[string]any))
|
|
}
|
|
return out
|
|
}
|
|
|
|
// applicationByID reads one application as an operator, or fails.
|
|
func applicationByID(t *testing.T, r *rbac, id string) map[string]any {
|
|
t.Helper()
|
|
list := r.as(r.admin, "GET", "/api/v1/job-applications?limit=500", nil)
|
|
if list.code != http.StatusOK {
|
|
t.Fatalf("list applications: %d (%v)", list.code, list.body)
|
|
}
|
|
for _, raw := range list.body["data"].([]any) {
|
|
rec := raw.(map[string]any)
|
|
if rec["id"] == id {
|
|
return rec
|
|
}
|
|
}
|
|
t.Fatalf("application %s not found", id)
|
|
return nil
|
|
}
|
|
|
|
/* ── Assign: the application a worker does not have yet ─────────────────── */
|
|
|
|
// The behaviour the frontend had and the endpoint did not.
|
|
//
|
|
// An application is what puts a person in the pipeline for a role: the
|
|
// candidate record is addressed by it and an interview takes one as its
|
|
// subject. A worker assigned from the talent pool has none, so the endpoint has
|
|
// to file one — in the same transaction as the assignment, which is the half
|
|
// the frontend could not do.
|
|
func TestAssignCreatesTheApplicationItNeeds(t *testing.T) {
|
|
r := newRBAC(t)
|
|
appsBefore := countRows(t, r, "job_applications")
|
|
|
|
got := r.as(r.admin, "POST", "/api/v1/job-postings/"+r.activePosting+"/assignments",
|
|
map[string]any{"workers": []map[string]any{{
|
|
"worker_email": "from-pool@example.test",
|
|
"worker_name": "Pool Worker",
|
|
"starts_at": "2026-09-01T09:00:00Z",
|
|
"match_score": 88,
|
|
"application": map[string]any{
|
|
"job_title": "Open Role",
|
|
"phone": "555-0100",
|
|
"years_experience": 4,
|
|
"skills": []string{"service", "bar"},
|
|
"professional_summary": "Placed from the talent pool.",
|
|
"ai_score": 88,
|
|
},
|
|
}}})
|
|
if got.code != http.StatusCreated {
|
|
t.Fatalf("assign: got %d, want 201 (%v)", got.code, got.body)
|
|
}
|
|
|
|
if after := countRows(t, r, "job_applications"); after != appsBefore+1 {
|
|
t.Fatalf("job_applications: %d -> %d, want exactly one more", appsBefore, after)
|
|
}
|
|
|
|
assignment := assignmentsOf(t, got)[0]
|
|
linked, _ := assignment["application_id"].(string)
|
|
if linked == "" {
|
|
t.Fatal("the assignment was not linked to the application that was created for it")
|
|
}
|
|
|
|
app := applicationByID(t, r, linked)
|
|
if app["status"] != "assigned" {
|
|
t.Errorf("application.status = %v, want assigned", app["status"])
|
|
}
|
|
if app["email"] != "from-pool@example.test" {
|
|
t.Errorf("application.email = %v, want the worker's email", app["email"])
|
|
}
|
|
if app["job_posting_id"] != r.activePosting {
|
|
t.Errorf("application.job_posting_id = %v, want the posting being assigned to", app["job_posting_id"])
|
|
}
|
|
// applicant_name falls back to the worker's name rather than being blank —
|
|
// the column has a not-blank check.
|
|
if app["applicant_name"] != "Pool Worker" {
|
|
t.Errorf("application.applicant_name = %v, want the worker's name", app["applicant_name"])
|
|
}
|
|
if app["phone"] != "555-0100" {
|
|
t.Errorf("application.phone = %v, want the supplied phone", app["phone"])
|
|
}
|
|
if score, ok := app["ai_score"].(float64); !ok || int(score) != 88 {
|
|
t.Errorf("application.ai_score = %v, want 88", app["ai_score"])
|
|
}
|
|
|
|
// The audit entry names the application, so the feed can open it.
|
|
var activityApp *string
|
|
if err := r.h.Pool.QueryRow(context.Background(),
|
|
`SELECT application_id::text FROM user_activity
|
|
WHERE org_id = $1::uuid AND worker_email = $2::citext`,
|
|
r.orgID, "from-pool@example.test").Scan(&activityApp); err != nil {
|
|
t.Fatalf("read the activity entry: %v", err)
|
|
}
|
|
if activityApp == nil || *activityApp != linked {
|
|
t.Errorf("activity.application_id = %v, want %s", activityApp, linked)
|
|
}
|
|
}
|
|
|
|
// (job_posting_id, email) is UNIQUE, so the second assign of the same person to
|
|
// the same posting must find the application rather than try to file another —
|
|
// and the comparison is case-insensitive, because the column is citext and the
|
|
// frontend's own lookup lowercased both sides.
|
|
func TestAssignLinksAnExistingApplicationInsteadOfDuplicating(t *testing.T) {
|
|
r := newRBAC(t)
|
|
existing := applicationFor(t, r, r.activePosting, "Already Applied", "Already.Applied@example.test")
|
|
|
|
appsBefore := countRows(t, r, "job_applications")
|
|
got := r.as(r.admin, "POST", "/api/v1/job-postings/"+r.activePosting+"/assignments",
|
|
map[string]any{"workers": []map[string]any{{
|
|
"worker_email": "already.applied@example.test",
|
|
"worker_name": "Already Applied",
|
|
"starts_at": "2026-09-01T09:00:00Z",
|
|
"application": map[string]any{"job_title": "Open Role"},
|
|
}}})
|
|
if got.code != http.StatusCreated {
|
|
t.Fatalf("assign: got %d, want 201 (%v)", got.code, got.body)
|
|
}
|
|
|
|
if after := countRows(t, r, "job_applications"); after != appsBefore {
|
|
t.Errorf("job_applications: %d -> %d, want no new row for a person who already applied",
|
|
appsBefore, after)
|
|
}
|
|
if linked := assignmentsOf(t, got)[0]["application_id"]; linked != existing {
|
|
t.Errorf("assignment.application_id = %v, want the existing application %s", linked, existing)
|
|
}
|
|
if app := applicationByID(t, r, existing); app["status"] != "assigned" {
|
|
t.Errorf("application.status = %v, want assigned", app["status"])
|
|
}
|
|
}
|
|
|
|
// An id the caller already has still wins over the payload: it is a decision
|
|
// they have made, and honouring the payload instead could file a second
|
|
// application for the same placement.
|
|
func TestAssignPrefersTheSuppliedApplicationID(t *testing.T) {
|
|
r := newRBAC(t)
|
|
app := applicationFor(t, r, r.activePosting, "Named", "named@example.test")
|
|
appsBefore := countRows(t, r, "job_applications")
|
|
|
|
got := r.as(r.admin, "POST", "/api/v1/job-postings/"+r.activePosting+"/assignments",
|
|
map[string]any{"workers": []map[string]any{{
|
|
"worker_email": "named@example.test",
|
|
"worker_name": "Named",
|
|
"starts_at": "2026-09-01T09:00:00Z",
|
|
"application_id": app,
|
|
"application": map[string]any{"applicant_name": "Ignored"},
|
|
}}})
|
|
if got.code != http.StatusCreated {
|
|
t.Fatalf("assign: got %d, want 201 (%v)", got.code, got.body)
|
|
}
|
|
if after := countRows(t, r, "job_applications"); after != appsBefore {
|
|
t.Errorf("job_applications: %d -> %d, want no new row", appsBefore, after)
|
|
}
|
|
if linked := assignmentsOf(t, got)[0]["application_id"]; linked != app {
|
|
t.Errorf("assignment.application_id = %v, want %s", linked, app)
|
|
}
|
|
if stored := applicationByID(t, r, app); stored["applicant_name"] != "Named" {
|
|
t.Errorf("applicant_name = %v — the payload overwrote a named application",
|
|
stored["applicant_name"])
|
|
}
|
|
}
|
|
|
|
// No payload, no application. A worker placed straight from the workforce is
|
|
// legitimate, and one must not be invented for them.
|
|
func TestAssignWithoutAnApplicationPayloadLinksNothing(t *testing.T) {
|
|
r := newRBAC(t)
|
|
appsBefore := countRows(t, r, "job_applications")
|
|
|
|
got := r.as(r.admin, "POST", "/api/v1/job-postings/"+r.activePosting+"/assignments",
|
|
map[string]any{"workers": []map[string]any{
|
|
{"worker_email": "unattached@example.test", "worker_name": "Unattached",
|
|
"starts_at": "2026-09-01T09:00:00Z"},
|
|
}})
|
|
if got.code != http.StatusCreated {
|
|
t.Fatalf("assign: got %d, want 201 (%v)", got.code, got.body)
|
|
}
|
|
if after := countRows(t, r, "job_applications"); after != appsBefore {
|
|
t.Errorf("job_applications: %d -> %d, want no application invented", appsBefore, after)
|
|
}
|
|
if linked := assignmentsOf(t, got)[0]["application_id"]; linked != nil {
|
|
t.Errorf("assignment.application_id = %v, want null", linked)
|
|
}
|
|
}
|
|
|
|
// The application payload is validated exactly as POST /job-applications would
|
|
// validate it, and the batch element that produced the complaint is named.
|
|
func TestAssignValidatesTheApplicationPayload(t *testing.T) {
|
|
r := newRBAC(t)
|
|
assignBefore := countRows(t, r, "assignments")
|
|
appsBefore := countRows(t, r, "job_applications")
|
|
|
|
got := r.as(r.admin, "POST", "/api/v1/job-postings/"+r.activePosting+"/assignments",
|
|
map[string]any{"workers": []map[string]any{
|
|
{"worker_email": "good@example.test", "worker_name": "Good",
|
|
"starts_at": "2026-09-01T09:00:00Z",
|
|
"application": map[string]any{"job_title": "Open Role"}},
|
|
{"worker_email": "bad@example.test", "worker_name": "Bad",
|
|
"starts_at": "2026-09-01T09:00:00Z",
|
|
"application": map[string]any{"english_level": "telepathic"}},
|
|
}})
|
|
if got.code != http.StatusUnprocessableEntity {
|
|
t.Fatalf("assign with an invalid application: got %d, want 422 (%v)", got.code, got.body)
|
|
}
|
|
details, _ := got.body["error"].(map[string]any)["details"].(map[string]any)
|
|
if details["workers[1].english_level"] == nil {
|
|
t.Errorf("details = %v, want the failure attributed to workers[1]", details)
|
|
}
|
|
|
|
// And the first worker — whose application WAS filed before the second
|
|
// failed — must be gone with it.
|
|
if after := countRows(t, r, "job_applications"); after != appsBefore {
|
|
t.Errorf("job_applications: %d -> %d — a rejected batch left an application behind",
|
|
appsBefore, after)
|
|
}
|
|
if after := countRows(t, r, "assignments"); after != assignBefore {
|
|
t.Errorf("assignments: %d -> %d — a rejected batch left an assignment behind",
|
|
assignBefore, after)
|
|
}
|
|
}
|