package processing import ( "context" "fmt" "strings" "testing" "github.com/descrybe/descrybe-v2/apps/api/internal/eprel" ) type stubCompleter struct { fn func(system, user string) (Completion, error) } func (s stubCompleter) Complete(_ context.Context, system, user string) (Completion, error) { return s.fn(system, user) } func TestResolveSteps(t *testing.T) { cases := map[string][]string{ "full": {StepNormalize, StepParseSpecs, StepFillFields, StepEPREL, StepCategorize, StepAIEnhance}, "enhance_only": {StepNormalize, StepParseSpecs, StepFillFields, StepCategorize, StepAIEnhance}, "title": {StepNormalize, StepParseSpecs, StepFillFields, StepCategorize, StepAIEnhance}, "description": {StepNormalize, StepParseSpecs, StepFillFields, StepCategorize, StepAIEnhance}, "attributes_only": {StepNormalize, StepParseSpecs, StepFillFields}, "eprel_only": {StepNormalize, StepParseSpecs, StepEPREL}, "normalize_only": {StepNormalize}, "categorize": {StepNormalize, StepParseSpecs, StepFillFields, StepEPREL, StepCategorize}, "categorize_only": {StepNormalize, StepParseSpecs, StepFillFields, StepEPREL, StepCategorize}, "categorize_enhance": {StepNormalize, StepParseSpecs, StepFillFields, StepEPREL, StepCategorize, StepAIEnhance}, } for in, want := range cases { got := resolveSteps(in) if strings.Join(got, ",") != strings.Join(want, ",") { t.Fatalf("%s: got %v want %v", in, got, want) } } } func TestRunSteps_fullMock(t *testing.T) { e := &Engine{ Completer: stubCompleter{fn: func(system, user string) (Completion, error) { return Completion{Text: `{"name":"Red Runner","description":"A fine shoe."}`, TotalTokens: 7, Raw: map[string]any{"ok": true}}, nil }}, Vector: NoopVectorCategorizer{}, EPREL: nil, } out, err := e.RunSteps(context.Background(), "co", ProductInput{ GTIN: "123", Name: "Runner", Description: "shoe", Mapped: map[string]any{"brand": "Acme"}, }, "full", nil, StepPolicy{AllowAI: true, AllowEPREL: true}) if err != nil { t.Fatal(err) } if out.ProcessedName != "Red Runner" { t.Fatalf("name=%q", out.ProcessedName) } if out.TotalTokens < 1 { t.Fatalf("tokens=%d", out.TotalTokens) } } func TestRunSteps_enhanceOnly(t *testing.T) { calls := 0 e := &Engine{ Completer: stubCompleter{fn: func(system, user string) (Completion, error) { calls++ return Completion{Text: `{"name":"N","description":"D"}`, TotalTokens: 1}, nil }}, Vector: NoopVectorCategorizer{}, } _, err := e.RunSteps(context.Background(), "co", ProductInput{Name: "x"}, "enhance_only", nil, StepPolicy{AllowAI: true, AllowEPREL: true}) if err != nil { t.Fatal(err) } if calls != 1 { t.Fatalf("calls=%d", calls) } } func TestRunSteps_enhanceOnly_keepsPriorCategoryWhenMappedEmpty(t *testing.T) { e := &Engine{ Completer: stubCompleter{fn: func(system, user string) (Completion, error) { return Completion{Text: `{"name":"N","description":"D"}`, TotalTokens: 1}, nil }}, Vector: NoopVectorCategorizer{}, } out, err := e.RunSteps(context.Background(), "co", ProductInput{ GTIN: "5905575903198", Name: "Adler", Mapped: map[string]any{"name": "Adler", "description": "radiator"}, PriorCategory: "Radiators", }, "enhance_only", nil, StepPolicy{AllowAI: true, AllowEPREL: true}) if err != nil { t.Fatal(err) } if out.Category != "Radiators" { t.Fatalf("category=%q want Radiators", out.Category) } if src, _ := out.FieldSources["category"].(string); src != "prior_processed" { t.Fatalf("field_sources.category=%v", out.FieldSources["category"]) } } func TestHeuristicCompleter_usesNameNotCategoryLine(t *testing.T) { h := HeuristicCompleter{} user := ProductEnhanceUser("120", "Adler LED radiator", "Bathroom heater", map[string]any{"brand": "ADLER"}) comp, err := h.Complete(context.Background(), `Return JSON with "name" and "description".`, user) if err != nil { t.Fatal(err) } obj, err := ParseJSONObject(comp.Text) if err != nil { t.Fatal(err) } name := SanitizeOutput(fmt.Sprint(obj["name"])) if isPromptLabelTitle(name) || strings.HasPrefix(strings.ToLower(name), "category:") { t.Fatalf("heuristic echoed prompt label as title: %q", name) } if !strings.Contains(strings.ToLower(name), "adler") { t.Fatalf("name=%q want Adler from Name: line", name) } } func TestEnhance_rejectsPromptLabelTitle(t *testing.T) { e := &Engine{ Completer: stubCompleter{fn: func(system, user string) (Completion, error) { return Completion{Text: `{"name":"Category:","description":"ok"}`, TotalTokens: 2}, nil }}, Vector: NoopVectorCategorizer{}, } out, err := e.RunSteps(context.Background(), "co", ProductInput{ GTIN: "1", Name: "Good Title", Mapped: map[string]any{"name": "Good Title", "description": "d", "category": "demo-electronics"}, }, "enhance_only", nil, StepPolicy{AllowAI: true}) if err != nil { t.Fatal(err) } if out.ProcessedName != "Good Title" { t.Fatalf("ProcessedName=%q want Good Title (reject Category:)", out.ProcessedName) } if out.Name == "Category:" || isPromptLabelTitle(out.Name) { t.Fatalf("Name polluted: %q", out.Name) } if out.Category != "demo-electronics" { t.Fatalf("category=%q", out.Category) } } func TestRunSteps_enhanceOnly_keepsMappedTitleWhenAIReturnsCategoryLabel(t *testing.T) { // A1 Elkotex Adler shape: title in mapped_data, not name. e := &Engine{ Completer: stubCompleter{fn: func(system, user string) (Completion, error) { return Completion{Text: `{"name":"Category:","description":"Category: 120"}`, TotalTokens: 2}, nil }}, Vector: NoopVectorCategorizer{}, } out, err := e.RunSteps(context.Background(), "co", ProductInput{ GTIN: "5905575903198", Mapped: map[string]any{ "title": "Adler LED kopalniski radiator lestev 600W AD7824", "description": "Bathroom radiator", "category": "120", }, PriorCategory: "120", PriorProcessedName: "Category:", }, "enhance_only", nil, StepPolicy{AllowAI: true}) if err != nil { t.Fatal(err) } if isPromptLabelTitle(out.ProcessedName) || out.ProcessedName == "Category:" { t.Fatalf("ProcessedName=%q", out.ProcessedName) } if !strings.Contains(out.ProcessedName, "Adler") { t.Fatalf("ProcessedName=%q want mapped title", out.ProcessedName) } if out.Category != "120" { t.Fatalf("category=%q", out.Category) } } func TestPreferredProductTitle_skipsPromptLabels(t *testing.T) { got := preferredProductTitle("99", "Category:", "Category: 120", "Name:", "Real Product") if got != "Real Product" { t.Fatalf("got %q", got) } got = preferredProductTitle("99", "Category:", "") if got != "Product 99" { t.Fatalf("fallback got %q", got) } } func TestPreferredProductTitle_skipsFormulaLeakage(t *testing.T) { leak := "short retail title; follow any Title formula constraints that follow; use Attrs" got := preferredProductTitle("1", leak, "follow any Title formula constraints that follow; use Attrs", "Vox SWA-8000W") if got != "Vox SWA-8000W" { t.Fatalf("got %q want feed title", got) } got = preferredProductTitle("", leak, "Schema: {\"name\":\"string\"}", "Reply with ONLY JSON") if got != "Product" { t.Fatalf("all-leakage fallback got %q", got) } } func TestPreferredProductTitle_skipsBrandOnlyWhenFullNameExists(t *testing.T) { got := preferredProductTitle("1", "ANKER", "Anker Soundcore Space One Pro") if got != "Anker Soundcore Space One Pro" { t.Fatalf("got %q want full product name", got) } got = preferredProductTitle("1", "Anker", "Anker Soundcore Space One Pro Wireless") if got != "Anker Soundcore Space One Pro Wireless" { t.Fatalf("got %q", got) } // Brand alone when no longer candidate. got = preferredProductTitle("1", "ANKER") if got != "ANKER" { t.Fatalf("brand-only alone got %q", got) } } func TestIsPromptLabelTitle(t *testing.T) { cases := map[string]bool{ "Category:": true, "category: 120": true, "Category : 46": true, "Name:": true, "Desc:": true, "Adler LED": false, "": false, } for in, want := range cases { if got := isPromptLabelTitle(in); got != want { t.Fatalf("%q: got %v want %v", in, got, want) } } } func TestIsPromptLeakageTitle(t *testing.T) { cases := map[string]bool{ "follow any Title formula constraints that follow; use Attrs": true, "short retail title; follow any Title formula constraints…": true, "write name in {{language}}": true, "Schema: {\"name\":\"string\",\"description\":\"string\"}": true, "Reply with ONLY JSON (no markdown)": true, "Emit description as ONE HTML string covering each section in order": true, "Description formula (REQUIRED — overrides any shorter \"1-2 sentences\" rule)": true, "when a Description formula follows, emit ONE HTML string": true, "never copy name as description": true, "Vox SWA-8000W dishwasher": false, "Acme Widget Pro": false, "": false, } for in, want := range cases { if got := isPromptLeakageTitle(in); got != want { t.Fatalf("%q: leakage got %v want %v", in, got, want) } if want && !isPromptLabelTitle(in) { t.Fatalf("%q: isPromptLabelTitle should include leakage", in) } } // Long dump with formula keyword (>120 runes). long := strings.Repeat("x", 100) + " Title formula scaffolding " + strings.Repeat("y", 30) if !isPromptLeakageTitle(long) { t.Fatalf("long formula dump should leak: len=%d", len([]rune(long))) } } func TestIsUnusableCategoryValue_rejectsNameAndFormula(t *testing.T) { t.Parallel() title := "NOSILEC W53070 81-140CM 180ST VOGELS" if !isUnusableCategoryValue(title, title) { t.Fatal("product title must not be usable as category") } if !isUnusableCategoryValue("Title formula (order matters; join with \" \")", title) { t.Fatal("title formula must not be usable as category") } if !isUnusableCategoryValue("Emit description as ONE HTML string covering each section", title) { t.Fatal("description formula must not be usable as category") } if !isUnusableCategoryValue("Category:", title) { t.Fatal("prompt label must not be usable as category") } if isUnusableCategoryValue("28", title) { t.Fatal("taxonomy unique_id must remain usable") } if isUnusableCategoryValue("TV mounts", title) { t.Fatal("real category display name must remain usable") } } func TestScrubCategoryPollution_clearsNameAsCategory(t *testing.T) { t.Parallel() title := "Vogel's WALL 3245 TV Wall Mount" out := StepResult{ Name: title, ProcessedName: title, Category: title, CategoryName: title, FieldSources: map[string]any{"category": "vector"}, } scrubCategoryPollution(&out, nil, nil) if out.Category != "" || out.CategoryName != "" { t.Fatalf("expected category cleared, got Category=%q CategoryName=%q", out.Category, out.CategoryName) } if src, _ := out.FieldSources["category"].(string); src != "cleared_invalid" { t.Fatalf("field_sources.category=%v want cleared_invalid", out.FieldSources["category"]) } } func TestEnhance_rejectsDescriptionFormulaLeakageTitle(t *testing.T) { leak := "Emit description as ONE HTML string covering each section in order in Slovenian" e := &Engine{ Completer: stubCompleter{fn: func(system, user string) (Completion, error) { return Completion{Text: `{"name":"` + leak + `","description":"A solid washer."}`, TotalTokens: 2}, nil }}, Vector: NoopVectorCategorizer{}, } out, err := e.RunSteps(context.Background(), "co", ProductInput{ GTIN: "8712285326882", Name: "Vox SWA-8000W", Mapped: map[string]any{"name": "Vox SWA-8000W", "description": "Washer", "category": "demo-electronics"}, }, "enhance_only", nil, StepPolicy{AllowAI: true}) if err != nil { t.Fatal(err) } if out.ProcessedName != "Vox SWA-8000W" { t.Fatalf("ProcessedName=%q want Vox SWA-8000W (reject description formula leakage)", out.ProcessedName) } } func TestRunSteps_rejectsProductNameAsCategory(t *testing.T) { title := "NOSILEC W53070 81-140CM 180ST VOGELS" e := &Engine{ Completer: stubCompleter{fn: func(system, user string) (Completion, error) { return Completion{Text: `{"name":"` + title + `","description":"TV wall mount for large screens."}`, TotalTokens: 2}, nil }}, Vector: NoopVectorCategorizer{}, } out, err := e.RunSteps(context.Background(), "co", ProductInput{ GTIN: "8712285326882", Name: title, PriorCategory: title, Mapped: map[string]any{"name": title, "description": title, "category": title}, }, "enhance_only", nil, StepPolicy{AllowAI: true}) if err != nil { t.Fatal(err) } if out.Category == title || strings.EqualFold(out.Category, out.ProcessedName) { t.Fatalf("Category=%q must not equal product title", out.Category) } if categoryDisplayLabel(out) == title { t.Fatalf("display category leaked product title: %q", categoryDisplayLabel(out)) } } func TestRunSteps_rejectsFormulaAsCategory(t *testing.T) { leak := "Title formula (order matters; join with \" \"). Build name from Attrs" e := &Engine{ Completer: stubCompleter{fn: func(system, user string) (Completion, error) { return Completion{Text: `{"name":"Vox SWA-8000W","description":"Washer."}`, TotalTokens: 2}, nil }}, Vector: NoopVectorCategorizer{}, } out, err := e.RunSteps(context.Background(), "co", ProductInput{ GTIN: "8712285326882", Name: "Vox SWA-8000W", PriorCategory: leak, Mapped: map[string]any{"name": "Vox SWA-8000W", "description": "Washer", "category": leak}, }, "enhance_only", nil, StepPolicy{AllowAI: true}) if err != nil { t.Fatal(err) } if out.Category != "" { t.Fatalf("Category=%q want empty (formula rejected)", out.Category) } if categoryDisplayLabel(out) != "" { t.Fatalf("display=%q want empty", categoryDisplayLabel(out)) } } func TestEnhance_rejectsFormulaLeakageTitle(t *testing.T) { leak := "short retail title; follow any Title formula constraints that follow; use Attrs" e := &Engine{ Completer: stubCompleter{fn: func(system, user string) (Completion, error) { return Completion{Text: `{"name":"` + leak + `","description":"A solid washer."}`, TotalTokens: 2}, nil }}, Vector: NoopVectorCategorizer{}, } out, err := e.RunSteps(context.Background(), "co", ProductInput{ GTIN: "8712285326882", Name: "Vox SWA-8000W", Mapped: map[string]any{"name": "Vox SWA-8000W", "description": "Washer", "category": "demo-electronics"}, }, "enhance_only", nil, StepPolicy{AllowAI: true}) if err != nil { t.Fatal(err) } if out.ProcessedName != "Vox SWA-8000W" { t.Fatalf("ProcessedName=%q want Vox SWA-8000W (reject formula leakage)", out.ProcessedName) } if isPromptLeakageTitle(out.Name) || isPromptLabelTitle(out.Name) { t.Fatalf("Name polluted: %q", out.Name) } } func TestHeuristicCompleter_keepsNameWhenFormulaConstraintsPresent(t *testing.T) { h := HeuristicCompleter{} // Shape matches aiprompts.CategoryEnhanceUserTemplate: instruction "- name:" // appears BEFORE the real "Name:" field — labeledPromptValue must skip leakage. user := `Your reply is parsed as JSON {"name":"string","description":"string","meta_title":"string","meta_description":"string"} only (system schema). Write all fields in English (do not hardcode a language). - name: short retail title; follow any Title formula constraints that follow; use Attrs - description: prefer 1-3 factual paragraphs as ONE string Category: demo-electronics Name: Vox SWA-8000W Description: Washer Attrs: {"brand":"Vox"} Title formula (order matters; join with " "). Build name from Attrs using this structure: 1. attr [brand] Prefer Attrs values for [attr] slots; write name in English.` comp, err := h.Complete(context.Background(), `Return JSON with "name" and "description".`, user) if err != nil { t.Fatal(err) } obj, err := ParseJSONObject(comp.Text) if err != nil { t.Fatal(err) } name := SanitizeOutput(fmt.Sprint(obj["name"])) if isPromptLeakageTitle(name) || isPromptLabelTitle(name) { t.Fatalf("heuristic leaked formula into name: %q", name) } if !strings.Contains(strings.ToLower(name), "vox") { t.Fatalf("name=%q want Vox from Name: line", name) } } func TestLabeledPromptValue_skipsInstructionNameBullet(t *testing.T) { user := `- name: short retail title; follow any Title formula constraints that follow; use Attrs Name: Vox SWA-8000W Desc: Washer Attrs: {}` got := labeledPromptValue(user, "name:", "current name:") if got != "Vox SWA-8000W" { t.Fatalf("got %q want Vox SWA-8000W", got) } } func TestRunSteps_full_mappedCategoryWinsOverPrior(t *testing.T) { e := &Engine{ Completer: stubCompleter{fn: func(system, user string) (Completion, error) { return Completion{Text: `{"name":"N","description":"D"}`, TotalTokens: 1}, nil }}, Vector: NoopVectorCategorizer{}, } out, err := e.RunSteps(context.Background(), "co", ProductInput{ GTIN: "6970995789942", Mapped: map[string]any{ "name": "Roborock", "description": "vacuum", "category": "Robot Vacuums", }, PriorCategory: "Old Category", }, "full", nil, StepPolicy{AllowAI: true, AllowEPREL: false}) if err != nil { t.Fatal(err) } if out.Category != "Robot Vacuums" { t.Fatalf("category=%q want Robot Vacuums", out.Category) } } func TestRunSteps_normalizeOnly_keepsPriorWhenMappedMissing(t *testing.T) { e := &Engine{Vector: NoopVectorCategorizer{}} out, err := e.RunSteps(context.Background(), "co", ProductInput{ Mapped: map[string]any{"name": "x"}, PriorCategory: "FromProcessed", }, "normalize_only", nil, StepPolicy{}) if err != nil { t.Fatal(err) } if out.Category != "FromProcessed" { t.Fatalf("category=%q", out.Category) } } func TestRunSteps_skipsEPRELWithoutEntitlement(t *testing.T) { st := &stubEprel{enabled: true, data: &eprel.Data{ID: "1", EnergyClass: "A"}} e := &Engine{EPREL: st} out, err := e.RunSteps(context.Background(), "co", ProductInput{ Raw: map[string]any{"EPRELID": "1"}, }, "eprel_only", nil, StepPolicy{AllowEPREL: false}) if err != nil { t.Fatal(err) } if st.calls != 0 { t.Fatal("EPREL should not run without entitlement") } if len(out.Notes) == 0 { t.Fatal("expected skip note") } }