ResumeLens/internal/handlers/analyze_test.go
2026-04-07 13:14:52 -07:00

487 lines
15 KiB
Go

package handlers
import (
"bytes"
"encoding/json"
"errors"
"io"
"mime/multipart"
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
"git.gophernest.net/azpect/ResumeLens/internal/models"
)
func withMockAnalyzeResume(t *testing.T, fn func(resume io.Reader, jobDescription string) (*models.AnalysisResult, error)) {
t.Helper()
prev := analyzeResume
analyzeResume = fn
t.Cleanup(func() {
analyzeResume = prev
})
}
func makeMultipartRequest(t *testing.T, includeResume bool, resumeSize int, jobDescription string) *http.Request {
t.Helper()
body := &bytes.Buffer{}
writer := multipart.NewWriter(body)
if includeResume {
part, err := writer.CreateFormFile("resume", "resume.pdf")
if err != nil {
t.Fatalf("failed to create resume part: %v", err)
}
if resumeSize > 0 {
_, err = part.Write(bytes.Repeat([]byte{'A'}, resumeSize))
if err != nil {
t.Fatalf("failed to write resume bytes: %v", err)
}
}
}
if err := writer.WriteField("job_description", jobDescription); err != nil {
t.Fatalf("failed to write job_description field: %v", err)
}
if err := writer.Close(); err != nil {
t.Fatalf("failed to close multipart writer: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/api/analyze", body)
req.Header.Set("Content-Type", writer.FormDataContentType())
return req
}
func goodResult() *models.AnalysisResult {
return &models.AnalysisResult{
OverallScore: 80,
Summary: "Solid fit",
CriteriaScores: []models.CriterionScore{
{Criterion: "Go", Score: 8, Evidence: "Go projects", Comments: "Strong"},
},
Strengths: []string{"Strong backend", "Good testing", "Clear communication"},
Weaknesses: []string{"Limited cloud depth", "Sparse leadership", "Few metrics"},
MissingInformation: []string{"Production scale"},
GrammarSpelling: models.GrammarSpelling{Score: 9, IssuesFound: []string{}, Corrections: []string{}},
Recommendation: models.Recommendation{Label: "Strong fit", Rationale: "Good match"},
InjectionDetected: false,
InjectionDetails: "",
}
}
func TestAnalyze_4_1_1_ValidMultipartForm(t *testing.T) {
withMockAnalyzeResume(t, func(_ io.Reader, jobDescription string) (*models.AnalysisResult, error) {
if jobDescription != "Looking for Go developer" {
t.Fatalf("unexpected job description: %q", jobDescription)
}
return goodResult(), nil
})
req := makeMultipartRequest(t, true, 1024, "Looking for Go developer")
rr := httptest.NewRecorder()
Analyze(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("expected 200, got %d body=%s", rr.Code, rr.Body.String())
}
}
func TestAnalyze_4_1_2_FormSizeUnderLimit(t *testing.T) {
withMockAnalyzeResume(t, func(_ io.Reader, _ string) (*models.AnalysisResult, error) {
return goodResult(), nil
})
req := makeMultipartRequest(t, true, 5<<20, "Go role")
rr := httptest.NewRecorder()
Analyze(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("expected 200 for 5MB file, got %d body=%s", rr.Code, rr.Body.String())
}
}
func TestAnalyze_4_1_3_FormExceedsSizeLimit(t *testing.T) {
withMockAnalyzeResume(t, func(_ io.Reader, _ string) (*models.AnalysisResult, error) {
t.Fatal("analyzeResume should not be called for oversized request")
return nil, nil
})
req := makeMultipartRequest(t, true, 15<<20, "Go role")
rr := httptest.NewRecorder()
Analyze(rr, req)
if rr.Code != http.StatusBadRequest {
t.Fatalf("expected 400 for oversized request, got %d", rr.Code)
}
if !strings.Contains(rr.Body.String(), "failed to parse form") {
t.Fatalf("expected parse form error, got body: %s", rr.Body.String())
}
}
func TestAnalyze_4_1_4_MalformedMultipartData(t *testing.T) {
withMockAnalyzeResume(t, func(_ io.Reader, _ string) (*models.AnalysisResult, error) {
t.Fatal("analyzeResume should not be called for malformed multipart")
return nil, nil
})
body := bytes.NewBufferString("not-a-valid-multipart-body")
req := httptest.NewRequest(http.MethodPost, "/api/analyze", body)
req.Header.Set("Content-Type", "multipart/form-data; boundary=invalid")
rr := httptest.NewRecorder()
Analyze(rr, req)
if rr.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", rr.Code)
}
if !strings.Contains(rr.Body.String(), "failed to parse form") {
t.Fatalf("expected parse failure body, got: %s", rr.Body.String())
}
}
func TestAnalyze_4_2_1_MissingResumeFile(t *testing.T) {
withMockAnalyzeResume(t, func(_ io.Reader, _ string) (*models.AnalysisResult, error) {
t.Fatal("analyzeResume should not be called when resume missing")
return nil, nil
})
req := makeMultipartRequest(t, false, 0, "Looking for Go developer")
rr := httptest.NewRecorder()
Analyze(rr, req)
if rr.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", rr.Code)
}
if !strings.Contains(rr.Body.String(), "missing resume file") {
t.Fatalf("expected missing resume error, got: %s", rr.Body.String())
}
}
func TestAnalyze_4_2_2_MissingJobDescription(t *testing.T) {
withMockAnalyzeResume(t, func(_ io.Reader, _ string) (*models.AnalysisResult, error) {
t.Fatal("analyzeResume should not be called when job_description missing")
return nil, nil
})
req := makeMultipartRequest(t, true, 1024, "")
rr := httptest.NewRecorder()
Analyze(rr, req)
if rr.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", rr.Code)
}
if !strings.Contains(rr.Body.String(), "missing job_description") {
t.Fatalf("expected missing job_description error, got: %s", rr.Body.String())
}
}
func TestAnalyze_4_2_3_EmptyJobDescription(t *testing.T) {
withMockAnalyzeResume(t, func(_ io.Reader, _ string) (*models.AnalysisResult, error) {
t.Fatal("analyzeResume should not be called when job_description empty")
return nil, nil
})
req := makeMultipartRequest(t, true, 1024, "")
rr := httptest.NewRecorder()
Analyze(rr, req)
if rr.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", rr.Code)
}
if !strings.Contains(rr.Body.String(), "missing job_description") {
t.Fatalf("expected missing job_description error, got: %s", rr.Body.String())
}
}
func TestAnalyze_4_2_4_BothFieldsPresentProceedsToAnalysis(t *testing.T) {
called := false
withMockAnalyzeResume(t, func(_ io.Reader, _ string) (*models.AnalysisResult, error) {
called = true
return goodResult(), nil
})
req := makeMultipartRequest(t, true, 1024, "Backend engineer position")
rr := httptest.NewRecorder()
Analyze(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", rr.Code)
}
if !called {
t.Fatalf("expected analyzeResume to be called")
}
}
func TestAnalyze_4_3_1_ContentTypeHeader(t *testing.T) {
withMockAnalyzeResume(t, func(_ io.Reader, _ string) (*models.AnalysisResult, error) {
return goodResult(), nil
})
req := makeMultipartRequest(t, true, 1024, "Go role")
rr := httptest.NewRecorder()
Analyze(rr, req)
if ct := rr.Header().Get("Content-Type"); !strings.Contains(ct, "application/json") {
t.Fatalf("expected application/json content-type, got %q", ct)
}
}
func TestAnalyze_4_3_2_ValidJSONResponseBody(t *testing.T) {
withMockAnalyzeResume(t, func(_ io.Reader, _ string) (*models.AnalysisResult, error) {
return goodResult(), nil
})
req := makeMultipartRequest(t, true, 1024, "Go role")
rr := httptest.NewRecorder()
Analyze(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", rr.Code)
}
var parsed models.AnalysisResult
if err := json.Unmarshal(rr.Body.Bytes(), &parsed); err != nil {
t.Fatalf("expected valid JSON body, got error: %v body=%s", err, rr.Body.String())
}
if parsed.OverallScore == 0 {
t.Fatalf("expected parsed analysis fields")
}
}
func TestAnalyze_4_3_3_HTTPStatusCodes(t *testing.T) {
t.Run("success_200", func(t *testing.T) {
withMockAnalyzeResume(t, func(_ io.Reader, _ string) (*models.AnalysisResult, error) {
return goodResult(), nil
})
req := makeMultipartRequest(t, true, 1024, "Go role")
rr := httptest.NewRecorder()
Analyze(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", rr.Code)
}
})
t.Run("missing_fields_400", func(t *testing.T) {
withMockAnalyzeResume(t, func(_ io.Reader, _ string) (*models.AnalysisResult, error) {
t.Fatal("analyzeResume should not be called")
return nil, nil
})
req := makeMultipartRequest(t, false, 0, "")
rr := httptest.NewRecorder()
Analyze(rr, req)
if rr.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", rr.Code)
}
})
t.Run("analysis_failure_500", func(t *testing.T) {
withMockAnalyzeResume(t, func(_ io.Reader, _ string) (*models.AnalysisResult, error) {
return nil, errors.New("simulated failure")
})
req := makeMultipartRequest(t, true, 1024, "Go role")
rr := httptest.NewRecorder()
Analyze(rr, req)
if rr.Code != http.StatusInternalServerError {
t.Fatalf("expected 500, got %d", rr.Code)
}
})
}
func TestAnalyze_4_4_1_PDFExtractionErrorPropagation(t *testing.T) {
withMockAnalyzeResume(t, func(_ io.Reader, _ string) (*models.AnalysisResult, error) {
return nil, errors.New("extracting PDF text: parsing PDF: invalid header")
})
req := makeMultipartRequest(t, true, 1024, "Go role")
rr := httptest.NewRecorder()
Analyze(rr, req)
if rr.Code != http.StatusInternalServerError {
t.Fatalf("expected 500, got %d", rr.Code)
}
if !strings.Contains(rr.Body.String(), "analysis failed: extracting PDF text") {
t.Fatalf("expected propagated extraction error, got: %s", rr.Body.String())
}
}
func TestAnalyze_4_4_2_OpenAIErrorPropagation(t *testing.T) {
withMockAnalyzeResume(t, func(_ io.Reader, _ string) (*models.AnalysisResult, error) {
return nil, errors.New("calling LLM: OpenAI request: service unavailable")
})
req := makeMultipartRequest(t, true, 1024, "Go role")
rr := httptest.NewRecorder()
Analyze(rr, req)
if rr.Code != http.StatusInternalServerError {
t.Fatalf("expected 500, got %d", rr.Code)
}
if !strings.Contains(rr.Body.String(), "analysis failed: calling LLM") {
t.Fatalf("expected propagated LLM error, got: %s", rr.Body.String())
}
}
func countOpenFDs(t *testing.T) int {
t.Helper()
entries, err := os.ReadDir("/proc/self/fd")
if err != nil {
t.Skipf("skipping fd count check; /proc/self/fd unavailable: %v", err)
}
return len(entries)
}
func TestAnalyze_4_4_3_FileDescriptorCleanup(t *testing.T) {
withMockAnalyzeResume(t, func(_ io.Reader, _ string) (*models.AnalysisResult, error) {
return goodResult(), nil
})
before := countOpenFDs(t)
for i := 0; i < 120; i++ {
req := makeMultipartRequest(t, true, 8*1024, "Go role")
rr := httptest.NewRecorder()
Analyze(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("iteration %d: expected 200, got %d", i, rr.Code)
}
}
after := countOpenFDs(t)
if after-before > 20 {
t.Fatalf("possible fd leak detected: before=%d after=%d", before, after)
}
}
func TestAnalyze_10_1_1_InvalidContentType(t *testing.T) {
withMockAnalyzeResume(t, func(_ io.Reader, _ string) (*models.AnalysisResult, error) {
t.Fatal("analyzeResume should not be called for invalid content type")
return nil, nil
})
body := bytes.NewBufferString(`{"resume":"x"}`)
req := httptest.NewRequest(http.MethodPost, "/api/analyze", body)
req.Header.Set("Content-Type", "application/json")
rr := httptest.NewRecorder()
Analyze(rr, req)
if rr.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", rr.Code)
}
if !strings.Contains(rr.Body.String(), "failed to parse form") {
t.Fatalf("expected parse form error, got: %s", rr.Body.String())
}
}
func TestAnalyze_10_1_2_CorruptedMultipartBoundaries(t *testing.T) {
withMockAnalyzeResume(t, func(_ io.Reader, _ string) (*models.AnalysisResult, error) {
t.Fatal("analyzeResume should not be called for corrupted multipart")
return nil, nil
})
body := bytes.NewBufferString("--abc\r\nContent-Disposition: form-data; name=\"resume\"\r\n\r\nno end boundary")
req := httptest.NewRequest(http.MethodPost, "/api/analyze", body)
req.Header.Set("Content-Type", "multipart/form-data; boundary=abc")
rr := httptest.NewRecorder()
Analyze(rr, req)
if rr.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", rr.Code)
}
if !strings.Contains(rr.Body.String(), "failed to parse form") {
t.Fatalf("expected parse form error, got: %s", rr.Body.String())
}
}
func TestAnalyze_10_1_3_EmptyPostBody(t *testing.T) {
withMockAnalyzeResume(t, func(_ io.Reader, _ string) (*models.AnalysisResult, error) {
t.Fatal("analyzeResume should not be called for empty body")
return nil, nil
})
req := httptest.NewRequest(http.MethodPost, "/api/analyze", http.NoBody)
req.Header.Set("Content-Type", "multipart/form-data; boundary=abc")
rr := httptest.NewRecorder()
Analyze(rr, req)
if rr.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", rr.Code)
}
}
func TestAnalyze_10_2_1_MaximumPDFSizeBoundary(t *testing.T) {
withMockAnalyzeResume(t, func(_ io.Reader, _ string) (*models.AnalysisResult, error) {
return goodResult(), nil
})
limit := 10 << 20
probe := makeMultipartRequest(t, true, 0, "Go role")
overhead := probe.ContentLength
resumeSize := int64(limit) - overhead
if resumeSize < 1 {
t.Fatalf("unexpected multipart overhead too large: %d", overhead)
}
req := makeMultipartRequest(t, true, int(resumeSize), "Go role")
rr := httptest.NewRecorder()
Analyze(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("expected 200 at boundary-sized payload, got %d body=%s contentLength=%d", rr.Code, rr.Body.String(), req.ContentLength)
}
}
func TestAnalyze_10_2_2_VeryLongJobDescription(t *testing.T) {
word := "requirement"
longJob := strings.TrimSpace(strings.Repeat(word+" ", 10000))
withMockAnalyzeResume(t, func(_ io.Reader, jobDescription string) (*models.AnalysisResult, error) {
if len(strings.Fields(jobDescription)) != 10000 {
t.Fatalf("expected 10000-word job description, got %d", len(strings.Fields(jobDescription)))
}
return goodResult(), nil
})
req := makeMultipartRequest(t, true, 1024, longJob)
rr := httptest.NewRecorder()
Analyze(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("expected 200 for long job description, got %d", rr.Code)
}
}
func TestAnalyze_10_2_4_ResumeWithMinimalText(t *testing.T) {
withMockAnalyzeResume(t, func(_ io.Reader, _ string) (*models.AnalysisResult, error) {
result := goodResult()
result.OverallScore = 28
result.MissingInformation = []string{"Experience details", "Skills evidence", "Project depth"}
return result, nil
})
req := makeMultipartRequest(t, true, 64, "Go role")
rr := httptest.NewRecorder()
Analyze(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", rr.Code)
}
var parsed models.AnalysisResult
if err := json.Unmarshal(rr.Body.Bytes(), &parsed); err != nil {
t.Fatalf("failed parsing response: %v", err)
}
if parsed.OverallScore >= 50 {
t.Fatalf("expected low score for minimal resume text, got %d", parsed.OverallScore)
}
if len(parsed.MissingInformation) == 0 {
t.Fatalf("expected missing_information to be populated")
}
}