Files
go-cart-actor/cmd/cart/projection_overlay_test.go
T
matsandClaude Opus 4.8 d711348863
Build and Publish / BuildAndDeployArm64 (push) Failing after 5s
Build and Publish / BuildAndDeployAmd64 (push) Failing after 3s
cart: back projection cache with platform/catalog.ProjectionStore
Consumer side of the catalog-projection seam (C1):
- catalogProjectionCache now wraps ProjectionStore[catalog.Projection] (identity
  transform — the cart's overlay uses ~all fields); delivery handler routes bus
  frames via HandleFrame (snapshot begin|chunk|end + epoch-stamped deltas).
- Per-pod cold-start-ready .bin under CART_PROJECTION_DIR (pod-local, never
  shared) + IsDeleted tombstone passthrough; HTTP fetch demoted to last-resort.
- Tests: test-only Apply shim + mustCache helper keep existing cases green;
  new snapshot-framing integration test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 21:05:55 +02:00

602 lines
22 KiB
Go

package main
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"git.k6n.net/mats/go-cart-actor/pkg/cart"
messages "git.k6n.net/mats/go-cart-actor/proto/cart"
"git.k6n.net/mats/platform/catalog"
"git.k6n.net/mats/platform/money"
)
// TestApplyProjectionOverlay_PricePositiveOnly covers the contract that a cache
// hit with a positive PriceIncVat overrides HTTP-fetched price, but a zero
// (unbroadcast) PriceIncVat must NOT overwrite a valid HTTP price — it would
// be a silent corruption.
func TestApplyProjectionOverlay_PricePositiveOnly(t *testing.T) {
startPrice := int64(95_00)
base := func() *messages.AddItem {
return &messages.AddItem{Sku: "X", Price: startPrice, Name: "http-name", Image: "http.jpg", Tax: 2500}
}
// Positive cache value wins.
m := base()
ApplyProjectionOverlay(m, catalog.Projection{SKU: "X", Title: "cache-name", Image: "cache.jpg", PriceIncVat: money.Cents(120_00), TaxClass: "reduced"})
if m.Price != 120_00 {
t.Errorf("positive cache price: got %d, want 12000", m.Price)
}
// Zero cache value must NOT overwrite (treat as "no value").
m = base()
ApplyProjectionOverlay(m, catalog.Projection{SKU: "X", PriceIncVat: money.Cents(0)})
if m.Price != startPrice {
t.Errorf("zero cache price overwrote HTTP price: got %d, want %d (HTTP)", m.Price, startPrice)
}
}
// TestApplyProjectionOverlay_TaxOnlyWhenClassSet verifies the TaxClass-skip on
// an unclassified cache entry: HTTP rate is more trustworthy than a guessed
// 2500 default, so msg.Tax stays untouched.
func TestApplyProjectionOverlay_TaxOnlyWhenClassSet(t *testing.T) {
base := func() *messages.AddItem {
return &messages.AddItem{Sku: "X", Tax: 600} // HTTP-fetched lowered 6%
}
// Empty TaxClass: HTTP rate preserved.
m := base()
ApplyProjectionOverlay(m, catalog.Projection{SKU: "X", TaxClass: ""})
if m.Tax != 600 {
t.Errorf("empty TaxClass overwrote HTTP tax: got %d, want 600", m.Tax)
}
// Non-empty: cache rate wins.
m = base()
ApplyProjectionOverlay(m, catalog.Projection{SKU: "X", TaxClass: "reduced"})
if m.Tax != 1200 {
t.Errorf("TaxClass=reduced: got %d, want 1200", m.Tax)
}
m = base()
ApplyProjectionOverlay(m, catalog.Projection{SKU: "X", TaxClass: "zero"})
if m.Tax != 0 {
t.Errorf("TaxClass=zero: got %d, want 0", m.Tax)
}
}
// TestApplyProjectionOverlay_DisplayFields verifies Title/Image override only
// when the cache actually carries a value (an unbus-fed "" isn't propagated
// over a valid HTTP value).
func TestApplyProjectionOverlay_DisplayFields(t *testing.T) {
base := func() *messages.AddItem {
return &messages.AddItem{Sku: "X", Name: "http-name", Image: "http.jpg"}
}
m := base()
ApplyProjectionOverlay(m, catalog.Projection{SKU: "X", Title: "cache-name", Image: "cache.jpg"})
if m.Name != "cache-name" {
t.Errorf("Title overlay: got %q, want %q", m.Name, "cache-name")
}
if m.Image != "cache.jpg" {
t.Errorf("Image overlay: got %q, want %q", m.Image, "cache.jpg")
}
// Empty cache Title/Image must NOT overwrite.
m = base()
ApplyProjectionOverlay(m, catalog.Projection{SKU: "X", Title: "", Image: ""})
if m.Name != "http-name" {
t.Errorf("empty cache Title wiped HTTP name: got %q", m.Name)
}
if m.Image != "http.jpg" {
t.Errorf("empty cache Image wiped HTTP image: got %q", m.Image)
}
}
// TestApplyProjectionOverlay_FlagFlipToTrueOnly locks down the
// InventoryTracked / DropShip positive-flip semantics. False in the cache
// is the proto zero value and may be unset on the wire; we don't second-guess.
func TestApplyProjectionOverlay_FlagFlipToTrueOnly(t *testing.T) {
// HTTP set both true (e.g. live SKU). Cache also has them true. Should
// stay true.
m := &messages.AddItem{InventoryTracked: true, DropShip: true}
ApplyProjectionOverlay(m, catalog.Projection{SKU: "X", InventoryTracked: true, DropShip: true})
if !m.InventoryTracked || !m.DropShip {
t.Fatalf("true-from-cache over a true-from-HTTP: %+v", m)
}
// HTTP set false (default), cache says true → upgrade to true.
m = &messages.AddItem{InventoryTracked: false, DropShip: false}
ApplyProjectionOverlay(m, catalog.Projection{SKU: "X", InventoryTracked: true, DropShip: true})
if !m.InventoryTracked || !m.DropShip {
t.Errorf("true-from-cache should flip false-from-HTTP: %+v", m)
}
// HTTP already true, cache says false → must NOT downgrade (false would
// be the proto zero value; trust the live HTTP signal here).
m = &messages.AddItem{InventoryTracked: true, DropShip: true}
ApplyProjectionOverlay(m, catalog.Projection{SKU: "X", InventoryTracked: false, DropShip: false})
if !m.InventoryTracked || !m.DropShip {
t.Errorf("false-from-cache must NOT overwrite true-from-HTTP: %+v", m)
}
}
// TestApplyProjectionOverlay_NilMsgSafe confirms the helper tolerates a nil
// pointer (defensive — caller already validates, but a future caller may not).
func TestApplyProjectionOverlay_NilMsgSafe(t *testing.T) {
// Must not panic.
ApplyProjectionOverlay(nil, catalog.Projection{SKU: "X"})
}
// TestAddSkuToCartHandler_TombstoneReturns404 verifies the bus-deleted
// rejection path: HTTP 404 with the product-fetcher-shaped error string, no
// HTTP fetch attempted. Locks down the regression where the handler returned
// an error and the framework rendered it as 500.
//
// Why a real mux: `r.PathValue("sku")` only resolves when the request is
// dispatched through a mux that registered the path pattern (e.g.
// `GET /cart/add/{sku}` in pool-server.go's Serve()). Calling the handler
// directly with httptest.NewRequest produces an empty PathValue, which would
// bypass the tombstone branch and fall through to the HTTP fetch — failing
// the test for a wiring reason rather than the contract under test.
func TestAddSkuToCartHandler_TombstoneReturns404(t *testing.T) {
idx := mustCache(t)
idx.Apply([]catalog.ProjectionUpdate{
{Projection: catalog.Projection{SKU: "DEL", PriceIncVat: money.Cents(10_00)}},
{Projection: catalog.Projection{SKU: "DEL"}, Deleted: true},
})
// Use a stub PoolServer with nil grain pool: the tombstone path returns
// BEFORE ApplyLocal, so the embedded actor.GrainPool is never touched. If a
// future edit adds an early s.IsHealthy() / pool-touch on this handler, the
// test will panic with a nil-pointer deref and the wiring fault will be
// obvious.
s := &PoolServer{pod_name: "test", idx: idx}
mux := http.NewServeMux()
mux.HandleFunc("GET /cart/add/{sku}", func(w http.ResponseWriter, r *http.Request) {
_ = s.AddSkuToCartHandler(w, r, cart.CartId(1))
})
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/cart/add/DEL", nil))
if rec.Code != http.StatusNotFound {
t.Fatalf("status = %d, want %d (bus-deleted SKU). body=%s",
rec.Code, http.StatusNotFound, rec.Body.String())
}
// http.Error-style exact shape: trailing newline, text/plain. Pinning the
// string catches regressions either way (body too loose AND format flips).
wantCT := "text/plain; charset=utf-8"
if got := rec.Header().Get("Content-Type"); got != wantCT {
t.Errorf("Content-Type = %q, want %q", got, wantCT)
}
wantBody := "product service returned 404 for sku DEL (bus-deleted)\n"
if got := rec.Body.String(); got != wantBody {
t.Errorf("body mismatch:\n got %q\n want %q", got, wantBody)
}
}
// TestIsDeletedVsGet asserts the two methods don't conflict: a tombstoned SKU
// is reported by both IsDeleted (true) and Get (miss).
func TestIsDeletedVsGet(t *testing.T) {
c := mustCache(t)
c.Apply([]catalog.ProjectionUpdate{
{Projection: catalog.Projection{SKU: "DL", PriceIncVat: money.Cents(10_00)}},
{Projection: catalog.Projection{SKU: "DL"}, Deleted: true},
})
if _, ok := c.Get("DL"); ok {
t.Fatalf("Get on tombstone: ok=true")
}
if !c.IsDeleted("DL") {
t.Fatalf("IsDeleted on tombstone: false")
}
if c.IsDeleted("DL-fresh") {
t.Fatalf("IsDeleted on absent entry: true")
}
if _, ok := c.Get("DL-fresh"); ok {
t.Fatalf("Get on absent entry: ok=true")
}
}
// TestApplyProjectionOverlay_ItemIdPositiveOnly locks the contract that a
// positive bus ItemID overrides HTTP, but ItemID=0 must NOT clobber a real
// HTTP id (e.g. a producer that omits an integer id, or a tombstone's zero
// residual). This is the wire that future-unblocks the cache-only-skip-HTTP
// path; regression here would silently drop cart-line dedup info.
func TestApplyProjectionOverlay_ItemIdPositiveOnly(t *testing.T) {
base := func() *messages.AddItem {
return &messages.AddItem{Sku: "X", ItemId: 12345}
}
// Positive cache id wins.
m := base()
ApplyProjectionOverlay(m, catalog.Projection{SKU: "X", ItemID: 67890})
if m.ItemId != 67890 {
t.Errorf("positive cache ItemID: got %d, want 67890", m.ItemId)
}
// Zero cache id must NOT overwrite HTTP.
m = base()
ApplyProjectionOverlay(m, catalog.Projection{SKU: "X", ItemID: 0})
if m.ItemId != 12345 {
t.Errorf("zero cache ItemID wiped HTTP id: got %d, want 12345", m.ItemId)
}
}
// ---------------------------------------------------------------------------
// Cache-only-skip-HTTP path tests
// ---------------------------------------------------------------------------
// TestHasRequiredFields covers the eligibility contract for the cache-only
// fast path: positive PriceIncVat, non-empty TaxClass, non-zero ItemID. Any
// one missing → HTTP fallback.
func TestHasRequiredFields(t *testing.T) {
full := catalog.Projection{
SKU: "S", PriceIncVat: money.Cents(100), TaxClass: "standard", ItemID: 1,
}
cases := []struct {
name string
p catalog.Projection
want bool
}{
{"all required fields set", full, true},
{"zero price → ineligible", catalog.Projection{SKU: "S", TaxClass: "standard", ItemID: 1}, false},
{"empty tax → ineligible", catalog.Projection{SKU: "S", PriceIncVat: money.Cents(100), ItemID: 1}, false},
{"zero id → ineligible", catalog.Projection{SKU: "S", PriceIncVat: money.Cents(100), TaxClass: "standard"}, false},
{"only sku set", catalog.Projection{SKU: "S"}, false},
{"empty zero value", catalog.Projection{}, false},
{"reduced-rate still eligible", catalog.Projection{SKU: "S", PriceIncVat: money.Cents(99), TaxClass: "reduced", ItemID: 7}, true},
{"zero-rate still eligible (zero TaxClass not required, only non-empty)",
catalog.Projection{SKU: "S", PriceIncVat: money.Cents(0), TaxClass: "zero", ItemID: 7}, false}, // price=0 still ineligible
}
for _, tc := range cases {
if got := HasRequiredFields(tc.p); got != tc.want {
t.Errorf("%s: HasRequiredFields=%v, want %v (p=%+v)", tc.name, got, tc.want, tc.p)
}
}
}
// TestBuildAddItemFromCache verifies the cache-only fast-path builder maps
// every cache-visible carrier onto the proto fields. ItemID, Price, Tax
// (via TaxClass resolution), Title/Image, quantity/country/storeId all
// populated; SellerId/SellerName/Stock/OrgPrice/ExtraJson left blank (those
// fields live outside the Projection for now).
func TestBuildAddItemFromCache(t *testing.T) {
p := catalog.Projection{
SKU: "X",
Title: "Cache Title",
Image: "cache.jpg",
PriceIncVat: money.Cents(123_45),
TaxClass: "reduced",
ItemID: 42,
InventoryTracked: true,
DropShip: true,
}
msg := BuildAddItemFromCache("X", 2, "no", nil, p)
if msg == nil {
t.Fatalf("BuildAddItemFromCache returned nil")
}
if msg.Sku != "X" {
t.Errorf("Sku: got %q, want %q", msg.Sku, "X")
}
if msg.ItemId != 42 {
t.Errorf("ItemId: got %d, want 42", msg.ItemId)
}
if msg.Quantity != 2 {
t.Errorf("Quantity: got %d, want 2", msg.Quantity)
}
if msg.Country != "no" {
t.Errorf("Country: got %q, want %q", msg.Country, "no")
}
if msg.StoreId != nil {
t.Errorf("StoreId: got %v, want nil", msg.StoreId)
}
if msg.Price != 123_45 {
t.Errorf("Price: got %d, want 12345", msg.Price)
}
if msg.Tax != 1200 {
t.Errorf("Tax: got %d, want 1200 (reduced)", msg.Tax)
}
if msg.Name != "Cache Title" {
t.Errorf("Name: got %q, want %q", msg.Name, "Cache Title")
}
if msg.Image != "cache.jpg" {
t.Errorf("Image: got %q, want %q", msg.Image, "cache.jpg")
}
if !msg.InventoryTracked {
t.Errorf("InventoryTracked: got false, want true")
}
if !msg.DropShip {
t.Errorf("DropShip: got false, want true")
}
// Cache-only gaps (intentionally blank):
if msg.SellerId != "" || msg.SellerName != "" {
t.Errorf("SellerId/SellerName should be blank: %q/%q", msg.SellerId, msg.SellerName)
}
if msg.Stock != 0 {
t.Errorf("Stock should be 0 (queried sync from inventory): got %d", msg.Stock)
}
if msg.OrgPrice != 0 {
t.Errorf("OrgPrice should be 0 (not on Projection): got %d", msg.OrgPrice)
}
if len(msg.ExtraJson) != 0 {
t.Errorf("ExtraJson should be empty (not on Projection): got %s", msg.ExtraJson)
}
}
// TestBuildAddItemFromCache_WithStoreId covers the *string storeId path.
// nil pointer is the default; non-nil must propagate through.
func TestBuildAddItemFromCache_WithStoreId(t *testing.T) {
sid := "store-42"
p := catalog.Projection{
SKU: "X", PriceIncVat: money.Cents(100),
TaxClass: "standard", ItemID: 1,
}
msg := BuildAddItemFromCache("X", 1, "se", &sid, p)
if msg.StoreId == nil {
t.Fatalf("StoreId is nil; want propagation")
}
if *msg.StoreId != "store-42" {
t.Errorf("StoreId: got %q, want %q", *msg.StoreId, "store-42")
}
}
// productServer is a fixture that returns a minimal product document so
// BuildItemMessage / FetchItem can complete without depending on a real
// product service. Each invocation records the SKU path so tests can assert
// "the cache-only path did NOT call the product service".
type productProduct struct {
Id uint64 `json:"id"`
Sku string `json:"sku"`
Title string `json:"title"`
Img string `json:"img"`
Price float64 `json:"price"`
Vat int `json:"vat"`
InStock int32 `json:"inStock"`
SupplierId int `json:"supplierId"`
SupplierName string `json:"supplierName"`
}
// fakeProductServer is a goroutine-safe mock that records each call.
type fakeProductServer struct {
srv *httptest.Server
called chan string
}
func newFakeProductServer(t *testing.T) *fakeProductServer {
t.Helper()
called := make(chan string, 16)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
select {
case called <- r.URL.Path:
default:
}
w.Header().Set("Content-Type", "application/json")
sku := r.URL.Path
_ = json.NewEncoder(w).Encode(productProduct{
Id: 100, Sku: sku, Title: "http-name",
Img: "http.jpg", Price: 50.0, Vat: 25,
InStock: 5, SupplierId: 1, SupplierName: "test",
})
}))
t.Cleanup(func() { srv.Close() })
return &fakeProductServer{srv: srv, called: called}
}
// TestBuildItemGroups_CacheOnlySkipsHTTP locks the core contract of the
// cache-only fast path: a parent with NO children AND a cache hit with
// HasRequiredFields returns an AddItem populated from the Projection
// WITHOUT making any HTTP round-trip. The mock product server's handler
// calls t.Errorf if invoked, so a non-empty channel would also fail the
// test.
func TestBuildItemGroups_CacheOnlySkipsHTTP(t *testing.T) {
prod := newFakeProductServer(t)
t.Setenv("PRODUCT_BASE_URL", prod.srv.URL)
idx := mustCache(t)
idx.Apply([]catalog.ProjectionUpdate{
{Projection: catalog.Projection{
SKU: "TOP",
PriceIncVat: money.Cents(99_00),
TaxClass: "standard",
ItemID: 7,
Title: "Cache Top",
Image: "cache.jpg",
InventoryTracked: true,
}},
})
groups := buildItemGroups(context.Background(), []Item{
{Sku: "TOP", Quantity: 1},
}, "se", idx)
if len(groups) != 1 {
t.Fatalf("len(groups) = %d, want 1", len(groups))
}
g := groups[0]
if g.parent == nil {
t.Fatalf("parent nil; want a BuildAddItemFromCache-built message")
}
if g.parent.Sku != "TOP" {
t.Errorf("Sku: got %q, want %q", g.parent.Sku, "TOP")
}
if g.parent.Price != 99_00 {
t.Errorf("Price: got %d, want 9900", g.parent.Price)
}
if g.parent.Tax != 2500 {
t.Errorf("Tax: got %d, want 2500", g.parent.Tax)
}
if g.parent.ItemId != 7 {
t.Errorf("ItemId: got %d, want 7", g.parent.ItemId)
}
if g.parent.Name != "Cache Top" {
t.Errorf("Name: got %q, want %q", g.parent.Name, "Cache Top")
}
if g.parent.Image != "cache.jpg" {
t.Errorf("Image: got %q, want %q", g.parent.Image, "cache.jpg")
}
if !g.parent.InventoryTracked {
t.Errorf("InventoryTracked: got false (cache said true)")
}
if len(g.children) != 0 {
t.Errorf("children: got %d, want 0", len(g.children))
}
// Crucially: the mock product service must NOT have been called.
select {
case path := <-prod.called:
t.Errorf("product service called at %s; cache-only path should skip HTTP", path)
default:
// expected
}
}
// TestBuildItemGroups_HTTPFallbackWhenChildrenPresent locks the configurator
// safety: a parent with children CANNOT take the cache-only fast path
// because price derivation in ToItemAddMessage's parent != nil branch uses
// areaFromItem(parent) which reads width/height from the HTTP-fetched
// ProductItem.Extra — not on Projection yet. So the HTTP path must run for
// BOTH the parent and the child.
func TestBuildItemGroups_HTTPFallbackWhenChildrenPresent(t *testing.T) {
prod := newFakeProductServer(t)
t.Setenv("PRODUCT_BASE_URL", prod.srv.URL)
idx := mustCache(t)
idx.Apply([]catalog.ProjectionUpdate{
{Projection: catalog.Projection{
SKU: "PARENT", PriceIncVat: money.Cents(99_00),
TaxClass: "standard", ItemID: 7,
}},
})
groups := buildItemGroups(context.Background(), []Item{
{Sku: "PARENT", Quantity: 1, Children: []Item{
{Sku: "CHILD", Quantity: 1},
}},
}, "se", idx)
if len(groups) != 1 {
t.Fatalf("len(groups) = %d, want 1", len(groups))
}
if groups[0].parent == nil {
t.Fatalf("parent nil; expect HTTP-built message")
}
// Drain the channel and pin BOTH parent and child fetches. A regression
// that fetches the parent twice or skips the child will surface loudly:
// we capture the SKU paths the mock sees, so missing/duplicate calls
// fail the assertion.
close(prod.called)
paths := make(map[string]int)
for path := range prod.called {
paths[path]++
}
if paths["/api/get/PARENT"] < 1 {
t.Errorf("expected /api/get/PARENT fetch (HTTP fallback for parent); got %v", paths)
}
if paths["/api/get/CHILD"] < 1 {
t.Errorf("expected /api/get/CHILD fetch (HTTP fallback for child); got %v", paths)
}
}
// TestAddSkuToCartHandler_CacheOnlySkipsHTTP exercises the AddSkuToCartHandler
// cache-only path end-to-end through the mux. The same fail-on-call mock
// product service proves no HTTP round-trip happens on the cache-only fast
// path. ApplyLocal will fail on a nil grain pool — that's fine, we recover()
// inside the mux wrapper; the only assertion that matters is "the mock was
// not called".
//
// Why a real mux: PathValue("sku") only resolves when the mux has registered
// the path pattern (see TestAddSkuToCartHandler_TombstoneReturns404 for the
// same rationale).
func TestAddSkuToCartHandler_CacheOnlySkipsHTTP(t *testing.T) {
failSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Errorf("product service called at %s on cache-only path; want zero HTTP fetches", r.URL.Path)
w.WriteHeader(http.StatusInternalServerError)
}))
defer failSrv.Close()
t.Setenv("PRODUCT_BASE_URL", failSrv.URL)
idx := mustCache(t)
idx.Apply([]catalog.ProjectionUpdate{
{Projection: catalog.Projection{
SKU: "OK", PriceIncVat: money.Cents(99_00),
TaxClass: "standard", ItemID: 7,
Title: "Cache OK", Image: "cache.jpg",
}},
})
s := &PoolServer{pod_name: "test", idx: idx}
mux := http.NewServeMux()
mux.HandleFunc("GET /cart/add/{sku}", func(w http.ResponseWriter, r *http.Request) {
// ApplyLocal calls into the embedded GrainPool which is nil here;
// the cache-only build-the-message path will reach ApplyLocal and
// then panic on a nil deref. Recover so the test framework can
// observe a clean pass; what matters is the cache-only short-circuit
// RAN (the mock product service would have been hit otherwise).
defer func() { _ = recover() }()
_ = s.AddSkuToCartHandler(w, r, cart.CartId(1))
})
mux.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, "/cart/add/OK", nil))
// No explicit assertion needed: failSrv's t.Errorf would already be
// recorded as a test failure if the cache-only short-circuit missed.
}
// TestBuildItemGroups_HTTPFallbackWhenCacheMiss locks the cold-grace path:
// when the cache has NO entry for the SKU, the HTTP fetch must run.
func TestBuildItemGroups_HTTPFallbackWhenCacheMiss(t *testing.T) {
prod := newFakeProductServer(t)
t.Setenv("PRODUCT_BASE_URL", prod.srv.URL)
idx := mustCache(t) // empty
groups := buildItemGroups(context.Background(), []Item{
{Sku: "MISS", Quantity: 1},
}, "se", idx)
if groups[0].parent == nil {
t.Fatalf("parent nil; cold-grace should have HTTP-fetched")
}
close(prod.called)
count := 0
for range prod.called {
count++
}
if count != 1 {
t.Errorf("expected exactly 1 product service call; got %d", count)
}
}
// TestBuildItemGroups_HTTPFallbackWhenHasRequiredFieldsFalse locks the
// partial-cache case: a cache entry with PriceIncVat > 0 but TaxClass=""
// fails HasRequiredFields → HTTP fallback. This keeps the existing cold-grace
// behavior for SKUs whose producer hasn't published a TaxClass yet.
func TestBuildItemGroups_HTTPFallbackWhenHasRequiredFieldsFalse(t *testing.T) {
prod := newFakeProductServer(t)
t.Setenv("PRODUCT_BASE_URL", prod.srv.URL)
idx := mustCache(t)
idx.Apply([]catalog.ProjectionUpdate{
{Projection: catalog.Projection{
SKU: "PARTIAL", PriceIncVat: money.Cents(99_00),
TaxClass: "" /* missing → HasRequiredFields=false */,
ItemID: 7,
}},
})
groups := buildItemGroups(context.Background(), []Item{
{Sku: "PARTIAL", Quantity: 1},
}, "se", idx)
if groups[0].parent == nil {
t.Fatalf("parent nil; partial cache should have HTTP-fetched")
}
close(prod.called)
count := 0
for range prod.called {
count++
}
if count != 1 {
t.Errorf("expected exactly 1 product service call; got %d (HasRequiredFields=false should fail the cache-only gate)", count)
}
}