240 lines
9.4 KiB
Go
240 lines
9.4 KiB
Go
package httpserver_test
|
|
|
|
import (
|
|
"context"
|
|
"net/http"
|
|
"testing"
|
|
)
|
|
|
|
// Completing an AI interview.
|
|
//
|
|
// The endpoint is unchanged — POST /api/v1/ai-interviews, the one the modal
|
|
// already calls — but finishing an interview is two writes, and the second one
|
|
// is a write the caller who most often makes the request may not perform. The
|
|
// tests below are about that seam: the interview and the link land together,
|
|
// they land for a talent user, and nothing about talent's own permissions has
|
|
// widened to make it possible.
|
|
|
|
// interviewCount counts the organization's interview rows.
|
|
func interviewCount(t *testing.T, r *rbac) int {
|
|
t.Helper()
|
|
return countRows(t, r, "ai_interviews")
|
|
}
|
|
|
|
// talentApplication files an application through the API as the talent user, so
|
|
// its email is whatever the server derived rather than what a test asked for.
|
|
func talentApplication(t *testing.T, r *rbac, who actor) string {
|
|
t.Helper()
|
|
return mustCreate(t, r, who, "/api/v1/job-applications", map[string]any{
|
|
"job_posting_id": r.activePosting,
|
|
"applicant_name": who.name,
|
|
})
|
|
}
|
|
|
|
func interviewBody(applicationID, postingID string, score any) map[string]any {
|
|
body := map[string]any{
|
|
"application_id": applicationID,
|
|
"job_posting_id": postingID,
|
|
"job_title": "Open Role",
|
|
"candidate_name": "Candidate",
|
|
"messages": []map[string]any{
|
|
{"role": "assistant", "content": "Tell me about a difficult shift."},
|
|
{"role": "user", "content": "We were two people short and I re-planned the passes."},
|
|
},
|
|
"verdict": "hire",
|
|
"hire_recommendation": "Hire",
|
|
"summary": "Composed under pressure.",
|
|
}
|
|
if score != nil {
|
|
body["overall_interview_score"] = score
|
|
}
|
|
return body
|
|
}
|
|
|
|
/* ── The RBAC break this fixes ──────────────────────────────────────────── */
|
|
|
|
// A talent user completing their own interview is the whole talent flow, and it
|
|
// could not finish: ai-interviews:Create is open to everyone, job-applications:
|
|
// Update is operators only, so the interview was written and the application
|
|
// never learned about it. Both writes now happen server-side, in one
|
|
// transaction, on the row the interview already names.
|
|
func TestTalentCompletesTheirOwnInterview(t *testing.T) {
|
|
r := newRBAC(t)
|
|
app := talentApplication(t, r, r.talA)
|
|
|
|
got := r.as(r.talA, "POST", "/api/v1/ai-interviews",
|
|
interviewBody(app, r.activePosting, 88))
|
|
if got.code != http.StatusCreated {
|
|
t.Fatalf("talent interview: got %d, want 201 (%v)", got.code, got.body)
|
|
}
|
|
interview := got.body["data"].(map[string]any)
|
|
interviewID, _ := interview["id"].(string)
|
|
if interviewID == "" {
|
|
t.Fatalf("the response carries no interview id: %v", got.body)
|
|
}
|
|
|
|
// The response is still the interview record, unchanged.
|
|
if interview["application_id"] != app {
|
|
t.Errorf("data.application_id = %v, want %s", interview["application_id"], app)
|
|
}
|
|
|
|
stored := applicationByID(t, r, app)
|
|
if stored["status"] != "interview" {
|
|
t.Errorf("application.status = %v, want interview — the analytics count "+
|
|
"status === 'interview' || interview_id", stored["status"])
|
|
}
|
|
if stored["interview_id"] != interviewID {
|
|
t.Errorf("application.interview_id = %v, want %s", stored["interview_id"], interviewID)
|
|
}
|
|
if score, ok := stored["ai_score"].(float64); !ok || int(score) != 88 {
|
|
t.Errorf("application.ai_score = %v, want the interview's 88", stored["ai_score"])
|
|
}
|
|
}
|
|
|
|
// And the permission itself has NOT widened. The server writes that one row on
|
|
// the caller's behalf; the caller still cannot patch an application.
|
|
func TestCompletingAnInterviewDoesNotWidenApplicationUpdate(t *testing.T) {
|
|
r := newRBAC(t)
|
|
app := talentApplication(t, r, r.talA)
|
|
|
|
if got := r.as(r.talA, "POST", "/api/v1/ai-interviews",
|
|
interviewBody(app, r.activePosting, 70)); got.code != http.StatusCreated {
|
|
t.Fatalf("talent interview: got %d, want 201 (%v)", got.code, got.body)
|
|
}
|
|
if got := r.as(r.talA, "PATCH", "/api/v1/job-applications/"+app,
|
|
map[string]any{"status": "hired"}); got.code != http.StatusForbidden {
|
|
t.Fatalf("talent PATCH of their own application: got %d, want 403 (%v)", got.code, got.body)
|
|
}
|
|
}
|
|
|
|
// An operator's interview links the same way. The atomicity half of the fix is
|
|
// not talent-specific: a failure between the two writes left an interview
|
|
// attached to an application that did not know about it, whoever ran it.
|
|
func TestOperatorCompletingAnInterviewLinksTheApplication(t *testing.T) {
|
|
r := newRBAC(t)
|
|
app := applicationFor(t, r, r.activePosting, "Operator Candidate", "opcand@example.test")
|
|
|
|
got := r.as(r.empA, "POST", "/api/v1/ai-interviews",
|
|
interviewBody(app, r.activePosting, 64))
|
|
if got.code != http.StatusCreated {
|
|
t.Fatalf("operator interview: got %d, want 201 (%v)", got.code, got.body)
|
|
}
|
|
interviewID := got.body["data"].(map[string]any)["id"].(string)
|
|
|
|
stored := applicationByID(t, r, app)
|
|
if stored["status"] != "interview" || stored["interview_id"] != interviewID {
|
|
t.Errorf("application = status %v, interview_id %v; want interview / %s",
|
|
stored["status"], stored["interview_id"], interviewID)
|
|
}
|
|
}
|
|
|
|
/* ── What the link must not do ──────────────────────────────────────────── */
|
|
|
|
// A body that says nothing about the score must not overwrite the screening
|
|
// score with the interview column's default of 0. The field the caller never
|
|
// mentioned is not a value they asked to store.
|
|
func TestInterviewWithoutAScoreLeavesTheApplicationScore(t *testing.T) {
|
|
r := newRBAC(t)
|
|
app := applicationFor(t, r, r.activePosting, "Scored", "scored@example.test") // ai_score 77
|
|
|
|
got := r.as(r.admin, "POST", "/api/v1/ai-interviews",
|
|
interviewBody(app, r.activePosting, nil))
|
|
if got.code != http.StatusCreated {
|
|
t.Fatalf("interview: got %d, want 201 (%v)", got.code, got.body)
|
|
}
|
|
|
|
stored := applicationByID(t, r, app)
|
|
if score, ok := stored["ai_score"].(float64); !ok || int(score) != 77 {
|
|
t.Errorf("application.ai_score = %v, want the screening score 77 left alone",
|
|
stored["ai_score"])
|
|
}
|
|
// The status and the link still move — those are what completing an
|
|
// interview means.
|
|
if stored["status"] != "interview" || stored["interview_id"] == nil {
|
|
t.Errorf("application = status %v, interview_id %v; want interview and a link",
|
|
stored["status"], stored["interview_id"])
|
|
}
|
|
}
|
|
|
|
// Somebody else's application is not a subject a talent user may interview for,
|
|
// and the refusal must leave nothing behind — not the interview, and not a
|
|
// changed application.
|
|
func TestInterviewForAnotherPersonsApplicationWritesNothing(t *testing.T) {
|
|
r := newRBAC(t)
|
|
app := talentApplication(t, r, r.talA)
|
|
before := interviewCount(t, r)
|
|
|
|
got := r.as(r.talB, "POST", "/api/v1/ai-interviews",
|
|
interviewBody(app, r.activePosting, 95))
|
|
if got.code != http.StatusNotFound {
|
|
t.Fatalf("interview for another person's application: got %d, want 404 (%v)",
|
|
got.code, got.body)
|
|
}
|
|
if after := interviewCount(t, r); after != before {
|
|
t.Errorf("ai_interviews: %d -> %d, want no row", before, after)
|
|
}
|
|
|
|
stored := applicationByID(t, r, app)
|
|
if stored["status"] != "applied" || stored["interview_id"] != nil {
|
|
t.Errorf("application = status %v, interview_id %v; want it untouched",
|
|
stored["status"], stored["interview_id"])
|
|
}
|
|
}
|
|
|
|
// An interview that cannot be written must not move the application either.
|
|
// Both writes are in one transaction, so a refusal at the first is the whole
|
|
// request rolled back rather than a partial completion.
|
|
func TestARefusedInterviewLeavesTheApplicationAlone(t *testing.T) {
|
|
r := newRBAC(t)
|
|
app := applicationFor(t, r, r.activePosting, "Unfinished", "unfinished@example.test")
|
|
before := interviewCount(t, r)
|
|
|
|
body := interviewBody(app, r.activePosting, 80)
|
|
body["verdict"] = "definitely" // outside the interview_verdict enum
|
|
|
|
got := r.as(r.admin, "POST", "/api/v1/ai-interviews", body)
|
|
if got.code == http.StatusCreated {
|
|
t.Fatalf("an invalid verdict was accepted: %v", got.body)
|
|
}
|
|
if after := interviewCount(t, r); after != before {
|
|
t.Errorf("ai_interviews: %d -> %d, want no row", before, after)
|
|
}
|
|
|
|
stored := applicationByID(t, r, app)
|
|
if stored["status"] != "shortlisted" || stored["interview_id"] != nil {
|
|
t.Errorf("application = status %v, interview_id %v; want it untouched",
|
|
stored["status"], stored["interview_id"])
|
|
}
|
|
}
|
|
|
|
// Cross-tenant: the application is in another organization, so it is absent
|
|
// rather than forbidden, and no interview is written for it.
|
|
func TestInterviewCannotReachAnotherOrganizationsApplication(t *testing.T) {
|
|
r := newRBAC(t)
|
|
app := applicationFor(t, r, r.activePosting, "Ours", "ours-interview@example.test")
|
|
|
|
var before int
|
|
if err := r.h.Pool.QueryRow(context.Background(),
|
|
`SELECT count(*) FROM ai_interviews`).Scan(&before); err != nil {
|
|
t.Fatalf("count interviews: %v", err)
|
|
}
|
|
|
|
got := r.as(r.outsider, "POST", "/api/v1/ai-interviews",
|
|
interviewBody(app, r.activePosting, 90))
|
|
if got.code == http.StatusCreated {
|
|
t.Fatalf("an outsider wrote an interview for our application: %v", got.body)
|
|
}
|
|
|
|
var after int
|
|
if err := r.h.Pool.QueryRow(context.Background(),
|
|
`SELECT count(*) FROM ai_interviews`).Scan(&after); err != nil {
|
|
t.Fatalf("count interviews: %v", err)
|
|
}
|
|
if after != before {
|
|
t.Errorf("ai_interviews: %d -> %d, want no row", before, after)
|
|
}
|
|
if stored := applicationByID(t, r, app); stored["interview_id"] != nil {
|
|
t.Errorf("application.interview_id = %v, want it untouched", stored["interview_id"])
|
|
}
|
|
}
|