d711348863
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>
602 lines
22 KiB
Go
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)
|
|
}
|
|
}
|