package main import ( "testing" "git.k6n.net/mats/platform/catalog" "git.k6n.net/mats/platform/money" ) // TestProjectionCache_ApplyUpserts covers the happy path: a batch of N // projections reaches the cache via Apply, all are visible via Get. func TestProjectionCache_ApplyUpserts(t *testing.T) { c := newCatalogProjectionCache() updates := []catalog.ProjectionUpdate{ {Projection: catalog.Projection{ID: "id-A", SKU: "A", PriceIncVat: money.Cents(100_00), TaxClass: "standard"}}, {Projection: catalog.Projection{ID: "id-B", SKU: "B", PriceIncVat: money.Cents(150_00), TaxClass: "reduced"}}, {Projection: catalog.Projection{ID: "id-C", SKU: "C", PriceIncVat: money.Cents(0), TaxClass: ""}}, } upserts, deletes := c.Apply(updates) if upserts != 3 || deletes != 0 { t.Fatalf("counts: upserts=%d deletes=%d, want 3/0", upserts, deletes) } for _, want := range []struct { sku string price money.Cents taxCls string }{ {"A", money.Cents(100_00), "standard"}, {"B", money.Cents(150_00), "reduced"}, {"C", money.Cents(0), ""}, } { got, ok := c.Get(want.sku) if !ok { t.Fatalf("Get(%q): missing", want.sku) } if got.PriceIncVat != want.price || got.TaxClass != want.taxCls { t.Fatalf("Get(%q) = %+v, want price=%d taxCls=%q", want.sku, got, want.price, want.taxCls) } } if c.Len() != 3 { t.Fatalf("Len = %d, want 3", c.Len()) } } // TestProjectionCache_DeleteTombstone verifies that a delete update sets a // tombstone (not removes the entry), so: // - Get returns (_, false) on a deleted SKU // - IsDeleted returns true // - the same entry continues to count toward Len (size accounting) func TestProjectionCache_DeleteTombstone(t *testing.T) { c := newCatalogProjectionCache() c.Apply([]catalog.ProjectionUpdate{ {Projection: catalog.Projection{ID: "id-A", SKU: "A", PriceIncVat: money.Cents(100_00)}}, }) if _, ok := c.Get("A"); !ok { t.Fatalf("A: expected hit before delete") } upserts, deletes := c.Apply([]catalog.ProjectionUpdate{ {Projection: catalog.Projection{SKU: "A"}, Deleted: true}, }) if upserts != 0 || deletes != 1 { t.Fatalf("delete counts: upserts=%d deletes=%d, want 0/1", upserts, deletes) } if got, ok := c.Get("A"); ok { t.Fatalf("A after delete: got %+v ok=true, want miss", got) } if !c.IsDeleted("A") { t.Fatalf("A after delete: IsDeleted false") } if c.IsDeleted("UNKNOWN") { t.Fatalf("UNKNOWN: IsDeleted should be false (entry doesn't exist)") } if c.Len() != 1 { t.Fatalf("Len after tombstone: %d, want 1 (tombstone still in map)", c.Len()) } } // TestProjectionCache_BusResurrect verifies that a fresh upsert after a delete // clears the tombstone (live entry replaces it): Get returns the projection, // IsDeleted flips to false. Mirrors how the live catalog keeps flipping state. func TestProjectionCache_BusResurrect(t *testing.T) { c := newCatalogProjectionCache() c.Apply([]catalog.ProjectionUpdate{ {Projection: catalog.Projection{SKU: "A", PriceIncVat: money.Cents(50_00)}}, {Projection: catalog.Projection{SKU: "A"}, Deleted: true}, }) if !c.IsDeleted("A") { t.Fatalf("A: not tombstoned after delete") } c.Apply([]catalog.ProjectionUpdate{ {Projection: catalog.Projection{SKU: "A", PriceIncVat: money.Cents(75_00)}}, }) got, ok := c.Get("A") if !ok { t.Fatalf("A: expected live after re-upsert") } if got.PriceIncVat != money.Cents(75_00) { t.Fatalf("A re-upsert price = %d, want 7500", got.PriceIncVat) } if c.IsDeleted("A") { t.Fatalf("A: still tombstoned after re-upsert") } } // TestProjectionCache_EmptySKUIgnored validates that an upsert with an empty // SKU is a no-op (defensive — bus should never publish "" but cheaper to log // + drop than to populate an ambiguous cache slot). func TestProjectionCache_EmptySKUIgnored(t *testing.T) { c := newCatalogProjectionCache() upserts, _ := c.Apply([]catalog.ProjectionUpdate{ {Projection: catalog.Projection{SKU: "", PriceIncVat: money.Cents(100_00)}}, }) if upserts != 0 { t.Fatalf("empty-SKU upsert: %d, want 0", upserts) } if c.Len() != 0 { t.Fatalf("Len after empty-SKU: %d, want 0", c.Len()) } } // TestProjectionCache_BusRaceSafe runs Apply + Get concurrently under -race // to lock down the lock order. Run via `go test -race ./cmd/cart/...`. func TestProjectionCache_BusRaceSafe(t *testing.T) { c := newCatalogProjectionCache() done := make(chan struct{}) go func() { defer close(done) for i := 0; i < 500; i++ { c.Apply([]catalog.ProjectionUpdate{ {Projection: catalog.Projection{SKU: "X", PriceIncVat: money.Cents(int64(i * 100))}}, }) } }() for i := 0; i < 2000; i++ { _, _ = c.Get("X") _ = c.IsDeleted("X") _ = c.Len() } <-done if _, ok := c.Get("X"); !ok { t.Fatalf("final Get(X): miss after concurrent writes") } } // TestApply_MixedUpsertDelete verifies the realistic per-batch sequence a // publisher emits: a single Apply call interleaves upserts and deletes, and // the per-batch counters report the right split. Without this the cache could // drift on count semantics across batched events. func TestApply_MixedUpsertDelete(t *testing.T) { c := newCatalogProjectionCache() // Order matters: prior in-cache state for A is upserted twice (latest wins), // B is tombstoned, C cold-upserted, D cold-tombstoned. updates := []catalog.ProjectionUpdate{ {Projection: catalog.Projection{SKU: "A", PriceIncVat: money.Cents(50_00)}}, {Projection: catalog.Projection{SKU: "B"}, Deleted: true}, {Projection: catalog.Projection{SKU: "C", PriceIncVat: money.Cents(75_00)}}, {Projection: catalog.Projection{SKU: "A", PriceIncVat: money.Cents(60_00)}}, // later wins {Projection: catalog.Projection{SKU: "D"}, Deleted: true}, } upserts, deletes := c.Apply(updates) if upserts != 3 || deletes != 2 { t.Fatalf("counts: upserts=%d deletes=%d, want 3/2 (A,C live-upserts; B,D tombstones)", upserts, deletes) } if got, ok := c.Get("A"); !ok || got.PriceIncVat != money.Cents(60_00) { t.Fatalf("A latest-wins: got %+v ok=%v, want 6000", got, ok) } if _, ok := c.Get("B"); ok { t.Fatalf("B: tombstoned but Get returned a value") } if !c.IsDeleted("B") { t.Fatalf("B: IsDeleted not true") } if got, ok := c.Get("C"); !ok || got.PriceIncVat != money.Cents(75_00) { t.Fatalf("C cold-upsert: got %+v ok=%v, want 7500", got, ok) } if !c.IsDeleted("D") { t.Fatalf("D: IsDeleted not true") } if c.Len() != 4 { t.Fatalf("Len() after mixed batch: %d, want 4 (A live + B tombstone + C live + D tombstone)", c.Len()) } } // TestTaxResolveBp covers the static TaxClass→bp mapping used by // ApplyProjectionOverlay. Adds a regression net for the common Nordic rates. func TestTaxResolveBp(t *testing.T) { cases := []struct { class string want int }{ {"standard", 2500}, {"reduced", 1200}, {"lowered", 600}, {"zero", 0}, {"exempt", 0}, {"", 2500}, // missing class defaults to standard (matches mutation registry) {"unknown-class", 2500}, } for _, tc := range cases { got := resolveTaxBp(tc.class) if got != tc.want { t.Errorf("resolveTaxBp(%q) = %d, want %d", tc.class, got, tc.want) } } }