487 lines
15 KiB
Go
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")
|
|
}
|
|
}
|