package main import ( "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 := newCatalogProjectionCache() 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 := newCatalogProjectionCache() 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) } }