210 lines
7.0 KiB
Go
210 lines
7.0 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")
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
}
|
|
}
|