Files
krow_backend/go-api/internal/httpserver/interviews_test.go
2026-08-25 16:37:05 +05:30

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"])
}
}