391 lines
12 KiB
Go
391 lines
12 KiB
Go
//go:build integration
|
|
|
|
// Integration tests for the product fetcher. These hit a live product service
|
|
// and are excluded from the default build/test run. Run them with:
|
|
//
|
|
// go test -tags=integration ./cmd/cart/
|
|
//
|
|
// Point at a different service with PRODUCT_BASE_URL (default http://localhost:8082).
|
|
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"net"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strconv"
|
|
"testing"
|
|
"time"
|
|
|
|
"git.k6n.net/mats/go-cart-actor/pkg/actor"
|
|
"git.k6n.net/mats/go-cart-actor/pkg/cart"
|
|
"git.k6n.net/mats/go-cart-actor/pkg/proxy"
|
|
)
|
|
|
|
// newTestPoolServer builds a real, disk-backed PoolServer for exercising the
|
|
// HTTP handlers end-to-end (no clustering peers).
|
|
func newTestPoolServer(t *testing.T) *PoolServer {
|
|
t.Helper()
|
|
reg := cart.NewCartMultationRegistry(cart.NewCartMutationContext(nil))
|
|
reg.RegisterProcessor(actor.NewMutationProcessor(func(_ context.Context, g *cart.CartGrain) error {
|
|
g.UpdateTotals()
|
|
g.Version++
|
|
return nil
|
|
}))
|
|
dir := t.TempDir()
|
|
disk := actor.NewDiskStorage[cart.CartGrain](dir, reg)
|
|
pool, err := actor.NewSimpleGrainPool(actor.GrainPoolConfig[cart.CartGrain]{
|
|
MutationRegistry: reg,
|
|
Storage: disk,
|
|
Spawn: func(ctx context.Context, id uint64) (actor.Grain[cart.CartGrain], error) {
|
|
g := cart.NewCartGrain(id, time.Now())
|
|
err := disk.LoadEvents(ctx, id, g)
|
|
return g, err
|
|
},
|
|
Destroy: func(actor.Grain[cart.CartGrain]) error { return nil },
|
|
SpawnHost: func(host string) (actor.Host[cart.CartGrain], error) {
|
|
return proxy.NewRemoteHost[cart.CartGrain](host, "8080")
|
|
},
|
|
TTL: 5 * time.Minute,
|
|
PoolSize: 1000,
|
|
Hostname: "",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("new pool: %v", err)
|
|
}
|
|
return NewPoolServer(pool, "test", nil)
|
|
}
|
|
|
|
// exampleId is the catalog item the fetcher rework was validated against.
|
|
const exampleId = "8163"
|
|
|
|
// requireService skips the test when the product service is not reachable so
|
|
// the suite degrades gracefully in environments without it.
|
|
func requireService(t *testing.T) {
|
|
t.Helper()
|
|
base := getBaseUrl("se")
|
|
req, err := http.NewRequest(http.MethodGet, base+"/api/get/"+exampleId, nil)
|
|
if err != nil {
|
|
t.Fatalf("build probe request: %v", err)
|
|
}
|
|
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
|
defer cancel()
|
|
res, err := http.DefaultClient.Do(req.WithContext(ctx))
|
|
if err != nil {
|
|
var netErr net.Error
|
|
if errors.As(err, &netErr) || errors.Is(err, context.DeadlineExceeded) {
|
|
t.Skipf("product service %s not reachable: %v", base, err)
|
|
}
|
|
t.Fatalf("probe product service: %v", err)
|
|
}
|
|
res.Body.Close()
|
|
}
|
|
|
|
func TestFetchItem_Example8163(t *testing.T) {
|
|
requireService(t)
|
|
|
|
ctx := context.Background()
|
|
item, err := FetchItem(ctx, exampleId, "se")
|
|
if err != nil {
|
|
t.Fatalf("FetchItem(%s): %v", exampleId, err)
|
|
}
|
|
|
|
if item.Id != 8163 {
|
|
t.Errorf("Id = %d, want 8163", item.Id)
|
|
}
|
|
if item.Sku == "" {
|
|
t.Error("Sku is empty")
|
|
}
|
|
if item.Price <= 0 {
|
|
t.Errorf("Price = %v, want > 0", item.Price)
|
|
}
|
|
t.Logf("fetched %d sku=%s price=%v vat=%d discount=%d inStock=%d supplier=%q",
|
|
item.Id, item.Sku, item.Price, item.Vat, item.Discount, item.InStock, item.SupplierName)
|
|
}
|
|
|
|
func TestToItemAddMessage_Example8163(t *testing.T) {
|
|
requireService(t)
|
|
|
|
ctx := context.Background()
|
|
item, err := FetchItem(ctx, exampleId, "se")
|
|
if err != nil {
|
|
t.Fatalf("FetchItem(%s): %v", exampleId, err)
|
|
}
|
|
|
|
// Central stock (no storeId, no parent).
|
|
msg, err := ToItemAddMessage(item, nil, nil, 2, "se")
|
|
if err != nil {
|
|
t.Fatalf("ToItemAddMessage: %v", err)
|
|
}
|
|
|
|
if msg.ItemId != uint32(item.Id) {
|
|
t.Errorf("ItemId = %d, want %d", msg.ItemId, item.Id)
|
|
}
|
|
if msg.Quantity != 2 {
|
|
t.Errorf("Quantity = %d, want 2", msg.Quantity)
|
|
}
|
|
if want := int64(item.Price * 100); msg.Price != want {
|
|
t.Errorf("Price = %d, want %d (price*100)", msg.Price, want)
|
|
}
|
|
if msg.Sku != item.Sku {
|
|
t.Errorf("Sku = %q, want %q", msg.Sku, item.Sku)
|
|
}
|
|
if msg.Name == "" {
|
|
t.Error("Name is empty")
|
|
}
|
|
if want := int32(item.Vat * 100); msg.Tax != want {
|
|
t.Errorf("Tax = %d, want vat*100 = %d", msg.Tax, want)
|
|
}
|
|
if msg.Stock != item.InStock {
|
|
t.Errorf("Stock = %d, want central inStock %d", msg.Stock, item.InStock)
|
|
}
|
|
if msg.SellerName != item.SupplierName {
|
|
t.Errorf("SellerName = %q, want %q", msg.SellerName, item.SupplierName)
|
|
}
|
|
if want := strconv.Itoa(item.SupplierId); msg.SellerId != want {
|
|
t.Errorf("SellerId = %q, want %q", msg.SellerId, want)
|
|
}
|
|
|
|
// OrgPrice is reconstructed from the discount percent.
|
|
if item.Discount > 0 && item.Discount < 100 {
|
|
if msg.OrgPrice <= msg.Price {
|
|
t.Errorf("OrgPrice = %d, want > Price %d (discount %d%%)", msg.OrgPrice, msg.Price, item.Discount)
|
|
}
|
|
} else if msg.OrgPrice != 0 {
|
|
t.Errorf("OrgPrice = %d, want 0 (no usable discount)", msg.OrgPrice)
|
|
}
|
|
|
|
// Store stock currently resolves to zero (store-level stock not in payload).
|
|
storeId := "1234"
|
|
storeMsg, err := ToItemAddMessage(item, nil, &storeId, 1, "se")
|
|
if err != nil {
|
|
t.Fatalf("ToItemAddMessage(store): %v", err)
|
|
}
|
|
if storeMsg.Stock != 0 {
|
|
t.Errorf("store Stock = %d, want 0", storeMsg.Stock)
|
|
}
|
|
if storeMsg.StoreId == nil || *storeMsg.StoreId != storeId {
|
|
t.Errorf("StoreId = %v, want %q", storeMsg.StoreId, storeId)
|
|
}
|
|
|
|
if len(msg.ExtraJson) == 0 {
|
|
t.Error("expected ExtraJson to carry dynamic product data")
|
|
}
|
|
}
|
|
|
|
// TestDynamicData_RoundTripsToCartItem exercises the full path: fetch the
|
|
// product, build the AddItem mutation, apply it through the cart mutation
|
|
// registry, then render the cart exactly as the API does — asserting that a
|
|
// dynamic product key ("glas" for item 8163) is flattened onto the item.
|
|
func TestDynamicData_RoundTripsToCartItem(t *testing.T) {
|
|
requireService(t)
|
|
|
|
ctx := context.Background()
|
|
msg, err := GetItemAddMessage(ctx, exampleId, 1, "se", nil)
|
|
if err != nil {
|
|
t.Fatalf("GetItemAddMessage: %v", err)
|
|
}
|
|
|
|
reg := cart.NewCartMultationRegistry(cart.NewCartMutationContext(nil))
|
|
grain := cart.NewCartGrain(1, time.Now())
|
|
if _, err := reg.Apply(ctx, grain, msg); err != nil {
|
|
t.Fatalf("apply AddItem: %v", err)
|
|
}
|
|
|
|
// Marshal the grain the way the HTTP handlers do (WriteResult -> json).
|
|
b, err := json.Marshal(grain)
|
|
if err != nil {
|
|
t.Fatalf("marshal grain: %v", err)
|
|
}
|
|
|
|
var rendered struct {
|
|
Items []map[string]json.RawMessage `json:"items"`
|
|
}
|
|
if err := json.Unmarshal(b, &rendered); err != nil {
|
|
t.Fatalf("unmarshal grain: %v", err)
|
|
}
|
|
if len(rendered.Items) != 1 {
|
|
t.Fatalf("items = %d, want 1", len(rendered.Items))
|
|
}
|
|
|
|
item := rendered.Items[0]
|
|
// Typed field present.
|
|
if _, ok := item["sku"]; !ok {
|
|
t.Error("rendered item missing typed key \"sku\"")
|
|
}
|
|
// Dynamic key flattened onto the item and returned over the API.
|
|
glas, ok := item["glas"]
|
|
if !ok {
|
|
t.Fatalf("rendered item missing dynamic key \"glas\"; got keys %v", keysOf(item))
|
|
}
|
|
if string(glas) != `"V"` {
|
|
t.Errorf("glas = %s, want \"V\"", glas)
|
|
}
|
|
}
|
|
|
|
// TestSetCartItems_ParentWithChildren reproduces the reported scenario through
|
|
// the real SetCartItemsHandler: a parent SKU with three distinct children.
|
|
func TestSetCartItems_ParentWithChildren(t *testing.T) {
|
|
requireService(t)
|
|
|
|
s := newTestPoolServer(t)
|
|
body := `{"sku":"146620","quantity":1,"children":[{"sku":"170852","quantity":1},{"sku":"123075","quantity":1},{"sku":"123076","quantity":1}]}`
|
|
// The handler decodes SetCartItems{country, items:[Item]} — the reported
|
|
// payload is a single Item, so wrap it in the items array.
|
|
payload := `{"country":"se","items":[` + body + `]}`
|
|
|
|
rec := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodPost, "/cart/set", bytes.NewBufferString(payload))
|
|
if err := s.SetCartItemsHandler(rec, req, cart.CartId(42)); err != nil {
|
|
t.Fatalf("SetCartItemsHandler: %v", err)
|
|
}
|
|
|
|
type lineT struct {
|
|
Id uint32 `json:"id"`
|
|
ItemId uint32 `json:"itemId"`
|
|
ParentId *uint32 `json:"parentId"`
|
|
Sku string `json:"sku"`
|
|
}
|
|
// Handler returns a MutationResult {result:{items}, mutations}. Fall back to
|
|
// top-level items for the empty-cart path.
|
|
var resp struct {
|
|
Result struct {
|
|
Items []lineT `json:"items"`
|
|
} `json:"result"`
|
|
Items []lineT `json:"items"`
|
|
}
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
|
t.Fatalf("decode response: %v\nbody: %s", err, rec.Body.String())
|
|
}
|
|
grain := struct{ Items []lineT }{Items: resp.Result.Items}
|
|
if len(grain.Items) == 0 {
|
|
grain.Items = resp.Items
|
|
}
|
|
|
|
t.Logf("result has %d items:", len(grain.Items))
|
|
for _, it := range grain.Items {
|
|
t.Logf(" line %d itemId=%d sku=%s parentId=%v", it.Id, it.ItemId, it.Sku, it.ParentId)
|
|
}
|
|
if len(grain.Items) != 4 {
|
|
t.Fatalf("items = %d, want 4 (parent + 3 children)", len(grain.Items))
|
|
}
|
|
children := 0
|
|
for _, it := range grain.Items {
|
|
if it.ParentId != nil {
|
|
children++
|
|
}
|
|
}
|
|
if children != 3 {
|
|
t.Errorf("children = %d, want 3", children)
|
|
}
|
|
}
|
|
|
|
// TestAddSkuRequest_SingleItemWithChildren posts the bare single-item body (no
|
|
// items wrapper) to the single-add handler and asserts children are added.
|
|
func TestAddSkuRequest_SingleItemWithChildren(t *testing.T) {
|
|
requireService(t)
|
|
|
|
s := newTestPoolServer(t)
|
|
payload := `{"sku":"146620","quantity":1,"country":"se","children":[{"sku":"170852","quantity":1},{"sku":"123075","quantity":1},{"sku":"123076","quantity":1}]}`
|
|
|
|
rec := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodPost, "/cart", bytes.NewBufferString(payload))
|
|
if err := s.AddSkuRequestHandler(rec, req, cart.CartId(7)); err != nil {
|
|
t.Fatalf("AddSkuRequestHandler: %v", err)
|
|
}
|
|
|
|
var resp struct {
|
|
Result struct {
|
|
Items []struct {
|
|
ParentId *uint32 `json:"parentId"`
|
|
} `json:"items"`
|
|
} `json:"result"`
|
|
}
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
|
t.Fatalf("decode: %v\nbody: %s", err, rec.Body.String())
|
|
}
|
|
if len(resp.Result.Items) != 4 {
|
|
t.Fatalf("items = %d, want 4 (parent + 3 children)", len(resp.Result.Items))
|
|
}
|
|
}
|
|
|
|
func keysOf(m map[string]json.RawMessage) []string {
|
|
ks := make([]string, 0, len(m))
|
|
for k := range m {
|
|
ks = append(ks, k)
|
|
}
|
|
return ks
|
|
}
|
|
|
|
// TestChildren_ParentLinkage exercises the nested-children build + ordered apply:
|
|
// buildItemGroups fetches the parent product and prices the child against it,
|
|
// then parentLineId resolves the parent's line id and the child line links to it.
|
|
//
|
|
// Structural test: it reuses 8163 as both parent and child (child pinned to a
|
|
// store so it does not merge with the parent line). Real children would be
|
|
// distinct service/insurance/accessory SKUs.
|
|
func TestChildren_ParentLinkage(t *testing.T) {
|
|
requireService(t)
|
|
|
|
ctx := context.Background()
|
|
childStore := "1"
|
|
items := []Item{{
|
|
Sku: exampleId,
|
|
Quantity: 1,
|
|
Children: []Item{{Sku: exampleId, Quantity: 1, StoreId: &childStore}},
|
|
}}
|
|
|
|
groups := buildItemGroups(ctx, items, "se", nil)
|
|
if len(groups) != 1 {
|
|
t.Fatalf("groups = %d, want 1", len(groups))
|
|
}
|
|
if groups[0].parent == nil {
|
|
t.Fatal("parent message not built")
|
|
}
|
|
if len(groups[0].children) != 1 {
|
|
t.Fatalf("children = %d, want 1", len(groups[0].children))
|
|
}
|
|
|
|
// Apply through the real mutation registry to verify the linkage the handler
|
|
// performs (applyItemGroups does the same with pool.ApplyLocal).
|
|
reg := cart.NewCartMultationRegistry(cart.NewCartMutationContext(nil))
|
|
grain := cart.NewCartGrain(1, time.Now())
|
|
|
|
if _, err := reg.Apply(ctx, grain, groups[0].parent); err != nil {
|
|
t.Fatalf("apply parent: %v", err)
|
|
}
|
|
pid, ok := parentLineId(grain, groups[0].parent)
|
|
if !ok {
|
|
t.Fatal("parentLineId did not resolve the parent line")
|
|
}
|
|
|
|
child := groups[0].children[0]
|
|
child.ParentId = &pid
|
|
if _, err := reg.Apply(ctx, grain, child); err != nil {
|
|
t.Fatalf("apply child: %v", err)
|
|
}
|
|
|
|
if len(grain.Items) != 2 {
|
|
t.Fatalf("cart items = %d, want 2 (parent + child)", len(grain.Items))
|
|
}
|
|
|
|
var parentLine, childLine *cart.CartItem
|
|
for _, it := range grain.Items {
|
|
if it.ParentId == nil {
|
|
parentLine = it
|
|
} else {
|
|
childLine = it
|
|
}
|
|
}
|
|
if parentLine == nil || childLine == nil {
|
|
t.Fatalf("expected one parent and one child line, got %+v", grain.Items)
|
|
}
|
|
if *childLine.ParentId != parentLine.Id {
|
|
t.Errorf("child.ParentId = %d, want parent line id %d", *childLine.ParentId, parentLine.Id)
|
|
}
|
|
t.Logf("parent line %d, child line %d -> parent %d", parentLine.Id, childLine.Id, *childLine.ParentId)
|
|
}
|