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) } }