create employee table
Some checks failed
CI / test (push) Failing after 4m38s
CI / fixture (push) Failing after 9s

This commit is contained in:
2026-09-05 10:44:47 +05:30
parent dc785b917c
commit cf99866e12
11 changed files with 820 additions and 26 deletions

View File

@@ -66,13 +66,26 @@ func setStatus(t *testing.T, pool *pgxpool.Pool, userID, status string) {
}
}
// seededUser is the demo user the fixture loads into the test database.
// seededUser is the demo ADMINISTRATOR the fixture loads into the test
// database.
//
// The role is now part of the question. The fixture used to hold one account,
// so "the seeded user" and "the administrator" were the same row and ordering
// by date was enough to find it. It holds two since the Employer console gained
// somebody to sign in as, both created on the same seeded date, which left the
// tiebreak to a deterministic UUID — and picked the employer. Tests that assert
// an administrator's access were then asserting an employer's, and failed
// exactly as they should have.
//
// So it asks for what it means. Ordering is kept beneath the filter for the
// case of several administrators.
func seededUser(t *testing.T, pool *pgxpool.Pool) (id, email string) {
t.Helper()
if err := pool.QueryRow(context.Background(),
`SELECT id::text, email::text FROM users ORDER BY created_date, id LIMIT 1`).
`SELECT id::text, email::text FROM users WHERE role = 'admin'
ORDER BY created_date, id LIMIT 1`).
Scan(&id, &email); err != nil {
t.Fatalf("read the seeded user: %v", err)
t.Fatalf("read the seeded administrator: %v", err)
}
return id, email
}

View 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)
}
}

View File

@@ -0,0 +1,89 @@
package httpserver_test
import (
"net/http"
"testing"
)
// Who counts as the same person.
//
// The rule is the schema's and it is worth stating plainly, because the whole
// duplicate question turns on it: `worker_profiles` carries
// UNIQUE (org_id, email) and `email` is `citext`. So identity is the pair
// (organization, email), compared case-insensitively, and `full_name` carries
// NO uniqueness at all — an organization may employ any number of people with
// the same name, and they are different people.
//
// These are database guarantees rather than application checks, which is what
// makes them hold under concurrency: two simultaneous creates of the same
// identity cannot both win, whatever the callers checked first.
func createWorker(t *testing.T, r *rbac, act actor, name, email string) response {
t.Helper()
return r.as(act, "POST", "/api/v1/worker-profiles", map[string]any{
"full_name": name, "email": email,
})
}
// A name is not an identity. Two people who share one are two records.
func TestWorkersMayShareAName(t *testing.T) {
r := newRBAC(t)
const shared = "Shared Name"
first := createWorker(t, r, r.admin, shared, "shared-name-1@example.test")
second := createWorker(t, r, r.admin, shared, "shared-name-2@example.test")
for i, got := range []response{first, second} {
if got.code != http.StatusCreated {
t.Fatalf("create %d: %d (%v) — sharing a name must not block creation", i+1, got.code, got.body)
}
}
a := first.body["data"].(map[string]any)
b := second.body["data"].(map[string]any)
if a["id"] == b["id"] {
t.Fatal("two people sharing a name collapsed into one record")
}
if a["email"] == b["email"] {
t.Error("the second worker took the first one's email")
}
}
// The same identity cannot be created twice, whoever it claims to be, and the
// refusal is a conflict a caller can act on rather than a 500.
func TestTheSameIdentityCannotBeCreatedTwice(t *testing.T) {
r := newRBAC(t)
const email = "one-identity@example.test"
if got := createWorker(t, r, r.admin, "Person One", email); got.code != http.StatusCreated {
t.Fatalf("first create: %d (%v)", got.code, got.body)
}
for _, tc := range []struct{ name, who, email string }{
{"a different name on the same email", "Person Two", email},
{"the same email in another case", "Person Three", "ONE-IDENTITY@EXAMPLE.TEST"},
} {
t.Run(tc.name, func(t *testing.T) {
got := createWorker(t, r, r.admin, tc.who, tc.email)
if got.code != http.StatusConflict {
t.Errorf("= %d, want 409 — the identity is already taken", got.code)
}
if got.errCode(t) != "conflict" {
t.Errorf("error code = %q, want conflict", got.errCode(t))
}
})
}
}
// The identity is scoped to the organization, so the same email in another
// tenant is another person and is allowed.
func TestTheSameEmailInAnotherOrganizationIsAnotherPerson(t *testing.T) {
r := newRBAC(t)
const email = "cross-tenant-identity@example.test"
if got := createWorker(t, r, r.admin, "Inside", email); got.code != http.StatusCreated {
t.Fatalf("create inside: %d (%v)", got.code, got.body)
}
if got := createWorker(t, r, r.outsider, "Outside", email); got.code != http.StatusCreated {
t.Errorf("create in another organization = %d, want 201 — identity is (org, email)", got.code)
}
}

