383 lines
11 KiB
Go
383 lines
11 KiB
Go
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<c>`), "&") {
|
||
|
|
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{
|
||
|
|
`<?xml version="1.0" encoding="UTF-8"?>`,
|
||
|
|
`<products feed="Demo Feed">`,
|
||
|
|
`<product_id>4897098683545</product_id>`,
|
||
|
|
`<name>Fridge X Energy</name>`,
|
||
|
|
`<eprel_id>246834</eprel_id>`,
|
||
|
|
`<energy_class>E</energy_class>`,
|
||
|
|
`<battery_life>30 hours</battery_life>`,
|
||
|
|
`<weight>250g</weight>`,
|
||
|
|
`</product>`,
|
||
|
|
`</products>`,
|
||
|
|
} {
|
||
|
|
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, `<products feed="Batch Feed">`) || !strings.Contains(out, "</products>") {
|
||
|
|
t.Fatalf("bad xml:\n%s", out)
|
||
|
|
}
|
||
|
|
if strings.Count(out, "<product>") != 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)
|
||
|
|
}
|
||
|
|
}
|