Files
krow_backend/go-api/internal/httpserver/employee_roles_test.go
Aravind cf99866e12
Some checks failed
CI / test (push) Failing after 4m38s
CI / fixture (push) Failing after 9s
create employee table
2026-09-05 10:44:47 +05:30

196 lines
7.0 KiB
Go

package httpserver_test
import (
"net/http"
"testing"
)
// Employee roles: what a worker declares they do.
//
// The properties here are the ones the conversational flow depends on and that
// no amount of frontend testing can establish, because they are decided by a
// SQL predicate and a derived column:
//
// THE OPERATOR IS NOT THE WORKER. An employer records a role for somebody
// else. If the subject were derived from the session — as created_by
// legitimately is — every role would be filed against whoever was signed in.
//
// A WORKER HOLDS MANY ROLES. There is deliberately no uniqueness on the
// worker, so a second declaration is a second row and a role already marked
// `placed` survives the worker declaring the same category again. The panel
// promises exactly this in its review step: "The worker can hold more than one
// role — recording this does not replace an existing one."
func createEmployeeRole(t *testing.T, r *rbac, act actor, body map[string]any) map[string]any {
t.Helper()
got := r.as(act, "POST", "/api/v1/employee-roles", body)
if got.code != http.StatusCreated {
t.Fatalf("%s create employee role: %d (%v)", act.name, got.code, got.body)
}
return got.body["data"].(map[string]any)
}
// The subject comes from the request; only the audit column comes from the
// session. This is the test that fails if anyone ever derives worker_email the
// way job-applications derives it for a talent caller.
func TestEmployeeRoleRecordsTheWorkerNotTheOperator(t *testing.T) {
r := newRBAC(t)
rec := createEmployeeRole(t, r, r.empA, map[string]any{
"worker_email": "someone-else@example.test",
"worker_name": "Someone Else",
"role_category": "Bartender",
})
if rec["worker_email"] == r.empA.email {
t.Fatal("the operator became the worker")
}
if got := rec["worker_email"]; got != "someone-else@example.test" {
t.Errorf("worker_email = %v, want the worker's", got)
}
if got := rec["created_by"]; got != r.empA.id {
t.Errorf("created_by = %v, want the operator %v", got, r.empA.id)
}
}
// created_by is ReadOnly in the descriptor, so a caller cannot attribute a role
// to somebody else. The body's value is dropped, not honoured.
func TestEmployeeRoleCreatedByIsNotClientSettable(t *testing.T) {
r := newRBAC(t)
rec := createEmployeeRole(t, r, r.empA, map[string]any{
"worker_email": "worker@example.test", "role_category": "Server",
"created_by": r.admin.id,
})
if got := rec["created_by"]; got != r.empA.id {
t.Errorf("created_by = %v, want the caller %v — the body must not set it", got, r.empA.id)
}
}
// The promise the review step makes, tested against the database.
func TestAWorkerHoldsManyRolesAndNoneReplaceAnother(t *testing.T) {
r := newRBAC(t)
const worker = "many-roles@example.test"
first := createEmployeeRole(t, r, r.empA, map[string]any{
"worker_email": worker, "worker_name": "Many Roles",
"role_category": "Bartender", "status": "placed",
})
second := createEmployeeRole(t, r, r.empA, map[string]any{
"worker_email": worker, "worker_name": "Many Roles", "role_category": "Server",
})
// The same category again while the first is still placed: a worker who
// finished a Bartender placement and is seeking Bartender work again.
third := createEmployeeRole(t, r, r.empA, map[string]any{
"worker_email": worker, "worker_name": "Many Roles", "role_category": "Bartender",
})
ids := map[string]bool{}
for _, rec := range []map[string]any{first, second, third} {
id := rec["id"].(string)
if ids[id] {
t.Fatalf("duplicate id %s — a role replaced another", id)
}
ids[id] = true
}
got := r.ids(t, r.empA, "/api/v1/employee-roles?worker_email="+worker)
for id := range ids {
if !got[id] {
t.Errorf("role %s is missing — it was overwritten or filtered away", id)
}
}
if len(got) != 3 {
t.Errorf("%d roles for one worker, want 3", len(got))
}
if first["status"] != "placed" {
t.Errorf("the first role's status = %v, want placed to survive", first["status"])
}
}
// Every field the conversation collects survives the round trip. Named from the
// payload the panel actually sends, so a column the flow fills and the API drops
// fails here rather than silently arriving empty.
func TestEmployeeRoleKeepsEveryCollectedField(t *testing.T) {
r := newRBAC(t)
rec := createEmployeeRole(t, r, r.admin, map[string]any{
"worker_email": "full@example.test", "worker_name": "Full Record",
"role_category": "Picker", "experience_years": 3,
"english_level": "native", "certifications": []string{"TIPS Certified"},
"desired_pay_min": 30, "desired_pay_max": 40,
"availability": []string{"Weekdays"}, "notes": "recorded by the panel",
"status": "seeking",
})
for _, tc := range []struct {
field string
want any
}{
{"role_category", "Picker"},
{"experience_years", float64(3)},
{"english_level", "native"},
{"desired_pay_min", float64(30)},
{"desired_pay_max", float64(40)},
{"notes", "recorded by the panel"},
{"status", "seeking"},
} {
if got := rec[tc.field]; got != tc.want {
t.Errorf("%s = %#v, want %#v", tc.field, got, tc.want)
}
}
for _, tc := range []struct {
field string
want string
}{{"certifications", "TIPS Certified"}, {"availability", "Weekdays"}} {
list, _ := rec[tc.field].([]any)
if len(list) != 1 || list[0] != tc.want {
t.Errorf("%s = %#v, want [%q]", tc.field, rec[tc.field], tc.want)
}
}
}
// Cross-tenant isolation stands on its own: an ADMIN in another organization
// gets 404, not 403, and never sees the row in a listing.
func TestEmployeeRolesAreInvisibleAcrossOrganizations(t *testing.T) {
r := newRBAC(t)
rec := createEmployeeRole(t, r, r.admin, map[string]any{
"worker_email": "inside@example.test", "role_category": "Bartender",
})
id := rec["id"].(string)
if got := r.as(r.outsider, "GET", "/api/v1/employee-roles/"+id, nil); got.code != http.StatusNotFound {
t.Errorf("outside admin GET = %d, want 404", got.code)
}
if r.ids(t, r.outsider, "/api/v1/employee-roles")[id] {
t.Error("a role leaked into another organization's listing")
}
if got := r.as(r.outsider, "PATCH", "/api/v1/employee-roles/"+id,
map[string]any{"notes": "n"}); got.code != http.StatusNotFound {
t.Errorf("outside admin PATCH = %d, want 404", got.code)
}
}
// A talent caller reads only their own declared roles, and cannot create.
func TestTalentSeesOnlyItsOwnEmployeeRoles(t *testing.T) {
r := newRBAC(t)
mine := createEmployeeRole(t, r, r.empA, map[string]any{
"worker_email": r.talA.email, "role_category": "Bartender",
})
theirs := createEmployeeRole(t, r, r.empA, map[string]any{
"worker_email": r.talB.email, "role_category": "Server",
})
seen := r.ids(t, r.talA, "/api/v1/employee-roles")
if !seen[mine["id"].(string)] {
t.Error("talent cannot see its own declared role")
}
if seen[theirs["id"].(string)] {
t.Error("talent A can see talent B's declared role")
}
if got := r.as(r.talA, "GET", "/api/v1/employee-roles/"+theirs["id"].(string), nil); got.code != http.StatusNotFound {
t.Errorf("GET another talent's role = %d, want 404 — absent, not refused", got.code)
}
}