View File

@@ -0,0 +1,175 @@
package httpserver_test
import (
"net/http"
"testing"
)
// Recording a NEW person and their first declared role, atomically.
//
// The flow this endpoint exists for is a CREATION: HR is adding somebody the
// organization does not have yet. So the properties under test are about
// creation, not lookup — no worker id is accepted, no name is searched, and the
// email the caller states is the identity the row is keyed on.
func createWorkerWithRole(t *testing.T, r *rbac, act actor, body map[string]any) response {
t.Helper()
return r.as(act, "POST", "/api/v1/worker-profiles/with-role", body)
}
// The happy path, and the two records it must leave behind.
func TestCreateWorkerWithRoleCreatesBoth(t *testing.T) {
r := newRBAC(t)
const email = "new-person@example.test"
got := createWorkerWithRole(t, r, r.empA, map[string]any{
"full_name": "New Person", "email": email,
"role": map[string]any{
"role_category": "Bartender", "experience_years": 3,
"english_level": "fluent", "certifications": []string{"A Certification"},
"desired_pay_min": 30, "desired_pay_max": 40,
"availability": []string{"Weekdays"}, "notes": "recorded by the panel",
},
})
if got.code != http.StatusCreated {
t.Fatalf("= %d, want 201 (%v)", got.code, got.body)
}
data := got.body["data"].(map[string]any)
worker := data["worker"].(map[string]any)
role := data["role"].(map[string]any)
if worker["id"] == nil || worker["id"] == "" {
t.Fatal("no worker id came back")
}
// The whole point: the role points at the worker this call created.
if role["worker_profile_id"] != worker["id"] {
t.Errorf("role.worker_profile_id = %v, want the new worker %v", role["worker_profile_id"], worker["id"])
}
if role["worker_email"] != worker["email"] {
t.Errorf("role.worker_email = %v, want %v", role["worker_email"], worker["email"])
}
// The operator is the author, never the subject.
if role["created_by"] != r.empA.id {
t.Errorf("created_by = %v, want the operator %v", role["created_by"], r.empA.id)
}
if worker["email"] == r.empA.email {
t.Fatal("the operator became the worker")
}
// The role's own fields survived, and did not land on the worker.
if role["role_category"] != "Bartender" {
t.Errorf("role_category = %v", role["role_category"])
}
if _, leaked := worker["role_category"]; leaked {
t.Error("a role field landed on the worker record")
}
// Both are readable afterwards, under the caller's own org predicate.
if !r.ids(t, r.empA, "/api/v1/worker-profiles")[worker["id"].(string)] {
t.Error("the new worker is missing from the worker listing")
}
if !r.ids(t, r.empA, "/api/v1/employee-roles")[role["id"].(string)] {
t.Error("the new role is missing from the role listing")
}
}
// A name is not an identity: same name, different emails, two people.
func TestCreateWorkerWithRoleAllowsARepeatedName(t *testing.T) {
r := newRBAC(t)
const name = "Repeated Name"
first := createWorkerWithRole(t, r, r.admin, map[string]any{
"full_name": name, "email": "repeat-1@example.test",
"role": map[string]any{"role_category": "Server"},
})
second := createWorkerWithRole(t, r, r.admin, map[string]any{
"full_name": name, "email": "repeat-2@example.test",
"role": map[string]any{"role_category": "Chef"},
})
for i, got := range []response{first, second} {
if got.code != http.StatusCreated {
t.Fatalf("create %d = %d (%v) — a shared name must not block creation", i+1, got.code, got.body)
}
}
a := first.body["data"].(map[string]any)["worker"].(map[string]any)
b := second.body["data"].(map[string]any)["worker"].(map[string]any)
if a["id"] == b["id"] {
t.Fatal("two people sharing a name collapsed into one record")
}
}
// The identity is the email, and the database decides. A repeat is refused and
// leaves NOTHING behind — no worker, no role.
func TestCreateWorkerWithRoleRollsBackOnDuplicateIdentity(t *testing.T) {
r := newRBAC(t)
const email = "taken-identity@example.test"
if got := createWorkerWithRole(t, r, r.admin, map[string]any{
"full_name": "First Person", "email": email,
"role": map[string]any{"role_category": "Server"},
}); got.code != http.StatusCreated {
t.Fatalf("first create: %d (%v)", got.code, got.body)
}
before := len(r.ids(t, r.admin, "/api/v1/employee-roles"))
got := createWorkerWithRole(t, r, r.admin, map[string]any{
"full_name": "Second Person", "email": email,
"role": map[string]any{"role_category": "Chef"},
})
if got.code != http.StatusConflict {
t.Fatalf("duplicate identity = %d, want 409 (%v)", got.code, got.body)
}
if after := len(r.ids(t, r.admin, "/api/v1/employee-roles")); after != before {
t.Errorf("%d roles after a refused create, want %d — the transaction did not roll back", after, before)
}
}
// A role cannot be recorded for nobody, and an email is never invented for a
// name that arrived without one.
func TestCreateWorkerWithRoleRequiresBothNameAndEmail(t *testing.T) {
r := newRBAC(t)
for _, tc := range []struct {
name string
body map[string]any
}{
{"no email", map[string]any{"full_name": "Nameless Email", "role": map[string]any{"role_category": "Server"}}},
{"blank email", map[string]any{"full_name": "Blank", "email": " ", "role": map[string]any{"role_category": "Server"}}},
{"no name", map[string]any{"email": "no-name@example.test", "role": map[string]any{"role_category": "Server"}}},
{"neither", map[string]any{"role": map[string]any{"role_category": "Server"}}},
} {
t.Run(tc.name, func(t *testing.T) {
got := createWorkerWithRole(t, r, r.admin, tc.body)
if got.code == http.StatusCreated {
t.Fatalf("accepted a worker with %s: %v", tc.name, got.body)
}
})
}
}
// Talent cannot record workers, and another organization cannot see the ones
// this one records.
func TestCreateWorkerWithRoleIsScopedAndAuthorized(t *testing.T) {
r := newRBAC(t)
if got := createWorkerWithRole(t, r, r.talA, map[string]any{
"full_name": "Not Allowed", "email": "not-allowed@example.test",
"role": map[string]any{"role_category": "Server"},
}); got.code != http.StatusForbidden {
t.Errorf("talent create = %d, want 403", got.code)
}
made := createWorkerWithRole(t, r, r.admin, map[string]any{
"full_name": "Inside Only", "email": "inside-only@example.test",
"role": map[string]any{"role_category": "Server"},
})
if made.code != http.StatusCreated {
t.Fatalf("create: %d (%v)", made.code, made.body)
}
roleID := made.body["data"].(map[string]any)["role"].(map[string]any)["id"].(string)
if r.ids(t, r.outsider, "/api/v1/employee-roles")[roleID] {
t.Error("a role leaked into another organization")
}
}

