Files
platform/catalog/builder_test.go
T
2026-08-27 15:50:09 +02:00

292 lines
9.2 KiB
Go

package catalog
import (
"encoding/json"
"errors"
"strings"
"testing"
"time"
)
// json.Number from a json.Decoder running with UseNumber() is the
// canonical path for precision-preserving producer parsing. Without this
// case, BuildFromMap would log+skip every projection that came through
// UseNumber — silently losing items.
func TestBuilderJSONNumber(t *testing.T) {
// json.Number for SKU
src := map[string]any{
"sku": json.Number("123456789012345"), // float64-unsafe
"title": "BigNumber SKU",
"price_inc_vat": json.Number("14995"), // already øre
"currency": "SEK",
"tax_class": "standard",
"updated_at": json.Number("1700000000000"),
}
p, err := Apply(DefaultSchema(), src)
if err != nil {
t.Fatalf("json.Number source rejected: %v", err)
}
if p.SKU != "123456789012345" {
t.Errorf("SKU: got %q want %q", p.SKU, "123456789012345")
}
if p.PriceIncVat != 14995 {
t.Errorf("PriceIncVat: got %d want %d", p.PriceIncVat, 14995)
}
if p.UpdatedAt != 1700000000000 {
t.Errorf("UpdatedAt: got %d want %d", p.UpdatedAt, int64(1700000000000))
}
}
func TestBuilderAttributes(t *testing.T) {
s := DefaultSchema()
s.AttributesFields = []string{"cart", "color"}
src := map[string]any{
"sku": "X-1",
"price_inc_vat": int64(100),
"updated_at": int64(1700000000000),
"cart": map[string]any{"data": "some-cart-data"},
"color": "red",
"ignored": "should-not-be-in-attrs",
}
p, err := Apply(s, src)
if err != nil {
t.Fatalf("Apply: %v", err)
}
var m map[string]any
if err := json.Unmarshal(p.Attributes, &m); err != nil {
t.Fatalf("unmarshal Attributes: %v", err)
}
if len(m) != 2 {
t.Errorf("expected 2 attributes, got %d", len(m))
}
if cartVal, ok := m["cart"].(map[string]any); !ok || cartVal["data"] != "some-cart-data" {
t.Errorf("cart attribute: got %v", m["cart"])
}
if m["color"] != "red" {
t.Errorf("color attribute: got %v", m["color"])
}
if _, ok := m["ignored"]; ok {
t.Errorf("ignored attribute was extracted: %v", m)
}
}
// Updated=UseSourceField: missing source updated_at MUST be a loud error,
// not a stamp-now fallback. (Foot-gun 3 resolution: silence here invalidates
// every consumer's cache on rebuild.)
func TestBuilderRequiresUpdatedAt(t *testing.T) {
src := map[string]any{
"sku": "X-1",
"price_inc_vat": int64(100),
// no "updated_at" key intentionally
}
_, err := Apply(DefaultSchema(), src)
if err == nil {
t.Fatal("UseSourceField + missing updated_at should error, not stamp-now")
}
if !errors.Is(err, ErrProjectionBuild) {
t.Errorf("got %v want ErrProjectionBuild", err)
}
if !strings.Contains(err.Error(), "updated_at") {
t.Errorf("error should mention updated_at: %v", err)
}
}
// Same projection under StampAtPublish succeeds even without updated_at —
// that's the deliberate-fallback path. Used by sources with no other stamp.
// Bind the stamp to real wall-clock time (before ≤ actual ≤ after+slack)
// so the test distinguishes "stamp-now was called" from any other non-zero
// path.
func TestBuilderStampAtPublishFallback(t *testing.T) {
s := DefaultSchema()
s.UpdatedAtSource = StampAtPublish
s.UpdatedAtField = "" // ignored when source is StampAtPublish
before := time.Now().UnixMilli()
src := map[string]any{
"sku": "X-2",
"price_inc_vat": int64(100),
}
p, err := Apply(s, src)
if err != nil {
t.Fatalf("StampAtPublish should accept missing updated_at: %v", err)
}
after := time.Now().UnixMilli() + 1000 // generous slack for slow CI
if p.UpdatedAt < before || p.UpdatedAt > after {
t.Errorf("UpdatedAt should be stamp-now Unix-ms in [%d,%d], got %d", before, after, p.UpdatedAt)
}
}
// Fractional float price with scale=1 must be loud-fail — the float arm of
// coercePrice has the same foot-gun 4 risk as the string arm (silent 100x
// bias if a tenant forgets PriceScale=100 and ships a float literal).
func TestBuilderFractionalFloatLowScaleRejected(t *testing.T) {
src := map[string]any{
"sku": "F-1",
"price_inc_vat": 99.95, // float major-units at scale=1
"updated_at": int64(1700000000000),
}
_, err := Apply(DefaultSchema(), src)
if err == nil {
t.Fatal("fractional float price at scale=1 should error, not silently produce 100 øre")
}
if !errors.Is(err, ErrProjectionBuild) {
t.Errorf("got %v want ErrProjectionBuild", err)
}
if !strings.Contains(err.Error(), "PriceScale=100") {
t.Errorf("error message should mention PriceScale=100 hint: %v", err)
}
}
// json.Number("149.95") at scale=1 must reach the fractional guard via
// the json.Number branch — the precision-safe producer route.
func TestBuilderFractionalJSONNumberLowScaleRejected(t *testing.T) {
src := map[string]any{
"sku": "JN-1",
"price_inc_vat": json.Number("149.95"),
"updated_at": int64(1700000000000),
}
_, err := Apply(DefaultSchema(), src)
if err == nil {
t.Fatal("fractional json.Number at scale=1 should error, not silently produce 150 øre")
}
if !errors.Is(err, ErrProjectionBuild) {
t.Errorf("got %v want ErrProjectionBuild", err)
}
if !strings.Contains(err.Error(), "PriceScale=100") {
t.Errorf("error message should mention PriceScale=100 hint: %v", err)
}
}
// Fractional string price with scale=1 must be loud-fail (a misconfigured
// tenant shipping major units without PriceScale=100 — silent 100x bias).
func TestBuilderFractionalStringPriceLowScaleRejected(t *testing.T) {
src := map[string]any{
"sku": "X-1",
"price_inc_vat": "149.95", // major units FRACTION at scale=1
"updated_at": int64(1700000000000),
}
_, err := Apply(DefaultSchema(), src)
if err == nil {
t.Fatal("fractional string at scale=1 should error, not silently produce 150 øre")
}
if !errors.Is(err, ErrProjectionBuild) {
t.Errorf("got %v want ErrProjectionBuild", err)
}
if !strings.Contains(err.Error(), "PriceScale=100") {
t.Errorf("error message should mention PriceScale=100 hint: %v", err)
}
}
// Same string with scale=100 (correct major-unit config) succeeds.
func TestBuilderFractionalStringPriceCorrectScale(t *testing.T) {
s := DefaultSchema()
s.PriceScale = 100
src := map[string]any{
"sku": "X-2",
"price_inc_vat": "149.95",
"updated_at": int64(1700000000000),
}
p, err := Apply(s, src)
if err != nil {
t.Fatalf("scale=100 string price rejected: %v", err)
}
if p.PriceIncVat != 14995 {
t.Errorf("PriceIncVat: got %d want %d", p.PriceIncVat, 14995)
}
}
// Drop-ship routing carriers (docs/archive/plan-dropship.md Phase 1): seller id /
// name / lead time coerce from per-tenant source keys via Schema overrides,
// mirroring how the live feed emits supplierName-style keys. Zero values
// (missing keys, negative numbers, junk strings) must stay at the documented
// "unset" signals rather than erroring — one bad item can't poison a batch.
func TestBuilderDropshipCarriers(t *testing.T) {
s := DefaultSchema()
s.SellerIdField = "supplierId"
s.SellerNameField = "supplierName"
s.LeadTimeField = "deliveryTime"
p, err := Apply(s, map[string]any{
"sku": "X-1",
"price_inc_vat": int64(100_00),
"updated_at": int64(1700000000000),
"drop_ship": true,
"supplierId": 4711,
"supplierName": "Bygg Import AB",
"deliveryTime": 3,
})
if err != nil {
t.Fatalf("apply with dropship carriers: %v", err)
}
if !p.DropShip {
t.Error("DropShip: got false, want true")
}
if p.SellerID != 4711 {
t.Errorf("SellerID: got %d, want 4711", p.SellerID)
}
if p.SellerName != "Bygg Import AB" {
t.Errorf("SellerName: got %q", p.SellerName)
}
if p.LeadTimeDays != 3 {
t.Errorf("LeadTimeDays: got %d, want 3", p.LeadTimeDays)
}
// Missing keys → unset signals, no error.
p, err = Apply(s, map[string]any{
"sku": "X-2",
"price_inc_vat": int64(100_00),
"updated_at": int64(1700000000000),
})
if err != nil {
t.Fatalf("apply without dropship carriers: %v", err)
}
if p.DropShip || p.SellerID != 0 || p.SellerName != "" || p.LeadTimeDays != 0 {
t.Errorf("unset carriers should zero: %+v", p)
}
// Junk shapes floor to unset, never fail the build.
for name, bad := range map[string]any{
"negative id": -5,
"string id": "not-a-number",
"float id": 1.5,
} {
p, err = Apply(s, map[string]any{
"sku": "X-3", "price_inc_vat": int64(100_00),
"updated_at": int64(1700000000000),
"supplierId": bad,
})
if err != nil {
t.Fatalf("%s: apply should not fail on junk carrier: %v", name, err)
}
if p.SellerID != 0 {
t.Errorf("%s: SellerID should be 0, got %d", name, p.SellerID)
}
}
// Wire round-trip: new fields survive Encode/Decode for consumers that
// never rebuilt.
b, err := p.Encode()
if err != nil {
t.Fatalf("encode: %v", err)
}
rt, err := Decode(b)
if err != nil {
t.Fatalf("decode: %v", err)
}
if rt.SKU != p.SKU || rt.DropShip || rt.SellerID != 0 {
t.Errorf("round-trip mismatch: %+v vs %+v", rt, p)
}
// json.Number lead time (UseNumber producers).
p, err = Apply(s, map[string]any{
"sku": "X-4", "price_inc_vat": int64(100_00),
"updated_at": int64(1700000000000),
"deliveryTime": json.Number("7"),
})
if err != nil {
t.Fatalf("json.Number lead time: %v", err)
}
if p.LeadTimeDays != 7 {
t.Errorf("LeadTimeDays from json.Number: got %d, want 7", p.LeadTimeDays)
}
}