208 lines
13 KiB
Go
208 lines
13 KiB
Go
package growthreport
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"fmt"
|
||
"net/http"
|
||
"net/http/httptest"
|
||
"strings"
|
||
"testing"
|
||
"time"
|
||
)
|
||
|
||
func TestAvalAICountsFailedRetryRequestsAndUsage(t *testing.T) {
|
||
t.Parallel()
|
||
requests := 0
|
||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||
requests++
|
||
w.Header().Set("X-Request-ID", fmt.Sprintf("request-%d", requests))
|
||
if requests == 1 {
|
||
w.WriteHeader(http.StatusServiceUnavailable)
|
||
_, _ = w.Write([]byte(`{"usage":{"input_tokens":7,"output_tokens":3},"estimated_cost":"0.01"}`))
|
||
return
|
||
}
|
||
_, _ = w.Write([]byte(`{"status":"completed","output":[{"type":"message","content":[{"type":"output_text","text":"{\"eventRecommendations\":[{\"evidenceId\":\"event_teacher\",\"priorityPercent\":40,\"stance\":\"PROMOTE\",\"relevanceReason\":\"این مناسبت با الگوی فروش کسبوکار همخوان است.\",\"insightBasis\":\"BUSINESS_PROFILE\"},{\"evidenceId\":\"event_yalda\",\"priorityPercent\":35,\"stance\":\"PREPARE\",\"relevanceReason\":\"این رویداد با کانال فروش شما مرتبط است.\",\"insightBasis\":\"BUSINESS_PROFILE\"},{\"evidenceId\":\"event_nowruz\",\"priorityPercent\":25,\"stance\":\"CAUTION\",\"relevanceReason\":\"این بازه برای برنامهریزی عرضه مناسب است.\",\"insightBasis\":\"BUSINESS_PROFILE\"}]}"}]}],"usage":{"input_tokens":11,"output_tokens":5},"estimated_cost":"0.02"}`))
|
||
}))
|
||
defer server.Close()
|
||
|
||
client := NewAvalAIClient(AvalAIConfig{BaseURL: server.URL, APIKey: "test", Model: "test", Timeout: time.Second}, server.Client())
|
||
_, usage, err := client.Generate(context.Background(), json.RawMessage(`{"events":[]}`), profileKnownEvidence())
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if requests != 2 || usage.ProviderRequests != 2 || usage.InputTokens != 18 || usage.OutputTokens != 8 || usage.EstimatedCost != "0.03" {
|
||
t.Fatalf("requests=%d usage=%+v", requests, usage)
|
||
}
|
||
}
|
||
|
||
func TestValidateAICopyAcceptsExactlyThreeGroundedPersianRecommendations(t *testing.T) {
|
||
t.Parallel()
|
||
tests := []struct {
|
||
name string
|
||
copy AICopy
|
||
known KnownEvidence
|
||
wantErr bool
|
||
}{
|
||
{name: "grounded Persian recommendations", copy: validRankedCopy()},
|
||
{name: "numeric claim", copy: mutateRecommendation(validRankedCopy(), 0, func(item *EventRecommendation) {
|
||
item.RelevanceReason = "رشد ۲۰ درصدی پیشبینی میشود."
|
||
}), wantErr: true},
|
||
{name: "duplicate evidence", copy: mutateRecommendation(validRankedCopy(), 1, func(item *EventRecommendation) { item.EvidenceID = "event_teacher" }), wantErr: true},
|
||
{name: "unknown evidence", copy: mutateRecommendation(validRankedCopy(), 2, func(item *EventRecommendation) { item.EvidenceID = "unknown" }), wantErr: true},
|
||
{name: "bad sum", copy: mutateRecommendation(validRankedCopy(), 2, func(item *EventRecommendation) { item.PriorityPercent = 24 }), wantErr: true},
|
||
{name: "not descending", copy: AICopy{EventRecommendations: []EventRecommendation{
|
||
{EvidenceID: "event_teacher", PriorityPercent: 35, Stance: StancePromote, RelevanceReason: "این مناسبت با الگوی فروش کسبوکار همخوان است.", InsightBasis: InsightBasisBusinessProfile},
|
||
{EvidenceID: "event_yalda", PriorityPercent: 40, Stance: StancePrepare, RelevanceReason: "این رویداد با کانال فروش شما مرتبط است.", InsightBasis: InsightBasisBusinessProfile},
|
||
{EvidenceID: "event_nowruz", PriorityPercent: 25, Stance: StanceCaution, RelevanceReason: "این بازه برای برنامهریزی عرضه مناسب است.", InsightBasis: InsightBasisBusinessProfile},
|
||
}}, wantErr: true},
|
||
{name: "basis mismatch", copy: mutateRecommendation(validRankedCopy(), 0, func(item *EventRecommendation) { item.InsightBasis = InsightBasisHistorical }), wantErr: true},
|
||
{name: "historical evidence omitted", copy: validRankedCopy(), wantErr: true, known: KnownEvidence{"event_teacher": {InsightBasis: InsightBasisBusinessProfile, Promotable: true}, "event_yalda": {InsightBasis: InsightBasisBusinessProfile, Promotable: true}, "event_nowruz": {InsightBasis: InsightBasisBusinessProfile, Promotable: true}, "event_history": {InsightBasis: InsightBasisHistorical, Promotable: true}}},
|
||
{name: "promote material decline", copy: validRankedCopy(), wantErr: true, known: KnownEvidence{"event_teacher": {InsightBasis: InsightBasisBusinessProfile, Promotable: true, MaterialDecline: true}, "event_yalda": {InsightBasis: InsightBasisBusinessProfile, Promotable: true}, "event_nowruz": {InsightBasis: InsightBasisBusinessProfile, Promotable: true}}},
|
||
{name: "promote prohibited posture", copy: validRankedCopy(), wantErr: true, known: KnownEvidence{"event_teacher": {InsightBasis: InsightBasisBusinessProfile, Promotable: false}, "event_yalda": {InsightBasis: InsightBasisBusinessProfile, Promotable: true}, "event_nowruz": {InsightBasis: InsightBasisBusinessProfile, Promotable: true}}},
|
||
{name: "reason rune count not UTF-8 byte count", copy: mutateRecommendation(validRankedCopy(), 0, func(item *EventRecommendation) { item.RelevanceReason = strings.Repeat("ا", 200) })},
|
||
{name: "reason rune limit exceeded", copy: mutateRecommendation(validRankedCopy(), 0, func(item *EventRecommendation) { item.RelevanceReason = strings.Repeat("ا", 321) }), wantErr: true},
|
||
{name: "invalid stance", copy: mutateRecommendation(validRankedCopy(), 0, func(item *EventRecommendation) { item.Stance = "UNKNOWN" }), wantErr: true},
|
||
{name: "not exactly three", copy: AICopy{EventRecommendations: validRankedCopy().EventRecommendations[:1]}, wantErr: true},
|
||
}
|
||
for _, tt := range tests {
|
||
t.Run(tt.name, func(t *testing.T) {
|
||
known := tt.known
|
||
if known == nil {
|
||
known = profileKnownEvidence()
|
||
}
|
||
err := ValidateAICopy(tt.copy, known)
|
||
if (err != nil) != tt.wantErr {
|
||
t.Fatalf("ValidateAICopy() error = %v, wantErr %v", err, tt.wantErr)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
func TestAvalAIClientUsesBoundedStatelessRequest(t *testing.T) {
|
||
t.Parallel()
|
||
var request map[string]any
|
||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
if r.Header.Get("Authorization") != "Bearer test-key" {
|
||
t.Errorf("authorization header missing")
|
||
}
|
||
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
w.Header().Set("Content-Type", "application/json")
|
||
_, _ = w.Write([]byte(`{"status":"completed","output":[{"type":"message","content":[{"type":"output_text","text":"{\"eventRecommendations\":[{\"evidenceId\":\"event_teacher\",\"priorityPercent\":40,\"stance\":\"PROMOTE\",\"relevanceReason\":\"این مناسبت با الگوی فروش کسبوکار همخوان است.\",\"insightBasis\":\"BUSINESS_PROFILE\"},{\"evidenceId\":\"event_yalda\",\"priorityPercent\":35,\"stance\":\"PREPARE\",\"relevanceReason\":\"این رویداد با کانال فروش شما مرتبط است.\",\"insightBasis\":\"BUSINESS_PROFILE\"},{\"evidenceId\":\"event_nowruz\",\"priorityPercent\":25,\"stance\":\"CAUTION\",\"relevanceReason\":\"این بازه برای برنامهریزی عرضه مناسب است.\",\"insightBasis\":\"BUSINESS_PROFILE\"}]}"}]}],"usage":{"input_tokens":10,"output_tokens":20}}`))
|
||
}))
|
||
defer server.Close()
|
||
|
||
client := NewAvalAIClient(AvalAIConfig{BaseURL: server.URL, APIKey: "test-key", Model: "test-model", Timeout: time.Second}, server.Client())
|
||
copy, usage, err := client.Generate(context.Background(), json.RawMessage(`{"events":[{"evidenceId":"event_teacher"}]}`), profileKnownEvidence())
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if len(copy.EventRecommendations) != 3 || usage.InputTokens != 10 || usage.OutputTokens != 20 {
|
||
t.Fatalf("Generate() = %+v, %+v", copy, usage)
|
||
}
|
||
if request["store"] != false || request["parallel_tool_calls"] != false {
|
||
t.Fatalf("unsafe request controls: %#v", request)
|
||
}
|
||
if request["max_output_tokens"] != float64(4096) {
|
||
t.Fatalf("max_output_tokens = %#v", request["max_output_tokens"])
|
||
}
|
||
reasoning, ok := request["reasoning"].(map[string]any)
|
||
if !ok || reasoning["effort"] != "medium" {
|
||
t.Fatalf("reasoning = %#v", request["reasoning"])
|
||
}
|
||
tools, ok := request["tools"].([]any)
|
||
if !ok || len(tools) != 4 {
|
||
t.Fatalf("tools = %#v", request["tools"])
|
||
}
|
||
encoded, _ := json.Marshal(request)
|
||
if strings.Contains(string(encoded), "merchantId") || strings.Contains(string(encoded), "payer") {
|
||
t.Fatalf("sensitive field in request: %s", encoded)
|
||
}
|
||
}
|
||
|
||
type testTools struct {
|
||
calls int
|
||
evidenceID string
|
||
}
|
||
|
||
func (t *testTools) Execute(_ context.Context, name string, arguments json.RawMessage) (json.RawMessage, error) {
|
||
var args struct {
|
||
EvidenceID string `json:"evidenceId"`
|
||
}
|
||
if err := json.Unmarshal(arguments, &args); err != nil {
|
||
return nil, err
|
||
}
|
||
t.calls++
|
||
t.evidenceID = args.EvidenceID
|
||
return json.RawMessage(`{"state":"READY"}`), nil
|
||
}
|
||
|
||
func TestAvalAIClientExecutesSequentialToolsThenReturnsFinalCopy(t *testing.T) {
|
||
t.Parallel()
|
||
turn := 0
|
||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
turn++
|
||
w.Header().Set("Content-Type", "application/json")
|
||
switch turn {
|
||
case 1:
|
||
_, _ = w.Write([]byte(`{"status":"completed","output":[{"type":"function_call","call_id":"call_1","name":"get_growth_context","arguments":"{}"}],"usage":{"input_tokens":2,"output_tokens":1}}`))
|
||
return
|
||
case 2:
|
||
_, _ = w.Write([]byte(`{"status":"completed","output":[{"type":"function_call","call_id":"call_2","name":"get_event_evidence","arguments":"{\"evidenceId\":\"event_teacher\"}"}],"usage":{"input_tokens":3,"output_tokens":2}}`))
|
||
return
|
||
}
|
||
_, _ = w.Write([]byte(`{"status":"completed","output":[{"type":"message","content":[{"type":"output_text","text":"{\"eventRecommendations\":[{\"evidenceId\":\"event_teacher\",\"priorityPercent\":40,\"stance\":\"PROMOTE\",\"relevanceReason\":\"این مناسبت با الگوی فروش کسبوکار همخوان است.\",\"insightBasis\":\"BUSINESS_PROFILE\"},{\"evidenceId\":\"event_yalda\",\"priorityPercent\":35,\"stance\":\"PREPARE\",\"relevanceReason\":\"این رویداد با کانال فروش شما مرتبط است.\",\"insightBasis\":\"BUSINESS_PROFILE\"},{\"evidenceId\":\"event_nowruz\",\"priorityPercent\":25,\"stance\":\"CAUTION\",\"relevanceReason\":\"این بازه برای برنامهریزی عرضه مناسب است.\",\"insightBasis\":\"BUSINESS_PROFILE\"}]}"}]}],"usage":{"input_tokens":5,"output_tokens":4}}`))
|
||
}))
|
||
defer server.Close()
|
||
executor := &testTools{}
|
||
client := NewAvalAIClient(AvalAIConfig{BaseURL: server.URL, APIKey: "key", Model: "model", Timeout: time.Second}, server.Client())
|
||
_, usage, err := client.GenerateWithTools(context.Background(), json.RawMessage(`{}`), profileKnownEvidence(), executor)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if executor.calls != 2 || executor.evidenceID != "event_teacher" || turn != 3 || usage.InputTokens != 10 || usage.OutputTokens != 7 {
|
||
t.Fatalf("calls=%d evidenceID=%q turns=%d usage=%+v", executor.calls, executor.evidenceID, turn, usage)
|
||
}
|
||
}
|
||
|
||
func TestAvalAIClientRejectsToolCallsBeyondBoundedLimit(t *testing.T) {
|
||
t.Parallel()
|
||
turn := 0
|
||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||
turn++
|
||
w.Header().Set("Content-Type", "application/json")
|
||
_, _ = fmt.Fprintf(w, `{"status":"completed","output":[{"type":"function_call","call_id":"call_%d","name":"get_growth_context","arguments":"{}"}],"usage":{"input_tokens":1,"output_tokens":1}}`, turn)
|
||
}))
|
||
defer server.Close()
|
||
executor := &testTools{}
|
||
client := NewAvalAIClient(AvalAIConfig{BaseURL: server.URL, APIKey: "key", Model: "model", Timeout: time.Second}, server.Client())
|
||
|
||
_, usage, err := client.GenerateWithTools(context.Background(), json.RawMessage(`{}`), profileKnownEvidence(), executor)
|
||
if err == nil || !strings.Contains(err.Error(), "tool call limit exceeded") {
|
||
t.Fatalf("GenerateWithTools() error = %v", err)
|
||
}
|
||
if executor.calls != 4 || turn != 5 || usage.ProviderRequests != 5 {
|
||
t.Fatalf("calls=%d turns=%d usage=%+v", executor.calls, turn, usage)
|
||
}
|
||
}
|
||
|
||
func validRankedCopy() AICopy {
|
||
return AICopy{EventRecommendations: []EventRecommendation{
|
||
{EvidenceID: "event_teacher", PriorityPercent: 40, Stance: StancePromote, RelevanceReason: "این مناسبت با الگوی فروش کسبوکار همخوان است.", InsightBasis: InsightBasisBusinessProfile},
|
||
{EvidenceID: "event_yalda", PriorityPercent: 35, Stance: StancePrepare, RelevanceReason: "این رویداد با کانال فروش شما مرتبط است.", InsightBasis: InsightBasisBusinessProfile},
|
||
{EvidenceID: "event_nowruz", PriorityPercent: 25, Stance: StanceCaution, RelevanceReason: "این بازه برای برنامهریزی عرضه مناسب است.", InsightBasis: InsightBasisBusinessProfile},
|
||
}}
|
||
}
|
||
|
||
func mutateRecommendation(copy AICopy, index int, mutate func(*EventRecommendation)) AICopy {
|
||
copy.EventRecommendations = append([]EventRecommendation(nil), copy.EventRecommendations...)
|
||
mutate(©.EventRecommendations[index])
|
||
return copy
|
||
}
|
||
|
||
func profileKnownEvidence() KnownEvidence {
|
||
return KnownEvidence{"event_teacher": {InsightBasis: InsightBasisBusinessProfile, Promotable: true}, "event_yalda": {InsightBasis: InsightBasisBusinessProfile, Promotable: true}, "event_nowruz": {InsightBasis: InsightBasisBusinessProfile, Promotable: true}}
|
||
}
|