View File

@@ -86,7 +86,50 @@ func errUnregisteredResource(path string) error { return unregisteredResourceErr
func (s *Server) routeWorkflows(mux *http.ServeMux) int {
mux.HandleFunc("POST /api/v1/job-applications/{id}/hire", s.handleHire)
mux.HandleFunc("POST /api/v1/job-postings/{id}/assignments", s.handleAssign)
return 2
mux.HandleFunc("POST /api/v1/worker-profiles/with-role", s.handleCreateWorkerWithRole)
return 3
}
// handleCreateWorkerWithRole records a NEW person and their first declared role
// in one transaction.
//
// Under `worker-profiles` rather than `employee-roles` because the worker is
// what the request creates; the role comes with it. A more specific literal
// than the generated `POST /api/v1/worker-profiles`, so the mux prefers it and
// neither route shadows the other.
//
// This is a CREATION flow. It takes a name and an email and never a worker id,
// and nothing in it searches for an existing person — an organization may
// employ many people who share a name, so a name cannot select anybody.
// Recording a second role for someone who already exists is
// POST /api/v1/employee-roles, unchanged.
func (s *Server) handleCreateWorkerWithRole(w http.ResponseWriter, r *http.Request) {
ident, ok := s.authorizeAll(w, r,
requirement{"worker-profiles", domain.OpCreate},
requirement{"employee-roles", domain.OpCreate},
)
if !ok {
return
}
body, err := decodeBody(r)
if err != nil {
writeError(w, s.log, err)
return
}
result, err := s.workflows.CreateWorkerWithRole(r.Context(), ident, body)
if err != nil {
writeError(w, s.log, err)
return
}
/* The worker's identity is not logged: an email is the person, and §10 puts
record content at DEBUG behind a per-tenant flag rather than at INFO. */
s.log.Info("worker recorded with a declared role", "user_id", ident.UserID,
"worker_profile_id", result.Worker["id"], "employee_role_id", result.Role["id"])
writeJSON(w, http.StatusCreated, envelope{Data: result})
}
// handleHire moves an application to `hired` and creates the staff record in