package processing import ( "context" "encoding/json" "strings" "testing" "time" "github.com/descrybe/descrybe-v2/apps/api/internal/eprel" ) type stubEprel struct { enabled bool data *eprel.Data err error calls int lastID string } func (s *stubEprel) Enabled() bool { return s.enabled } func (s *stubEprel) Fetch(_ context.Context, id string) (*eprel.Data, error) { s.calls++ s.lastID = id return s.data, s.err } func TestRunSteps_EPREL_mergesAttributes(t *testing.T) { st := &stubEprel{ enabled: true, data: &eprel.Data{ ID: "246834", Label: "https://eprel.ec.europa.eu/api/product/246834/labels?format=png", PDF: "https://eprel.ec.europa.eu/fiches/x.pdf", EnergyClass: "C", EnergyScale: "A-G", }, } e := &Engine{EPREL: st, Completer: HeuristicCompleter{}, Vector: NoopVectorCategorizer{}} out, err := e.RunSteps(context.Background(), "co", ProductInput{ Mapped: map[string]any{"name": "Fridge"}, Raw: map[string]any{"EPRELID": "246834"}, }, "eprel_only", nil, StepPolicy{AllowAI: true, AllowEPREL: true}) if err != nil { t.Fatal(err) } if st.calls != 1 || st.lastID != "246834" { t.Fatalf("calls=%d id=%q", st.calls, st.lastID) } if out.ProcessedAttributes["eprel_id"] != "246834" { t.Fatalf("attrs=%v", out.ProcessedAttributes) } if out.ProcessedAttributes["eprel_energy_class"] != "C" { t.Fatalf("class missing: %v", out.ProcessedAttributes) } if out.ProcessedAttributes["energy_class"] != "C" { t.Fatalf("energy_class not promoted: %v", out.ProcessedAttributes) } if out.EPREL["id"] != "246834" || out.EPREL["label"] == "" { t.Fatalf("out.EPREL=%v", out.EPREL) } } func TestRunSteps_EPREL_disabledOrMissingID(t *testing.T) { e := &Engine{EPREL: eprel.Disabled{}, Completer: HeuristicCompleter{}, Vector: NoopVectorCategorizer{}} out, err := e.RunSteps(context.Background(), "co", ProductInput{ Raw: map[string]any{"EPRELID": "1"}, }, "eprel_only", nil, StepPolicy{AllowAI: true, AllowEPREL: true}) if err != nil { t.Fatal(err) } // Disabled enricher still records the discovered id; it must not fetch remote data. if out.ProcessedAttributes["eprel_energy_class"] != nil { t.Fatal("should not fetch energy class when disabled") } st := &stubEprel{enabled: true} e.EPREL = st _, err = e.RunSteps(context.Background(), "co", ProductInput{}, "eprel_only", nil, StepPolicy{AllowAI: true, AllowEPREL: true}) if err != nil { t.Fatal(err) } if st.calls != 0 { t.Fatal("should not call fetch without id") } } func TestRunSteps_EPREL_readsIDFromParsedAttrs(t *testing.T) { st := &stubEprel{ enabled: true, data: &eprel.Data{ ID: "999001", Label: "https://eprel.ec.europa.eu/api/product/999001/labels?format=png", PDF: "https://eprel.ec.europa.eu/fiches/y.pdf", EnergyClass: "B", EnergyScale: "A-G", }, } e := &Engine{EPREL: st, Completer: HeuristicCompleter{}, Vector: NoopVectorCategorizer{}} out, err := e.RunSteps(context.Background(), "co", ProductInput{ Mapped: map[string]any{ "name": "Washer", "specifications": map[string]any{ "eprel_id": "999001", "brand": "Samsung", }, }, }, "eprel_only", nil, StepPolicy{AllowAI: false, AllowEPREL: true}) if err != nil { t.Fatal(err) } if st.calls != 1 || st.lastID != "999001" { t.Fatalf("calls=%d id=%q", st.calls, st.lastID) } if out.EPREL["id"] != "999001" || out.EPREL["energy_class"] != "B" { t.Fatalf("eprel=%v", out.EPREL) } if out.ProcessedAttributes["energy_class"] != "B" { t.Fatalf("energy_class not promoted: %v", out.ProcessedAttributes) } if out.ProcessedAttributes["eprel_id"] != "999001" { t.Fatalf("eprel_id missing: %v", out.ProcessedAttributes) } // Nested eprel object must survive sanitize/persist for V1 extract. nested, _ := out.ProcessedAttributes["eprel"].(map[string]any) if nested == nil || nested["label"] == nil { t.Fatalf("nested eprel missing: %v", out.ProcessedAttributes["eprel"]) } extracted := extractEPRELFromAttrs(out.ProcessedAttributes) em, ok := extracted.(map[string]any) if !ok || em["id"] != "999001" || em["label"] == nil || em["pdf"] == nil || em["energy_class"] != "B" { t.Fatalf("V1 extract=%v", extracted) } } func TestRunSteps_EPREL_skipsPlaceholderID(t *testing.T) { st := &stubEprel{enabled: true} e := &Engine{EPREL: st, Completer: HeuristicCompleter{}, Vector: NoopVectorCategorizer{}} _, err := e.RunSteps(context.Background(), "co", ProductInput{ Mapped: map[string]any{"eprel_id": "0000"}, }, "eprel_only", nil, StepPolicy{AllowEPREL: true}) if err != nil { t.Fatal(err) } if st.calls != 0 { t.Fatalf("placeholder id must not fetch, calls=%d", st.calls) } } func TestExtractEPRELFromAttrs_flatAndNested(t *testing.T) { got := extractEPRELFromAttrs(map[string]any{ "eprel_id": "1632113", "eprel_label": "https://eprel.ec.europa.eu/api/product/1632113/labels?format=png", "eprel_pdf_url": "https://eprel.ec.europa.eu/fiches/x.pdf", "eprel_energy_class": "A", "brand": "Samsung", }) m, ok := got.(map[string]any) if !ok { t.Fatalf("type=%T", got) } if m["id"] != "1632113" || m["label"] == nil || m["pdf"] == nil || m["energy_class"] != "A" { t.Fatalf("got=%v", m) } // Nested legacy aliases must fold onto public keys. got2 := extractEPRELFromAttrs(map[string]any{ "eprel": map[string]any{ "eprel_id": "999", "eprel_label": "https://eprel.example/label", "eprel_pdf": "https://eprel.example/pdf", "eprel_energy_class": "B", }, }) m2, ok := got2.(map[string]any) if !ok || m2["id"] != "999" || m2["energy_class"] != "B" || m2["label"] == nil || m2["pdf"] == nil { t.Fatalf("nested aliases: %v", got2) } // V1 poll must strip eprel_* from attributes after extract. clean := SanitizeV1ProcessAttributesAllowed(map[string]any{ "eprel_id": "1632113", "eprel_label": "https://eprel.example/label", "brand": "Samsung", "eprel_energy_class": "A", }, map[string]struct{}{}) if _, ok := clean["eprel_id"]; ok { t.Fatalf("eprel leaked into attributes: %v", clean) } if clean["brand"] != "Samsung" { t.Fatalf("brand missing: %v", clean) } } func TestRunSteps_EPREL_liveFetchIfNetwork(t *testing.T) { if testing.Short() { t.Skip("short") } client := eprel.NewClient(eprel.Options{Enabled: true, Timeout: 20 * time.Second}) if !client.Enabled() { t.Fatal("client disabled") } e := &Engine{EPREL: client, Completer: HeuristicCompleter{}, Vector: NoopVectorCategorizer{}} out, err := e.RunSteps(context.Background(), "co", ProductInput{ Mapped: map[string]any{ "name": "Samsung WW90CGC04DAELE", "eprel_id": "1632113", }, }, "eprel_only", nil, StepPolicy{AllowEPREL: true}) if err != nil { t.Fatal(err) } if out.EPREL["id"] != "1632113" { t.Fatalf("eprel=%v", out.EPREL) } if out.EPREL["label"] == nil || out.EPREL["label"] == "" { t.Fatalf("missing label: %v", out.EPREL) } if out.EPREL["pdf"] == nil || out.EPREL["pdf"] == "" { t.Fatalf("missing pdf: %v", out.EPREL) } if out.EPREL["energy_class"] == nil || out.EPREL["energy_class"] == "" { t.Fatalf("missing energy_class: %v", out.EPREL) } if out.ProcessedAttributes["eprel_id"] != "1632113" { t.Fatalf("attrs=%v", out.ProcessedAttributes) } eprelVal := extractEPRELFromAttrs(out.ProcessedAttributes) m, ok := eprelVal.(map[string]any) if !ok || m["id"] != "1632113" { t.Fatalf("extract=%v", eprelVal) } if m["label"] == nil || m["label"] == "" || m["pdf"] == nil || m["pdf"] == "" || m["energy_class"] == nil || m["energy_class"] == "" { t.Fatalf("extract missing fields: %v", m) } clean := SanitizeV1ProcessAttributesAllowed(out.ProcessedAttributes, map[string]struct{}{}) for k := range clean { if strings.HasPrefix(strings.ToLower(k), "eprel") { t.Fatalf("eprel leaked into V1 attributes: %v", clean) } } sample, _ := json.MarshalIndent(map[string]any{ "eprel": m, "attributes": clean, "step_eprel": out.EPREL, }, "", " ") t.Logf("sample eprel JSON:\n%s", sample) }