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