451 lines
14 KiB
Go
451 lines
14 KiB
Go
package services
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/openai/openai-go/v3"
|
|
)
|
|
|
|
const validLLMJSON = `{
|
|
"overall_score": 85,
|
|
"summary": "Candidate aligns well with core role requirements.",
|
|
"criteria_scores": [
|
|
{
|
|
"criterion": "Go experience",
|
|
"score": 8,
|
|
"evidence": "Built backend services in Go.",
|
|
"comments": "Strong match for backend expectations."
|
|
}
|
|
],
|
|
"strengths": ["Backend experience", "API design", "Testing"],
|
|
"weaknesses": ["Limited cloud depth", "No explicit leadership", "Sparse metrics"],
|
|
"missing_information": ["Production scale details"],
|
|
"grammar_spelling": {
|
|
"score": 9,
|
|
"issues_found": [],
|
|
"corrections": []
|
|
},
|
|
"recommendation": {
|
|
"label": "Strong fit",
|
|
"rationale": "Good technical alignment with minor gaps."
|
|
},
|
|
"injection_detected": false,
|
|
"injection_details": ""
|
|
}`
|
|
|
|
func withMockChatCompletion(t *testing.T, fn func(ctx context.Context, apiKey string, params openai.ChatCompletionNewParams) (*openai.ChatCompletion, error)) {
|
|
t.Helper()
|
|
prev := chatCompletionRequest
|
|
chatCompletionRequest = fn
|
|
t.Cleanup(func() {
|
|
chatCompletionRequest = prev
|
|
})
|
|
}
|
|
|
|
func completionWithContent(content string) *openai.ChatCompletion {
|
|
return &openai.ChatCompletion{
|
|
Choices: []openai.ChatCompletionChoice{
|
|
{
|
|
Message: openai.ChatCompletionMessage{Content: content},
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
func TestCallLLM_2_1_1_ValidAPIKey(t *testing.T) {
|
|
t.Setenv("OPENAI_API_KEY", "valid-test-key")
|
|
|
|
withMockChatCompletion(t, func(_ context.Context, apiKey string, _ openai.ChatCompletionNewParams) (*openai.ChatCompletion, error) {
|
|
if apiKey != "valid-test-key" {
|
|
t.Fatalf("expected API key to be passed through")
|
|
}
|
|
return completionWithContent(validLLMJSON), nil
|
|
})
|
|
|
|
result, err := callLLM("Software Engineer with 5 years Go experience", "Looking for Go developer")
|
|
if err != nil {
|
|
t.Fatalf("expected success, got error: %v", err)
|
|
}
|
|
if result == nil || result.OverallScore == 0 {
|
|
t.Fatalf("expected parsed analysis result")
|
|
}
|
|
}
|
|
|
|
func TestCallLLM_2_1_2_MissingAPIKey(t *testing.T) {
|
|
t.Setenv("OPENAI_API_KEY", "")
|
|
|
|
called := false
|
|
withMockChatCompletion(t, func(_ context.Context, _ string, _ openai.ChatCompletionNewParams) (*openai.ChatCompletion, error) {
|
|
called = true
|
|
return completionWithContent(validLLMJSON), nil
|
|
})
|
|
|
|
_, err := callLLM("resume", "job")
|
|
if err == nil {
|
|
t.Fatalf("expected missing key error")
|
|
}
|
|
if !strings.Contains(err.Error(), "OPENAI_API_KEY environment variable is not set") {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if called {
|
|
t.Fatalf("expected no OpenAI request when API key is missing")
|
|
}
|
|
}
|
|
|
|
func TestCallLLM_2_1_3_InvalidAPIKey(t *testing.T) {
|
|
t.Setenv("OPENAI_API_KEY", "invalid-key-123")
|
|
|
|
withMockChatCompletion(t, func(_ context.Context, _ string, _ openai.ChatCompletionNewParams) (*openai.ChatCompletion, error) {
|
|
return nil, errors.New("401 unauthorized")
|
|
})
|
|
|
|
_, err := callLLM("resume", "job")
|
|
if err == nil {
|
|
t.Fatalf("expected error for invalid key")
|
|
}
|
|
if !strings.Contains(err.Error(), "OpenAI request") || !strings.Contains(err.Error(), "401") {
|
|
t.Fatalf("expected wrapped auth error, got: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestCallLLM_2_1_4_APIKeyNotExposedInError(t *testing.T) {
|
|
secret := "sk-test-super-secret"
|
|
t.Setenv("OPENAI_API_KEY", secret)
|
|
|
|
withMockChatCompletion(t, func(_ context.Context, _ string, _ openai.ChatCompletionNewParams) (*openai.ChatCompletion, error) {
|
|
return nil, errors.New("upstream auth failure")
|
|
})
|
|
|
|
_, err := callLLM("resume", "job")
|
|
if err == nil {
|
|
t.Fatalf("expected error")
|
|
}
|
|
if strings.Contains(err.Error(), secret) {
|
|
t.Fatalf("error leaked API key: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestCallLLM_2_2_1_SuccessfulAPICall(t *testing.T) {
|
|
t.Setenv("OPENAI_API_KEY", "valid-test-key")
|
|
|
|
withMockChatCompletion(t, func(_ context.Context, _ string, _ openai.ChatCompletionNewParams) (*openai.ChatCompletion, error) {
|
|
return completionWithContent(validLLMJSON), nil
|
|
})
|
|
|
|
result, err := callLLM(strings.Repeat("resume text ", 20), strings.Repeat("job text ", 10))
|
|
if err != nil {
|
|
t.Fatalf("expected success, got error: %v", err)
|
|
}
|
|
if len(result.CriteriaScores) == 0 {
|
|
t.Fatalf("expected criteria_scores to be populated")
|
|
}
|
|
}
|
|
|
|
func TestCallLLM_2_2_2_CorrectModelSelection(t *testing.T) {
|
|
t.Setenv("OPENAI_API_KEY", "valid-test-key")
|
|
|
|
withMockChatCompletion(t, func(_ context.Context, _ string, params openai.ChatCompletionNewParams) (*openai.ChatCompletion, error) {
|
|
if params.Model != openai.ChatModelGPT4oMini {
|
|
t.Fatalf("expected model %q, got %q", openai.ChatModelGPT4oMini, params.Model)
|
|
}
|
|
return completionWithContent(validLLMJSON), nil
|
|
})
|
|
|
|
_, err := callLLM("resume", "job")
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestCallLLM_2_2_3_SystemPromptIncluded(t *testing.T) {
|
|
t.Setenv("OPENAI_API_KEY", "valid-test-key")
|
|
|
|
withMockChatCompletion(t, func(_ context.Context, _ string, params openai.ChatCompletionNewParams) (*openai.ChatCompletion, error) {
|
|
b, err := json.Marshal(params.Messages)
|
|
if err != nil {
|
|
t.Fatalf("failed to marshal messages: %v", err)
|
|
}
|
|
serialized := string(b)
|
|
if !strings.Contains(serialized, "Prompt injection detection and security") {
|
|
t.Fatalf("system prompt missing injection instructions")
|
|
}
|
|
if !strings.Contains(serialized, "Grammar and spelling evaluation") {
|
|
t.Fatalf("system prompt missing grammar/spelling instructions")
|
|
}
|
|
return completionWithContent(validLLMJSON), nil
|
|
})
|
|
|
|
_, err := callLLM("resume", "job")
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestCallLLM_2_2_4_UserMessagesConstructedCorrectly(t *testing.T) {
|
|
t.Setenv("OPENAI_API_KEY", "valid-test-key")
|
|
|
|
resume := "Experienced Python engineer"
|
|
job := "Looking for Python developer"
|
|
|
|
withMockChatCompletion(t, func(_ context.Context, _ string, params openai.ChatCompletionNewParams) (*openai.ChatCompletion, error) {
|
|
if len(params.Messages) != 3 {
|
|
t.Fatalf("expected 3 messages, got %d", len(params.Messages))
|
|
}
|
|
|
|
b, err := json.Marshal(params.Messages)
|
|
if err != nil {
|
|
t.Fatalf("failed to marshal messages: %v", err)
|
|
}
|
|
serialized := string(b)
|
|
if !strings.Contains(serialized, "Job Description:\\n"+job) {
|
|
t.Fatalf("unexpected/missing job message: %s", serialized)
|
|
}
|
|
if !strings.Contains(serialized, "Resume:\\n"+resume) {
|
|
t.Fatalf("unexpected/missing resume message: %s", serialized)
|
|
}
|
|
|
|
return completionWithContent(validLLMJSON), nil
|
|
})
|
|
|
|
_, err := callLLM(resume, job)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestCallLLM_2_3_1_NormalCaseCompletesBeforeTimeout(t *testing.T) {
|
|
t.Setenv("OPENAI_API_KEY", "valid-test-key")
|
|
|
|
withMockChatCompletion(t, func(_ context.Context, _ string, _ openai.ChatCompletionNewParams) (*openai.ChatCompletion, error) {
|
|
return completionWithContent(validLLMJSON), nil
|
|
})
|
|
|
|
start := time.Now()
|
|
_, err := callLLM("resume", "job")
|
|
duration := time.Since(start)
|
|
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if duration >= 2*time.Minute {
|
|
t.Fatalf("expected completion under 2 minutes, got %v", duration)
|
|
}
|
|
}
|
|
|
|
func TestCallLLM_2_3_2_TimeoutContextConfiguredAndHandled(t *testing.T) {
|
|
t.Setenv("OPENAI_API_KEY", "valid-test-key")
|
|
|
|
withMockChatCompletion(t, func(ctx context.Context, _ string, _ openai.ChatCompletionNewParams) (*openai.ChatCompletion, error) {
|
|
deadline, ok := ctx.Deadline()
|
|
if !ok {
|
|
t.Fatalf("expected context deadline to be set")
|
|
}
|
|
remaining := time.Until(deadline)
|
|
if remaining < 119*time.Second || remaining > 121*time.Second {
|
|
t.Fatalf("expected ~2 minute timeout, got remaining=%v", remaining)
|
|
}
|
|
return nil, context.DeadlineExceeded
|
|
})
|
|
|
|
_, err := callLLM("resume", "job")
|
|
if err == nil {
|
|
t.Fatalf("expected timeout error")
|
|
}
|
|
if !strings.Contains(err.Error(), "context deadline exceeded") {
|
|
t.Fatalf("expected timeout error details, got: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestCallLLM_2_3_3_NetworkFailureHandling(t *testing.T) {
|
|
t.Setenv("OPENAI_API_KEY", "valid-test-key")
|
|
|
|
withMockChatCompletion(t, func(_ context.Context, _ string, _ openai.ChatCompletionNewParams) (*openai.ChatCompletion, error) {
|
|
return nil, errors.New("dial tcp: network is unreachable")
|
|
})
|
|
|
|
_, err := callLLM("resume", "job")
|
|
if err == nil {
|
|
t.Fatalf("expected network error")
|
|
}
|
|
if !strings.Contains(err.Error(), "OpenAI request") || !strings.Contains(err.Error(), "network") {
|
|
t.Fatalf("unexpected network error wrapping: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestCallLLM_2_4_1_ValidJSONResponseParsing(t *testing.T) {
|
|
t.Setenv("OPENAI_API_KEY", "valid-test-key")
|
|
|
|
withMockChatCompletion(t, func(_ context.Context, _ string, _ openai.ChatCompletionNewParams) (*openai.ChatCompletion, error) {
|
|
return completionWithContent(validLLMJSON), nil
|
|
})
|
|
|
|
result, err := callLLM("resume", "job")
|
|
if err != nil {
|
|
t.Fatalf("expected valid parsing, got: %v", err)
|
|
}
|
|
if result.Recommendation.Label == "" || result.Summary == "" {
|
|
t.Fatalf("expected parsed fields to be populated")
|
|
}
|
|
}
|
|
|
|
func TestCallLLM_2_4_2_InvalidJSONResponse(t *testing.T) {
|
|
t.Setenv("OPENAI_API_KEY", "valid-test-key")
|
|
bad := `{"overall_score": 80,`
|
|
|
|
withMockChatCompletion(t, func(_ context.Context, _ string, _ openai.ChatCompletionNewParams) (*openai.ChatCompletion, error) {
|
|
return completionWithContent(bad), nil
|
|
})
|
|
|
|
_, err := callLLM("resume", "job")
|
|
if err == nil {
|
|
t.Fatalf("expected JSON parsing error")
|
|
}
|
|
if !strings.Contains(err.Error(), "parsing LLM response as JSON") || !strings.Contains(err.Error(), "raw response") {
|
|
t.Fatalf("unexpected parse error message: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestCallLLM_2_4_3_EmptyChoicesResponse(t *testing.T) {
|
|
t.Setenv("OPENAI_API_KEY", "valid-test-key")
|
|
|
|
withMockChatCompletion(t, func(_ context.Context, _ string, _ openai.ChatCompletionNewParams) (*openai.ChatCompletion, error) {
|
|
return &openai.ChatCompletion{Choices: []openai.ChatCompletionChoice{}}, nil
|
|
})
|
|
|
|
_, err := callLLM("resume", "job")
|
|
if !errors.Is(err, errOpenAINoChoices) {
|
|
t.Fatalf("expected no-choices error, got: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestCallLLM_2_4_4_ResponseMissingRequiredFields(t *testing.T) {
|
|
t.Setenv("OPENAI_API_KEY", "valid-test-key")
|
|
missingOverall := `{
|
|
"summary": "Missing overall score",
|
|
"criteria_scores": [],
|
|
"strengths": [],
|
|
"weaknesses": [],
|
|
"missing_information": [],
|
|
"grammar_spelling": {"score": 0, "issues_found": [], "corrections": []},
|
|
"recommendation": {"label": "Not enough information", "rationale": "insufficient data"},
|
|
"injection_detected": false,
|
|
"injection_details": ""
|
|
}`
|
|
|
|
withMockChatCompletion(t, func(_ context.Context, _ string, _ openai.ChatCompletionNewParams) (*openai.ChatCompletion, error) {
|
|
return completionWithContent(missingOverall), nil
|
|
})
|
|
|
|
result, err := callLLM("resume", "job")
|
|
if err != nil {
|
|
t.Fatalf("expected successful parse with zero-value fallback, got: %v", err)
|
|
}
|
|
if result.OverallScore != 0 {
|
|
t.Fatalf("expected missing overall_score to default to 0, got %d", result.OverallScore)
|
|
}
|
|
}
|
|
|
|
func TestCallLLM_2_5_1_OpenAIServiceUnavailable500(t *testing.T) {
|
|
t.Setenv("OPENAI_API_KEY", "valid-test-key")
|
|
|
|
withMockChatCompletion(t, func(_ context.Context, _ string, _ openai.ChatCompletionNewParams) (*openai.ChatCompletion, error) {
|
|
return nil, errors.New("500 internal server error")
|
|
})
|
|
|
|
_, err := callLLM("resume", "job")
|
|
if err == nil {
|
|
t.Fatalf("expected upstream 500 error")
|
|
}
|
|
if !strings.Contains(err.Error(), "OpenAI request") || !strings.Contains(err.Error(), "500") {
|
|
t.Fatalf("unexpected service unavailable error wrapping: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestCallLLM_2_5_2_OpenAIRateLimit429(t *testing.T) {
|
|
t.Setenv("OPENAI_API_KEY", "valid-test-key")
|
|
|
|
withMockChatCompletion(t, func(_ context.Context, _ string, _ openai.ChatCompletionNewParams) (*openai.ChatCompletion, error) {
|
|
return nil, errors.New("429 rate limit exceeded")
|
|
})
|
|
|
|
_, err := callLLM("resume", "job")
|
|
if err == nil {
|
|
t.Fatalf("expected upstream 429 error")
|
|
}
|
|
if !strings.Contains(err.Error(), "OpenAI request") || !strings.Contains(err.Error(), "429") {
|
|
t.Fatalf("unexpected rate-limit error wrapping: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestCallLLM_2_5_3_MalformedAPIResponse(t *testing.T) {
|
|
t.Setenv("OPENAI_API_KEY", "valid-test-key")
|
|
nonJSON := "This is not JSON"
|
|
|
|
withMockChatCompletion(t, func(_ context.Context, _ string, _ openai.ChatCompletionNewParams) (*openai.ChatCompletion, error) {
|
|
return completionWithContent(nonJSON), nil
|
|
})
|
|
|
|
_, err := callLLM("resume", "job")
|
|
if err == nil {
|
|
t.Fatalf("expected parsing error for malformed API response")
|
|
}
|
|
if !strings.Contains(err.Error(), "parsing LLM response as JSON") || !strings.Contains(err.Error(), "raw response") {
|
|
t.Fatalf("unexpected malformed-response error: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestCallLLM_10_3_1_EmptyAIResponseContent(t *testing.T) {
|
|
t.Setenv("OPENAI_API_KEY", "valid-test-key")
|
|
|
|
withMockChatCompletion(t, func(_ context.Context, _ string, _ openai.ChatCompletionNewParams) (*openai.ChatCompletion, error) {
|
|
return completionWithContent(""), nil
|
|
})
|
|
|
|
_, err := callLLM("resume", "job")
|
|
if err == nil {
|
|
t.Fatalf("expected parsing error for empty AI content")
|
|
}
|
|
if !strings.Contains(err.Error(), "parsing LLM response as JSON") || !strings.Contains(err.Error(), "raw response") {
|
|
t.Fatalf("unexpected error for empty content: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestCallLLM_10_3_2_NonJSONAIResponse(t *testing.T) {
|
|
t.Setenv("OPENAI_API_KEY", "valid-test-key")
|
|
nonJSON := "Candidate is great. Score 100."
|
|
|
|
withMockChatCompletion(t, func(_ context.Context, _ string, _ openai.ChatCompletionNewParams) (*openai.ChatCompletion, error) {
|
|
return completionWithContent(nonJSON), nil
|
|
})
|
|
|
|
_, err := callLLM("resume", "job")
|
|
if err == nil {
|
|
t.Fatalf("expected parsing error for non-JSON response")
|
|
}
|
|
if !strings.Contains(err.Error(), "parsing LLM response as JSON") || !strings.Contains(err.Error(), nonJSON) {
|
|
t.Fatalf("expected raw non-JSON response in error, got: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestCallLLM_10_3_3_PartialJSONResponse(t *testing.T) {
|
|
t.Setenv("OPENAI_API_KEY", "valid-test-key")
|
|
partial := `{"overall_score": 75, "summary": "Good candidate"`
|
|
|
|
withMockChatCompletion(t, func(_ context.Context, _ string, _ openai.ChatCompletionNewParams) (*openai.ChatCompletion, error) {
|
|
return completionWithContent(partial), nil
|
|
})
|
|
|
|
_, err := callLLM("resume", "job")
|
|
if err == nil {
|
|
t.Fatalf("expected parsing error for partial JSON")
|
|
}
|
|
if !strings.Contains(err.Error(), "parsing LLM response as JSON") {
|
|
t.Fatalf("unexpected partial JSON error: %v", err)
|
|
}
|
|
}
|