Files
platform/cmd/internal/catalogtest/items.go
T
2026-07-26 13:01:07 +02:00

151 lines
5.1 KiB
Go

// Package catalogtest holds shared helpers for the platform catalog test
// harnesses — cmd/catalog-emit (consumer harness) and cmd/event-emit-publisher
// (test publisher) both use these to seed a source-item set with the same
// shape conventions. Anything in this package is a test-only utility; do
// not import from production code.
//
// Scope: this package is parked under platform/cmd/internal/ on purpose.
// Go's internal-package rule restricts imports to packages within the
// parent directory's subtree, so only platform/cmd/* binaries can import
// it. That keeps the test-helper API out of the public platform surface
// (e.g. platform/event, platform/rabbit must not import catalogtest —
// they're cross-module seams, not test scaffolding).
package catalogtest
import (
"encoding/json"
"fmt"
"math/rand"
"os"
"strconv"
"git.k6n.net/mats/platform/catalog"
)
// LoadOrSynthesize returns one source-batch per tenant name. If itemsPath
// is non-empty, it parses a {"tenant":[...]}-keyed JSON map and returns
// it unchanged. Otherwise it generates -n synthetic items per tenant
// using the tenant's registered Schema's field names, with skuPrefix
// prepended to each SKU.
//
// schemaFor is supplied so callers must already have a Registry built
// (production code paths usually have a Registry at composition root;
// publishing this helper to test code keeps the hot path clean).
//
// skuPrefix is a single-character prefix that lets log lines / probe
// output attribute synthetic items to the binary that produced them
// ("B" for catalog-emit, "P" for publisher). Substituting per-binary
// retains recognisability in mixed test runs.
//
// Determinism: synth items are content-version-identical between runs at
// the same (schema, seed, i). UpdatedAt is Unix-ms seeded from `i`.
func LoadOrSynthesize(itemsPath string, tenants []string, n int, seed int64, reg *catalog.Registry, skuPrefix string) (map[string][]map[string]any, error) {
if itemsPath != "" {
data, err := os.ReadFile(itemsPath)
if err != nil {
return nil, err
}
var raw map[string][]map[string]any
if err := json.Unmarshal(data, &raw); err != nil {
return nil, fmt.Errorf("parse items: %w", err)
}
return raw, nil
}
rng := rand.New(rand.NewSource(seed))
out := make(map[string][]map[string]any, len(tenants))
for _, t := range tenants {
s := reg.SchemaFor(t)
items := make([]map[string]any, n)
for i := 0; i < n; i++ {
items[i] = SynthItem(s, rng, i, skuPrefix)
}
out[t] = items
}
return out, nil
}
// SynthItem builds one source-map whose key names match the supplied
// schema's field names. catalog.Apply on (s, item) succeeds for any
// schema that uses the same field-name conventions as DefaultSchema() /
// BygglagretSchema() / a Registry-supplied override.
//
// skuPrefix is prepended to each SKU (e.g. "B" or "P"); reasonable
// titles and prices are derived from a stable per-i seed plus the rng.
// Title text is intentionally uniform across binaries so log lines
// diff-cleanly between catalog-emit and event-emit-publisher runs.
//
// UpdatedAt is deterministic per i (Unix-ms, stable across runs) so the
// synthesised projection is content-version-identical between runs at
// the same (schema, seed, i).
func SynthItem(s catalog.Schema, rng *rand.Rand, i int, skuPrefix string) map[string]any {
sku := fmt.Sprintf("%s%07d", skuPrefix, i)
m := map[string]any{}
if s.SKUField != "" {
m[s.SKUField] = sku
}
if s.TitleField != "" {
m[s.TitleField] = fmt.Sprintf("Catalog Item %07d", i)
}
if s.PriceField != "" {
m[s.PriceField] = int64(99 + rng.Intn(9000))
}
if s.CurrencyField != "" {
m[s.CurrencyField] = "SEK"
}
if s.TaxClassField != "" {
m[s.TaxClassField] = "standard"
}
if s.InventoryTrackedF != "" {
m[s.InventoryTrackedF] = rng.Intn(2) == 0
}
if s.DropShipF != "" {
m[s.DropShipF] = rng.Intn(8) == 0
}
if s.CategoryField != "" {
m[s.CategoryField] = fmt.Sprintf("c-%d", rng.Intn(50))
}
if s.Category2Field != "" {
m[s.Category2Field] = fmt.Sprintf("c2-%d", rng.Intn(20))
}
if s.Category3Field != "" {
m[s.Category3Field] = fmt.Sprintf("c3-%d", rng.Intn(10))
}
if s.Category4Field != "" {
m[s.Category4Field] = fmt.Sprintf("c4-%d", rng.Intn(5))
}
if s.Category5Field != "" {
m[s.Category5Field] = fmt.Sprintf("c5-%d", rng.Intn(3))
}
if s.BrandField != "" {
m[s.BrandField] = "Acme"
}
if s.UpdatedAtField != "" {
m[s.UpdatedAtField] = int64(1700000000000 + i)
}
return m
}
// StringField returns the string value of a source key, accepting
// strings or integral-number source values (mirrors writeradmin's
// helper of the same name). Used by delete-marker detection where
// the body field may be either JSON-string "999" or JSON-number 999.
//
// Empty key returns ""; unknown type returns "". Caller decides
// what "" means (in writeradmin / publisher buildBatch it means
// "skip this delete — no SKU to delete on").
func StringField(src map[string]any, key string) string {
if key == "" {
return ""
}
switch v := src[key].(type) {
case string:
return v
case float64:
if v == float64(int64(v)) {
return strconv.FormatInt(int64(v), 10)
}
return strconv.FormatFloat(v, 'f', -1, 64)
}
return ""
}