Files
greeneclipse 8580c996c3 Initial commit of Descrybe v2 without local scratch artifacts.
Drop one-shot tmp/axe scripts and agent i18n scratch so the Gitea tree is deployable.
2026-08-09 22:47:43 +02:00

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>`), "&amp;") {
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)
}
}