Files
descrybe/apps/api/internal/processing/openai_test.go
T
2026-08-16 16:57:36 +02:00

229 lines
7.8 KiB
Go

package processing
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
func TestHeuristicCompleter_thinProductInventFromAttrs(t *testing.T) {
t.Parallel()
h := HeuristicCompleter{}
system := `Retail product copywriter. Schema: {"name":"string","description":"string"}`
user := "Category: Monitors\nName: UltraView 27\nDesc: UltraView 27\nAttrs: {\"brand\":\"Acme\",\"size\":\"27 inch\",\"panel\":\"IPS\"}"
comp, err := h.Complete(context.Background(), system, user)
if err != nil {
t.Fatal(err)
}
var out map[string]string
if err := json.Unmarshal([]byte(comp.Text), &out); err != nil {
t.Fatalf("json: %v text=%q", err, comp.Text)
}
if out["name"] != "UltraView 27" {
t.Fatalf("name=%q", out["name"])
}
desc := out["description"]
if desc == "" || strings.EqualFold(desc, "UltraView 27") || desc == "Product description" {
t.Fatalf("desc=%q want invented non-title copy", desc)
}
if !strings.Contains(strings.ToLower(desc), "acme") && !strings.Contains(strings.ToLower(desc), "ips") && !strings.Contains(strings.ToLower(desc), "27") {
t.Fatalf("desc=%q want attrs-derived facts", desc)
}
}
func TestNewOpenAIClient_capsRetries(t *testing.T) {
c := NewOpenAIClient("k", "https://api.openai.com/v1", "m", 0, 99)
if c.MaxRetries != maxOpenAIRetries {
t.Fatalf("MaxRetries=%d want %d", c.MaxRetries, maxOpenAIRetries)
}
c2 := NewOpenAIClient("k", "", "m", 0, 0)
if c2.MaxRetries != 3 {
t.Fatalf("default MaxRetries=%d want 3", c2.MaxRetries)
}
}
func TestNewOpenAIClient_blocksPrivateDial(t *testing.T) {
c := NewOpenAIClient("k", "https://api.openai.com/v1", "m", 0, 1)
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, "http://127.0.0.1:9/", nil)
if err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
req = req.WithContext(ctx)
_, err = c.HTTPClient.Do(req)
if err == nil {
t.Fatal("expected dial to private/loopback blocked")
}
}
func TestOpenAIBaseAllowsLoopback(t *testing.T) {
if !openAIBaseAllowsLoopback("http://localhost:11434/v1") {
t.Fatal("expected localhost allowed")
}
if !openAIBaseAllowsLoopback("http://127.0.0.1:11434/v1") {
t.Fatal("expected 127.0.0.1 allowed")
}
if openAIBaseAllowsLoopback("https://api.openai.com/v1") {
t.Fatal("expected public host denied for loopback flag")
}
if openAIBaseAllowsLoopback("https://192.168.1.1/v1") {
t.Fatal("expected private IP denied")
}
}
func TestOpenAIBaseAllowsPrivateNonProd(t *testing.T) {
t.Setenv("APP_ENV", "local")
if !openAIBaseAllowsPrivate("http://192.168.50.181:8767/v1") {
t.Fatal("expected LAN proxy allowed in local")
}
if !openAIDialPolicy("http://192.168.50.181:8767/v1").AllowPrivate {
t.Fatal("expected dial policy AllowPrivate")
}
t.Setenv("APP_ENV", "production")
if openAIBaseAllowsPrivate("http://192.168.50.181:8767/v1") {
t.Fatal("expected LAN proxy blocked in production")
}
t.Setenv("APP_ENV", "local")
if openAIBaseAllowsPrivate("http://169.254.169.254/v1") {
t.Fatal("expected link-local metadata blocked")
}
if openAIBaseAllowsPrivate("https://api.openai.com/v1") {
t.Fatal("expected public host not private-allowed")
}
}
func TestNewOpenAIClient_allowsPrivateDialNonProd(t *testing.T) {
t.Setenv("APP_ENV", "development")
c := NewOpenAIClient("k", "http://192.168.50.181:8767/v1", "m", 0, 1)
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, "http://192.168.50.181:9/", nil)
if err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
req = req.WithContext(ctx)
_, err = c.HTTPClient.Do(req)
if err == nil {
t.Fatal("expected connection error, not success")
}
if strings.Contains(err.Error(), "host is not allowed") {
t.Fatalf("SSRF blocked LAN OpenAI base unexpectedly: %v", err)
}
}
func TestNewOpenAIClient_blocksPrivateDialInProduction(t *testing.T) {
t.Setenv("APP_ENV", "production")
c := NewOpenAIClient("k", "http://192.168.50.181:8767/v1", "m", 0, 1)
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, "http://192.168.50.181:9/", nil)
if err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
req = req.WithContext(ctx)
_, err = c.HTTPClient.Do(req)
if err == nil {
t.Fatal("expected dial blocked in production")
}
if !strings.Contains(err.Error(), "host is not allowed") {
t.Fatalf("expected host is not allowed, got: %v", err)
}
}
func TestChoiceMessageText_prefersContentThenReasoningJSON(t *testing.T) {
t.Parallel()
if got := choiceMessageText(json.RawMessage(`"{\"name\":\"A\",\"description\":\"B\"}"`), "", ""); got == "" {
t.Fatal("expected content string")
}
reasoning := `Thinking…\nDraft JSON:\n{"name":"NOSILEC W53070","description":"Stenski nosilec za TV."}\nVerify…`
got := choiceMessageText(json.RawMessage(`""`), reasoning, "")
if !strings.Contains(got, `"name"`) || !strings.Contains(got, "NOSILEC") {
t.Fatalf("got=%q want JSON from reasoning_content", got)
}
if got := choiceMessageText(json.RawMessage(`null`), "no json here", ""); got != "" {
t.Fatalf("expected empty without JSON, got %q", got)
}
}
func TestOpenAIClient_doComplete_emptyContentWithReasoningJSON(t *testing.T) {
t.Parallel()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_ = json.NewEncoder(w).Encode(map[string]any{
"model": "code-fast",
"choices": []map[string]any{{
"finish_reason": "length",
"message": map[string]any{
"role": "assistant",
"content": "",
"reasoning_content": `steps… {"name":"TV Mount","description":"A wall mount for TVs."} more`,
},
}},
"usage": map[string]int{"prompt_tokens": 10, "completion_tokens": 50, "total_tokens": 60},
})
}))
defer srv.Close()
c := NewOpenAIClient("test-key", srv.URL, "code-fast", 0, 1)
c.HTTPClient = srv.Client()
comp, err := c.CompleteWithOptions(context.Background(), "sys", "user", CompleteOptions{MaxTokens: 350, Temperature: 0.2})
if err != nil {
t.Fatal(err)
}
if !strings.Contains(comp.Text, "TV Mount") {
t.Fatalf("text=%q", comp.Text)
}
}
func TestOpenAIClient_doComplete_emptyContentLengthRetriesWithBudget(t *testing.T) {
t.Parallel()
calls := 0
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls++
var req map[string]any
_ = json.NewDecoder(r.Body).Decode(&req)
maxTok, _ := req["max_tokens"].(float64)
if calls == 1 {
if maxTok != 350 {
t.Errorf("first max_tokens=%v want 350", maxTok)
}
_ = json.NewEncoder(w).Encode(map[string]any{
"model": "code-fast",
"choices": []map[string]any{{
"finish_reason": "length",
"message": map[string]any{"role": "assistant", "content": "", "reasoning_content": "still thinking"},
}},
"usage": map[string]int{"prompt_tokens": 1, "completion_tokens": 350, "total_tokens": 351},
})
return
}
if maxTok != float64(maxTokensReasoningBudget) {
t.Errorf("retry max_tokens=%v want %d", maxTok, maxTokensReasoningBudget)
}
_ = json.NewEncoder(w).Encode(map[string]any{
"model": "code-fast",
"choices": []map[string]any{{
"finish_reason": "stop",
"message": map[string]any{"role": "assistant", "content": `{"name":"X","description":"Y product description text here."}`},
}},
"usage": map[string]int{"prompt_tokens": 1, "completion_tokens": 40, "total_tokens": 41},
})
}))
defer srv.Close()
c := NewOpenAIClient("test-key", srv.URL, "code-fast", 0, 2)
c.HTTPClient = srv.Client()
comp, err := c.CompleteWithOptions(context.Background(), "sys", "user", CompleteOptions{MaxTokens: 350, Temperature: 0.2})
if err != nil {
t.Fatal(err)
}
if calls != 2 {
t.Fatalf("calls=%d want 2", calls)
}
if !strings.Contains(comp.Text, "\"name\"") {
t.Fatalf("text=%q", comp.Text)
}
}