Files
go-cart-actor/cmd/cart/projection_cache_test.go
T
mats aa8b2bdedc
Build and Publish / BuildAndDeployAmd64 (push) Failing after 5s
Build and Publish / BuildAndDeployArm64 (push) Failing after 6s
all the refactor
2026-06-28 17:51:52 +02:00

171 lines
5.3 KiB
Go

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