fixes
Build and Publish / BuildAndDeployArm64 (push) Failing after 49s
Build and Publish / BuildAndDeployAmd64 (push) Has been cancelled

This commit is contained in:
2026-06-16 13:14:35 +02:00
parent faec360789
commit 60722e3414
29 changed files with 1946 additions and 455 deletions
@@ -0,0 +1,390 @@
//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/go-cart-actor/pkg/actor"
"git.k6n.net/go-cart-actor/pkg/cart"
"git.k6n.net/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")
}
// 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")
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)
}