Files
platform/catalog/builder_test.go
T
2026-06-28 17:52:49 +02:00

163 lines
5.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))
}
}
// 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)
}
}