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