163 lines
5.4 KiB
Go
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)
|
|
}
|
|
}
|