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