196 lines
6.4 KiB
Go
196 lines
6.4 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)
|
|
}
|
|
}
|