package feeds import ( "bytes" "errors" "strings" "testing" ) func TestValidPublicToken(t *testing.T) { if validPublicToken("../etc/passwd") { t.Fatal("path traversal token must be rejected") } if validPublicToken("short") { t.Fatal("short token must be rejected") } if validPublicToken("0123456789abcdef") { // 16 hex / 64-bit — below floor t.Fatal("undersized token must be rejected") } if validPublicToken("0123456789abcdef0123456789abcde") { // odd length t.Fatal("odd-length hex must be rejected") } if !validPublicToken("0123456789abcdef0123456789abcdef") { t.Fatal("32-hex token should be accepted") } tok, err := newPublicExportToken() if err != nil { t.Fatalf("newPublicExportToken: %v", err) } if len(tok) != 64 { t.Fatalf("expected 64 hex chars, got %d", len(tok)) } if !validPublicToken(tok) { t.Fatal("fresh public export token should be accepted") } tok2, err := newPublicExportToken() if err != nil { t.Fatalf("newPublicExportToken second: %v", err) } if tok == tok2 { t.Fatal("rotated/fresh tokens must differ (CSPRNG collision)") } } func TestNewPublicExportTokenIs256BitHex(t *testing.T) { t.Parallel() for i := 0; i < 8; i++ { tok, err := newPublicExportToken() if err != nil { t.Fatalf("newPublicExportToken: %v", err) } if len(tok) != publicExportTokenBytes*2 { t.Fatalf("len=%d want %d", len(tok), publicExportTokenBytes*2) } if !validPublicToken(tok) { t.Fatalf("token %q rejected by validPublicToken", tok) } } } func TestSanitizeXMLName(t *testing.T) { if got := sanitizeXMLName("prod uct!", "product"); got != "prod_uct_" { t.Fatalf("got %q", got) } if got := sanitizeXMLName("", "product"); got != "product" { t.Fatalf("empty fallback got %q", got) } if got := sanitizeXMLName("g:id", "field"); got != "g:id" { t.Fatalf("namespace colon got %q", got) } if got := sanitizeXMLName("g:title", "field"); got != "g:title" { t.Fatalf("g:title got %q", got) } } func TestParseExportTemplateDefaults(t *testing.T) { tpl := parseExportTemplate([]byte("{}")) if tpl.Root != defaultExportRoot || tpl.Item != defaultExportItem { t.Fatalf("unexpected defaults %#v", tpl) } if len(tpl.Fields) < 3 { t.Fatal("expected default fields") } } func TestParseExportTemplateMappingsSorted(t *testing.T) { tpl := parseExportTemplate([]byte(`{"mappings":{"z":"status","a":"name"}}`)) if len(tpl.Fields) != 2 { t.Fatalf("fields=%d", len(tpl.Fields)) } if tpl.Fields[0].Key != "a" || tpl.Fields[1].Key != "z" { t.Fatalf("unsorted keys: %#v", tpl.Fields) } } func TestProductFieldValuePrefersProcessed(t *testing.T) { name := "Widget" processed := "Widget Pro" p := exportProduct{Name: &name, ProcessedName: &processed, Attributes: []byte(`{"color":"red"}`)} if got := productFieldValue(p, "name"); got != "Widget Pro" { t.Fatalf("name=%q", got) } if got := productFieldValue(p, "attr.color"); got != "red" { t.Fatalf("attr=%q", got) } if !strings.Contains(xmlEscape(`a&b`), "&") { t.Fatal("xmlEscape broken") } } func TestSanitizeExportFileName(t *testing.T) { if got := sanitizeExportFileName("My Feed!"); got != "my_feed" { t.Fatalf("got %q", got) } if got := sanitizeExportFileName(""); got != "export" { t.Fatalf("empty got %q", got) } } func TestScalarAttrStringStructuredAndNull(t *testing.T) { if got := scalarAttrString(nil); got != "" { t.Fatalf("nil=%q", got) } if got := scalarAttrString(map[string]any{"key": "oblika-zaslona-2", "name": "ukrivljen"}); got != "ukrivljen" { t.Fatalf("name object=%q", got) } if got := scalarAttrString(map[string]any{"key": "brand", "value": "CoolCo"}); got != "CoolCo" { t.Fatalf("value object=%q", got) } out := map[string]string{} putAttrValue(out, "barva", nil) putAttrValue(out, "oblika-zaslona", map[string]any{"key": "oblika-zaslona-2", "name": "ukrivljen"}) if _, ok := out["barva"]; ok { t.Fatal("nil attr should be skipped") } if out["oblika-zaslona"] != "ukrivljen" { t.Fatalf("oblika=%q", out["oblika-zaslona"]) } } func sampleProcessedProduct() exportProduct { pid := "4897098683545" name := "Fridge X" procName := "Fridge X Energy" status := "processed" return exportProduct{ ProductID: &pid, Name: &name, ProcessedName: &procName, Status: &status, Attributes: []byte(`{"color":"silver"}`), ProcessedAttributes: []byte(`{ "eprel_id": "246834", "brand": {"key":"brand","name":"CoolCo","value":"CoolCo"}, "specifications": [ {"key":"battery_life","value":"30 hours"}, {"key":"weight","value":"250g"} ], "eprel": { "energy_class": "E", "energy_scale": "A-G", "label": "https://eprel.ec.europa.eu/api/product/246834/labels?format=png", "pdf": "https://eprel.ec.europa.eu/fiches/example.pdf" } }`), MappedData: []byte(`{"eprel_id":"should-not-win"}`), } } func TestFlattenSpecificationsAndEprel(t *testing.T) { p := sampleProcessedProduct() if got := productFieldValue(p, "eprel_id"); got != "246834" { t.Fatalf("eprel_id=%q", got) } if got := productFieldValue(p, "energy_class"); got != "E" { t.Fatalf("energy_class=%q", got) } if got := productFieldValue(p, "eprel_energy_class"); got != "E" { t.Fatalf("eprel_energy_class=%q", got) } if got := productFieldValue(p, "eprel_label"); !strings.Contains(got, "eprel.ec.europa.eu") { t.Fatalf("eprel_label=%q", got) } if got := productFieldValue(p, "spec.battery_life"); got != "30 hours" { t.Fatalf("spec.battery_life=%q", got) } if got := productFieldValue(p, "attr.weight"); got != "250g" { t.Fatalf("attr.weight=%q", got) } if got := productFieldValue(p, "attr.brand"); got != "CoolCo" { t.Fatalf("brand=%q", got) } specs := productFieldValue(p, "specifications") if !strings.Contains(specs, "battery_life: 30 hours") || !strings.Contains(specs, "weight: 250g") { t.Fatalf("specifications=%q", specs) } } func TestFlattenSpecificationsObject(t *testing.T) { p := exportProduct{ ProcessedAttributes: []byte(`{"specifications":{"color":"red","size":"L"}}`), } if got := productFieldValue(p, "spec.color"); got != "red" { t.Fatalf("got %q", got) } if got := productFieldValue(p, "specifications.size"); got != "L" { t.Fatalf("got %q", got) } } func TestExportXMLSnippetWithSpecsAndEprel(t *testing.T) { tpl := exportTemplate{ Root: "products", Item: "product", Fields: []exportField{ {Key: "product_id", Source: "product_id"}, {Key: "name", Source: "name"}, {Key: "eprel_id", Source: "eprel_id"}, {Key: "energy_class", Source: "energy_class"}, {Key: "eprel_label", Source: "eprel_label"}, {Key: "specs", Source: "specifications.*"}, }, } out, n, err := renderExportSnippet("xml", tpl, []exportProduct{sampleProcessedProduct()}, "Demo Feed") if err != nil { t.Fatal(err) } if n != 1 { t.Fatalf("count=%d", n) } for _, want := range []string{ ``, ``, `4897098683545`, `Fridge X Energy`, `246834`, `E`, `30 hours`, `250g`, ``, ``, } { if !strings.Contains(out, want) { t.Fatalf("missing %q in:\n%s", want, out) } } } func TestExportCSVSnippetWithEprel(t *testing.T) { tpl := exportTemplate{ Fields: []exportField{ {Key: "product_id", Source: "product_id"}, {Key: "eprel_id", Source: "eprel_id"}, {Key: "energy_class", Source: "energy_class"}, {Key: "battery_life", Source: "spec.battery_life"}, }, } out, n, err := renderExportSnippet("csv", tpl, []exportProduct{sampleProcessedProduct()}, "") if err != nil { t.Fatal(err) } if n != 1 { t.Fatalf("count=%d", n) } lines := strings.Split(strings.TrimSpace(out), "\n") if len(lines) != 2 { t.Fatalf("lines=%v", lines) } if lines[0] != "product_id,eprel_id,energy_class,battery_life" { t.Fatalf("header=%q", lines[0]) } if lines[1] != "4897098683545,246834,E,30 hours" { t.Fatalf("row=%q", lines[1]) } } func TestProcessedAttributesWinOverMappedData(t *testing.T) { p := sampleProcessedProduct() if got := productFieldValue(p, "eprel_id"); got != "246834" { t.Fatalf("expected processed eprel_id, got %q", got) } } func TestStreamXMLUsesProductSeqWithoutCollecting(t *testing.T) { tpl := exportTemplate{ Root: "products", Item: "product", Fields: []exportField{ {Key: "product_id", Source: "product_id"}, {Key: "name", Source: "name"}, }, } yielded := 0 seq := exportProductSeq(func(yield func(exportProduct) error) error { for i := 0; i < 3; i++ { yielded++ if err := yield(sampleProcessedProduct()); err != nil { return err } } return nil }) var buf bytes.Buffer n, err := streamXML(&buf, seq, tpl, "Batch Feed") if err != nil { t.Fatal(err) } if n != 3 || yielded != 3 { t.Fatalf("count=%d yielded=%d", n, yielded) } out := buf.String() if !strings.Contains(out, ``) || !strings.Contains(out, "") { t.Fatalf("bad xml:\n%s", out) } if strings.Count(out, "") != 3 { t.Fatalf("expected 3 products, got:\n%s", out) } } func TestStreamCSVUsesProductSeqWithoutCollecting(t *testing.T) { tpl := exportTemplate{ Fields: []exportField{ {Key: "product_id", Source: "product_id"}, {Key: "name", Source: "name"}, }, } seq := exportProductSeq(func(yield func(exportProduct) error) error { return yield(sampleProcessedProduct()) }) var buf bytes.Buffer n, err := streamCSV(&buf, seq, tpl) if err != nil { t.Fatal(err) } if n != 1 { t.Fatalf("count=%d", n) } lines := strings.Split(strings.TrimSpace(buf.String()), "\n") if len(lines) != 2 { t.Fatalf("lines=%v", lines) } if lines[0] != "product_id,name" { t.Fatalf("header=%q", lines[0]) } if lines[1] != "4897098683545,Fridge X Energy" { t.Fatalf("row=%q", lines[1]) } } func TestExportBatchSizeBoundsMemoryPages(t *testing.T) { if exportBatchSize <= 0 || exportBatchSize > exportMaxProducts { t.Fatalf("exportBatchSize=%d exportMaxProducts=%d", exportBatchSize, exportMaxProducts) } if exportChunkHint <= 0 || exportChunkHint > exportBatchSize { t.Fatalf("exportChunkHint=%d should be positive and <= batch", exportChunkHint) } if exportSelectedMaxProducts <= 0 || exportSelectedMaxProducts > exportMaxProducts { t.Fatalf("exportSelectedMaxProducts=%d must be in (0, %d]", exportSelectedMaxProducts, exportMaxProducts) } } func TestStreamXMLPropagatesSeqError(t *testing.T) { tpl := exportTemplate{ Root: "products", Item: "product", Fields: []exportField{{Key: "name", Source: "name"}}, } seq := exportProductSeq(func(yield func(exportProduct) error) error { if err := yield(sampleProcessedProduct()); err != nil { return err } return errors.New("boom") }) var buf bytes.Buffer n, err := streamXML(&buf, seq, tpl, "x") if err == nil || err.Error() != "boom" { t.Fatalf("err=%v count=%d", err, n) } if n != 1 { t.Fatalf("expected 1 product written before error, got %d", n) } }