225 lines
8.8 KiB
Go
225 lines
8.8 KiB
Go
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)
|
|
}
|
|
}
|