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