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