create employee table
This commit is contained in:
195
go-api/internal/httpserver/employee_roles_test.go
Normal file
195
go-api/internal/httpserver/employee_roles_test.go
Normal file
@@ -0,0 +1,195 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user