2026-08-09 22:47:43 +02:00
|
|
|
package processing
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"context"
|
2026-08-16 16:57:36 +02:00
|
|
|
"encoding/json"
|
|
|
|
|
"strings"
|
2026-08-09 22:47:43 +02:00
|
|
|
"testing"
|
2026-08-16 16:57:36 +02:00
|
|
|
"time"
|
2026-08-09 22:47:43 +02:00
|
|
|
|
|
|
|
|
"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)
|
|
|
|
|
}
|
2026-08-16 16:57:36 +02:00
|
|
|
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)
|
|
|
|
|
}
|
2026-08-09 22:47:43 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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")
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-08-16 16:57:36 +02:00
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
}
|