fixes
This commit is contained in:
+95
-65
@@ -21,7 +21,6 @@ import (
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"github.com/prometheus/client_golang/prometheus/promauto"
|
||||
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||
"github.com/redis/go-redis/v9"
|
||||
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
|
||||
)
|
||||
|
||||
@@ -47,6 +46,23 @@ var amqpUrl = os.Getenv("AMQP_URL")
|
||||
var redisAddress = os.Getenv("REDIS_ADDRESS")
|
||||
var redisPassword = os.Getenv("REDIS_PASSWORD")
|
||||
|
||||
// getEnv returns the value of the environment variable named by key, or def if unset/empty.
|
||||
func getEnv(key, def string) string {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
return v
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
// normalizeListenAddr accepts either a bare port ("8080") or a full listen
|
||||
// address (":8080", "0.0.0.0:8080") and returns a value usable by http.Server.Addr.
|
||||
func normalizeListenAddr(v string) string {
|
||||
if strings.Contains(v, ":") {
|
||||
return v
|
||||
}
|
||||
return ":" + v
|
||||
}
|
||||
|
||||
func getCountryFromHost(host string) string {
|
||||
if strings.Contains(strings.ToLower(host), "-no") {
|
||||
return "no"
|
||||
@@ -83,6 +99,14 @@ func matchesSkuAndLocation(update inventory.InventoryResult, item cart.CartItem)
|
||||
|
||||
func main() {
|
||||
|
||||
// cartPort is the bare HTTP port. It drives both the local listener and the
|
||||
// port used to proxy to peer pods, so a cluster must run all pods on the
|
||||
// same CART_PORT.
|
||||
cartPort := getEnv("CART_PORT", "8080")
|
||||
if i := strings.LastIndex(cartPort, ":"); i >= 0 {
|
||||
cartPort = cartPort[i+1:]
|
||||
}
|
||||
|
||||
controlPlaneConfig := actor.DefaultServerConfig()
|
||||
|
||||
promotionData, err := promotions.LoadStateFile("data/promotions.json")
|
||||
@@ -92,25 +116,25 @@ func main() {
|
||||
|
||||
log.Printf("loaded %d promotions", len(promotionData.State.Promotions))
|
||||
|
||||
inventoryPubSub := actor.NewPubSub[inventory.InventoryChange]()
|
||||
//inventoryPubSub := actor.NewPubSub[inventory.InventoryChange]()
|
||||
|
||||
// promotionService := promotions.NewPromotionService(nil)
|
||||
rdb := redis.NewClient(&redis.Options{
|
||||
Addr: redisAddress,
|
||||
Password: redisPassword,
|
||||
DB: 0,
|
||||
})
|
||||
inventoryService, err := inventory.NewRedisInventoryService(rdb)
|
||||
if err != nil {
|
||||
log.Fatalf("Error creating inventory service: %v\n", err)
|
||||
}
|
||||
// rdb := redis.NewClient(&redis.Options{
|
||||
// Addr: redisAddress,
|
||||
// Password: redisPassword,
|
||||
// DB: 0,
|
||||
// })
|
||||
// inventoryService, err := inventory.NewRedisInventoryService(rdb)
|
||||
// if err != nil {
|
||||
// log.Fatalf("Error creating inventory service: %v\n", err)
|
||||
// }
|
||||
|
||||
inventoryReservationService, err := inventory.NewRedisCartReservationService(rdb)
|
||||
if err != nil {
|
||||
log.Fatalf("Error creating inventory reservation service: %v\n", err)
|
||||
}
|
||||
// inventoryReservationService, err := inventory.NewRedisCartReservationService(rdb)
|
||||
// if err != nil {
|
||||
// log.Fatalf("Error creating inventory reservation service: %v\n", err)
|
||||
// }
|
||||
|
||||
reg := cart.NewCartMultationRegistry(cart.NewCartMutationContext(inventoryReservationService))
|
||||
reg := cart.NewCartMultationRegistry(cart.NewCartMutationContext(nil))
|
||||
reg.RegisterProcessor(
|
||||
actor.NewMutationProcessor(func(ctx context.Context, g *cart.CartGrain) error {
|
||||
_, span := tracer.Start(ctx, "Totals and promotions")
|
||||
@@ -140,46 +164,46 @@ func main() {
|
||||
ret := cart.NewCartGrain(id, time.Now())
|
||||
// Set baseline lastChange at spawn; replay may update it to last event timestamp.
|
||||
|
||||
inventoryPubSub.Subscribe(ret.HandleInventoryChange)
|
||||
// inventoryPubSub.Subscribe(ret.HandleInventoryChange)
|
||||
err := diskStorage.LoadEvents(ctx, id, ret)
|
||||
if err == nil && inventoryService != nil {
|
||||
refs := make([]*inventory.InventoryReference, 0)
|
||||
for _, item := range ret.Items {
|
||||
refs = append(refs, &inventory.InventoryReference{
|
||||
SKU: inventory.SKU(item.Sku),
|
||||
LocationID: getLocationId(item),
|
||||
})
|
||||
}
|
||||
_, span := tracer.Start(ctx, "update inventory")
|
||||
defer span.End()
|
||||
res, err := inventoryService.GetInventoryBatch(ctx, refs...)
|
||||
if err != nil {
|
||||
log.Printf("unable to update inventory %v", err)
|
||||
} else {
|
||||
for _, update := range res {
|
||||
for _, item := range ret.Items {
|
||||
if matchesSkuAndLocation(update, *item) && update.Quantity != uint32(item.Stock) {
|
||||
// maybe apply an update to give visibility to the cart
|
||||
item.Stock = uint16(update.Quantity)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// if err == nil && inventoryService != nil {
|
||||
// refs := make([]*inventory.InventoryReference, 0)
|
||||
// for _, item := range ret.Items {
|
||||
// refs = append(refs, &inventory.InventoryReference{
|
||||
// SKU: inventory.SKU(item.Sku),
|
||||
// LocationID: getLocationId(item),
|
||||
// })
|
||||
// }
|
||||
// _, span := tracer.Start(ctx, "update inventory")
|
||||
// defer span.End()
|
||||
// res, err := inventoryService.GetInventoryBatch(ctx, refs...)
|
||||
// if err != nil {
|
||||
// log.Printf("unable to update inventory %v", err)
|
||||
// } else {
|
||||
// for _, update := range res {
|
||||
// for _, item := range ret.Items {
|
||||
// if matchesSkuAndLocation(update, *item) && update.Quantity != uint32(item.Stock) {
|
||||
// // maybe apply an update to give visibility to the cart
|
||||
// item.Stock = uint16(update.Quantity)
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
|
||||
return ret, err
|
||||
},
|
||||
Destroy: func(grain actor.Grain[cart.CartGrain]) error {
|
||||
cart, err := grain.GetCurrentState()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
inventoryPubSub.Unsubscribe(cart.HandleInventoryChange)
|
||||
// cart, err := grain.GetCurrentState()
|
||||
// if err != nil {
|
||||
// return err
|
||||
// }
|
||||
//inventoryPubSub.Unsubscribe(cart.HandleInventoryChange)
|
||||
|
||||
return nil
|
||||
},
|
||||
SpawnHost: func(host string) (actor.Host[cart.CartGrain], error) {
|
||||
return proxy.NewRemoteHost[cart.CartGrain](host)
|
||||
return proxy.NewRemoteHost[cart.CartGrain](host, cartPort)
|
||||
},
|
||||
TTL: 5 * time.Minute,
|
||||
PoolSize: 2 * 65535,
|
||||
@@ -191,7 +215,7 @@ func main() {
|
||||
log.Fatalf("Error creating cart pool: %v\n", err)
|
||||
}
|
||||
|
||||
syncedServer := NewPoolServer(pool, fmt.Sprintf("%s, %s", name, podIp), inventoryService, inventoryReservationService)
|
||||
syncedServer := NewPoolServer(pool, fmt.Sprintf("%s, %s", name, podIp)) //inventoryService, inventoryReservationService)
|
||||
|
||||
app := &App{
|
||||
pool: pool,
|
||||
@@ -262,10 +286,16 @@ func main() {
|
||||
|
||||
mux.HandleFunc("/openapi.json", ServeEmbeddedOpenAPI)
|
||||
|
||||
httpAddr := normalizeListenAddr(cartPort)
|
||||
debugAddr := normalizeListenAddr(getEnv("CART_DEBUG_PORT", "8081"))
|
||||
|
||||
srv := &http.Server{
|
||||
Addr: ":8080",
|
||||
BaseContext: func(net.Listener) context.Context { return ctx },
|
||||
ReadTimeout: 10 * time.Second,
|
||||
Addr: httpAddr,
|
||||
BaseContext: func(net.Listener) context.Context { return ctx },
|
||||
ReadTimeout: 10 * time.Second,
|
||||
// Close idle keep-alive connections so a load test with many short-lived
|
||||
// clients doesn't pin file handles open indefinitely.
|
||||
IdleTimeout: 60 * time.Second,
|
||||
WriteTimeout: 20 * time.Second,
|
||||
Handler: otelhttp.NewHandler(mux, "/"),
|
||||
}
|
||||
@@ -284,23 +314,23 @@ func main() {
|
||||
srvErr <- srv.ListenAndServe()
|
||||
}()
|
||||
|
||||
listener := inventory.NewInventoryChangeListener(rdb, context.Background(), func(changes []inventory.InventoryChange) {
|
||||
for _, change := range changes {
|
||||
log.Printf("inventory change: %v", change)
|
||||
inventoryPubSub.Publish(change)
|
||||
}
|
||||
})
|
||||
// listener := inventory.NewInventoryChangeListener(rdb, context.Background(), func(changes []inventory.InventoryChange) {
|
||||
// for _, change := range changes {
|
||||
// log.Printf("inventory change: %v", change)
|
||||
// inventoryPubSub.Publish(change)
|
||||
// }
|
||||
// })
|
||||
|
||||
go func() {
|
||||
err := listener.Start()
|
||||
if err != nil {
|
||||
log.Fatalf("Unable to start inventory listener: %v", err)
|
||||
}
|
||||
}()
|
||||
// go func() {
|
||||
// err := listener.Start()
|
||||
// if err != nil {
|
||||
// log.Fatalf("Unable to start inventory listener: %v", err)
|
||||
// }
|
||||
// }()
|
||||
|
||||
log.Print("Server started at port 8080")
|
||||
log.Printf("Server started at %s (debug %s)", httpAddr, debugAddr)
|
||||
|
||||
go http.ListenAndServe(":8081", debugMux)
|
||||
go http.ListenAndServe(debugAddr, debugMux)
|
||||
|
||||
select {
|
||||
case err = <-srvErr:
|
||||
|
||||
+81
-4
@@ -358,6 +358,40 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/cart/item/{itemId}/custom-fields": {
|
||||
"put": {
|
||||
"summary": "Set/merge custom input fields on a line item",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "itemId",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": { "type": "integer", "format": "int64", "minimum": 0 },
|
||||
"description": "Internal cart line item identifier."
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": { "$ref": "#/components/schemas/SetCustomFieldsRequest" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Custom fields updated",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": { "$ref": "#/components/schemas/CartGrain" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": { "description": "Invalid body or id" },
|
||||
"500": { "description": "Server error" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"/cart/item/{itemId}/marking": {
|
||||
"put": {
|
||||
"summary": "Set marking for line item",
|
||||
@@ -955,10 +989,14 @@
|
||||
},
|
||||
"CartItem": {
|
||||
"type": "object",
|
||||
"description": "Cart line item. Beyond the typed properties below, arbitrary dynamic product data is flattened onto the object as additional top-level keys (see additionalProperties).",
|
||||
"properties": {
|
||||
"id": { "type": "integer" },
|
||||
"itemId": { "type": "integer" },
|
||||
"parentId": { "type": "integer" },
|
||||
"parentId": {
|
||||
"type": "integer",
|
||||
"description": "Line-item id (the `id` field) of the parent item, set when this line is a child/sub-article"
|
||||
},
|
||||
"sku": { "type": "string" },
|
||||
"price": { "$ref": "#/components/schemas/Price" },
|
||||
"totalPrice": { "$ref": "#/components/schemas/Price" },
|
||||
@@ -975,11 +1013,19 @@
|
||||
"meta": { "$ref": "#/components/schemas/ItemMeta" },
|
||||
"saleStatus": { "type": "string" },
|
||||
"marking": { "$ref": "#/components/schemas/Marking" },
|
||||
"customFields": {
|
||||
"type": "object",
|
||||
"additionalProperties": { "type": "string" },
|
||||
"description": "User-supplied custom input fields for this line"
|
||||
},
|
||||
"subscriptionDetailsId": { "type": "string" },
|
||||
"orderReference": { "type": "string" },
|
||||
"isSubscribed": { "type": "boolean" }
|
||||
},
|
||||
"required": ["id", "sku", "price", "qty"]
|
||||
"required": ["id", "sku", "price", "qty"],
|
||||
"additionalProperties": {
|
||||
"description": "Dynamic product data carried through verbatim (e.g. glas, hangning, materialkular). Value can be any JSON type."
|
||||
}
|
||||
},
|
||||
"CartDelivery": {
|
||||
"type": "object",
|
||||
@@ -1021,7 +1067,17 @@
|
||||
"type": "string",
|
||||
"description": "Two-letter country code (inferred if omitted)"
|
||||
},
|
||||
"storeId": { "type": "string", "nullable": true }
|
||||
"storeId": { "type": "string", "nullable": true },
|
||||
"children": {
|
||||
"type": "array",
|
||||
"description": "Sub-articles (accessories, services, insurance, ...) added as separate cart lines whose parentId points to this item's line, and priced relative to this parent item.",
|
||||
"items": { "$ref": "#/components/schemas/Item" }
|
||||
},
|
||||
"customFields": {
|
||||
"type": "object",
|
||||
"additionalProperties": { "type": "string" },
|
||||
"description": "Optional user-supplied input fields for this line"
|
||||
}
|
||||
},
|
||||
"required": ["sku"]
|
||||
},
|
||||
@@ -1042,7 +1098,17 @@
|
||||
"properties": {
|
||||
"sku": { "type": "string" },
|
||||
"quantity": { "type": "integer", "minimum": 1 },
|
||||
"storeId": { "type": "string", "nullable": true }
|
||||
"storeId": { "type": "string", "nullable": true },
|
||||
"children": {
|
||||
"type": "array",
|
||||
"description": "Sub-articles (accessories, services, insurance, ...). Each is added as a separate cart line whose parentId points to this item's line, and is priced relative to this parent item.",
|
||||
"items": { "$ref": "#/components/schemas/Item" }
|
||||
},
|
||||
"customFields": {
|
||||
"type": "object",
|
||||
"additionalProperties": { "type": "string" },
|
||||
"description": "Optional user-supplied input fields for this line"
|
||||
}
|
||||
},
|
||||
"required": ["sku", "quantity"]
|
||||
},
|
||||
@@ -1121,6 +1187,17 @@
|
||||
},
|
||||
"required": ["type", "text"]
|
||||
},
|
||||
"SetCustomFieldsRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"customFields": {
|
||||
"type": "object",
|
||||
"additionalProperties": { "type": "string" },
|
||||
"description": "Fields to upsert on the line; keys not present are left untouched"
|
||||
}
|
||||
},
|
||||
"required": ["customFields"]
|
||||
},
|
||||
"Notice": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
+183
-38
@@ -15,7 +15,6 @@ import (
|
||||
messages "git.k6n.net/go-cart-actor/proto/cart"
|
||||
|
||||
"git.k6n.net/go-cart-actor/pkg/voucher"
|
||||
"github.com/matst80/go-redis-inventory/pkg/inventory"
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"github.com/prometheus/client_golang/prometheus/promauto"
|
||||
"go.opentelemetry.io/contrib/bridges/otelslog"
|
||||
@@ -42,17 +41,13 @@ var (
|
||||
|
||||
type PoolServer struct {
|
||||
actor.GrainPool[cart.CartGrain]
|
||||
pod_name string
|
||||
inventoryService inventory.InventoryService
|
||||
reservationService inventory.CartReservationService
|
||||
pod_name string
|
||||
}
|
||||
|
||||
func NewPoolServer(pool actor.GrainPool[cart.CartGrain], pod_name string, inventoryService inventory.InventoryService, inventoryReservationService inventory.CartReservationService) *PoolServer {
|
||||
func NewPoolServer(pool actor.GrainPool[cart.CartGrain], pod_name string) *PoolServer {
|
||||
srv := &PoolServer{
|
||||
GrainPool: pool,
|
||||
pod_name: pod_name,
|
||||
inventoryService: inventoryService,
|
||||
reservationService: inventoryReservationService,
|
||||
GrainPool: pool,
|
||||
pod_name: pod_name,
|
||||
}
|
||||
|
||||
return srv
|
||||
@@ -134,6 +129,12 @@ type Item struct {
|
||||
Sku string `json:"sku"`
|
||||
Quantity int `json:"quantity"`
|
||||
StoreId *string `json:"storeId,omitempty"`
|
||||
// Children are sub-articles (accessories, services, insurance, ...) priced
|
||||
// relative to this item. They are added as separate cart lines whose
|
||||
// ParentId points to this item's line-item id.
|
||||
Children []Item `json:"children,omitempty"`
|
||||
// CustomFields are optional user-supplied input fields for this line.
|
||||
CustomFields map[string]string `json:"customFields,omitempty"`
|
||||
}
|
||||
|
||||
type SetCartItems struct {
|
||||
@@ -141,25 +142,116 @@ type SetCartItems struct {
|
||||
Items []Item `json:"items"`
|
||||
}
|
||||
|
||||
func getMultipleAddMessages(ctx context.Context, items []Item, country string) []proto.Message {
|
||||
// itemGroup is a top-level item together with its child sub-articles. The child
|
||||
// AddItem messages are built with their price already derived from the parent
|
||||
// product; their ParentId is filled in after the parent line is applied.
|
||||
type itemGroup struct {
|
||||
parent *messages.AddItem
|
||||
children []*messages.AddItem
|
||||
}
|
||||
|
||||
// buildItemGroups fetches every product and builds the AddItem messages. Groups
|
||||
// are built concurrently; within a group the parent is fetched first so it can
|
||||
// drive child pricing, then children are fetched concurrently. Input order is
|
||||
// preserved. Items that fail to fetch are skipped (logged), matching the prior
|
||||
// best-effort behavior.
|
||||
func buildItemGroups(ctx context.Context, items []Item, country string) []itemGroup {
|
||||
groups := make([]itemGroup, len(items))
|
||||
wg := sync.WaitGroup{}
|
||||
mu := sync.Mutex{}
|
||||
msgs := make([]proto.Message, 0, len(items))
|
||||
for _, itm := range items {
|
||||
wg.Go(
|
||||
func() {
|
||||
msg, err := GetItemAddMessage(ctx, itm.Sku, itm.Quantity, country, itm.StoreId)
|
||||
if err != nil {
|
||||
log.Printf("error adding item %s: %v", itm.Sku, err)
|
||||
return
|
||||
for i, itm := range items {
|
||||
wg.Go(func() {
|
||||
parentMsg, parentProduct, err := BuildItemMessage(ctx, itm.Sku, itm.Quantity, country, itm.StoreId, nil)
|
||||
if err != nil {
|
||||
log.Printf("error adding item %s: %v", itm.Sku, err)
|
||||
return
|
||||
}
|
||||
parentMsg.CustomFields = itm.CustomFields
|
||||
groups[i].parent = parentMsg
|
||||
|
||||
if len(itm.Children) == 0 {
|
||||
return
|
||||
}
|
||||
children := make([]*messages.AddItem, len(itm.Children))
|
||||
cwg := sync.WaitGroup{}
|
||||
for j, child := range itm.Children {
|
||||
cwg.Go(func() {
|
||||
childMsg, _, err := BuildItemMessage(ctx, child.Sku, child.Quantity, country, child.StoreId, parentProduct)
|
||||
if err != nil {
|
||||
log.Printf("error adding child %s of %s: %v", child.Sku, itm.Sku, err)
|
||||
return
|
||||
}
|
||||
childMsg.CustomFields = child.CustomFields
|
||||
children[j] = childMsg
|
||||
})
|
||||
}
|
||||
cwg.Wait()
|
||||
for _, c := range children {
|
||||
if c != nil {
|
||||
groups[i].children = append(groups[i].children, c)
|
||||
}
|
||||
mu.Lock()
|
||||
msgs = append(msgs, msg)
|
||||
mu.Unlock()
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
wg.Wait()
|
||||
return msgs
|
||||
return groups
|
||||
}
|
||||
|
||||
// applyItemGroups applies each group in dependency order: the parent first, then
|
||||
// its children with ParentId set to the parent's resolved line-item id. Returns
|
||||
// the last mutation result for rendering.
|
||||
func (s *PoolServer) applyItemGroups(ctx context.Context, id cart.CartId, groups []itemGroup) (*actor.MutationResult[cart.CartGrain], error) {
|
||||
var last *actor.MutationResult[cart.CartGrain]
|
||||
for _, g := range groups {
|
||||
if g.parent == nil {
|
||||
continue
|
||||
}
|
||||
res, err := s.ApplyLocal(ctx, id, g.parent)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
last = res
|
||||
|
||||
if len(g.children) == 0 {
|
||||
continue
|
||||
}
|
||||
parentLineId, ok := parentLineId(&res.Result, g.parent)
|
||||
if !ok {
|
||||
log.Printf("could not resolve parent line for item %d (sku %s); skipping %d children",
|
||||
g.parent.ItemId, g.parent.Sku, len(g.children))
|
||||
continue
|
||||
}
|
||||
childMsgs := make([]proto.Message, len(g.children))
|
||||
for i, c := range g.children {
|
||||
c.ParentId = &parentLineId
|
||||
childMsgs[i] = c
|
||||
}
|
||||
res, err = s.ApplyLocal(ctx, id, childMsgs...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
last = res
|
||||
}
|
||||
return last, nil
|
||||
}
|
||||
|
||||
// parentLineId finds the line-item id of the just-applied parent: the resident
|
||||
// item with the parent's catalog ItemId, matching store, and no parent of its
|
||||
// own. AddItem merges by sku+store, so at most one such line exists.
|
||||
func parentLineId(grain *cart.CartGrain, parent *messages.AddItem) (uint32, bool) {
|
||||
for _, it := range grain.Items {
|
||||
if it == nil || it.ParentId != nil {
|
||||
continue
|
||||
}
|
||||
if it.ItemId != parent.ItemId {
|
||||
continue
|
||||
}
|
||||
sameStore := (it.StoreId == nil && parent.StoreId == nil) ||
|
||||
(it.StoreId != nil && parent.StoreId != nil && *it.StoreId == *parent.StoreId)
|
||||
if sameStore {
|
||||
return it.Id, true
|
||||
}
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
func (s *PoolServer) SetCartItemsHandler(w http.ResponseWriter, r *http.Request, id cart.CartId) error {
|
||||
@@ -169,14 +261,22 @@ func (s *PoolServer) SetCartItemsHandler(w http.ResponseWriter, r *http.Request,
|
||||
return err
|
||||
}
|
||||
|
||||
msgs := make([]proto.Message, 0, len(setCartItems.Items)+1)
|
||||
msgs = append(msgs, &messages.ClearCartRequest{})
|
||||
msgs = append(msgs, getMultipleAddMessages(r.Context(), setCartItems.Items, setCartItems.Country)...)
|
||||
|
||||
reply, err := s.ApplyLocal(r.Context(), id, msgs...)
|
||||
if _, err := s.ApplyLocal(r.Context(), id, &messages.ClearCartRequest{}); err != nil {
|
||||
return err
|
||||
}
|
||||
groups := buildItemGroups(r.Context(), setCartItems.Items, setCartItems.Country)
|
||||
reply, err := s.applyItemGroups(r.Context(), id, groups)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if reply == nil {
|
||||
// Cart was cleared but nothing added; return current (empty) state.
|
||||
grain, err := s.Get(r.Context(), uint64(id))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.WriteResult(w, grain)
|
||||
}
|
||||
return s.WriteResult(w, reply)
|
||||
}
|
||||
|
||||
@@ -187,12 +287,18 @@ func (s *PoolServer) AddMultipleItemHandler(w http.ResponseWriter, r *http.Reque
|
||||
return err
|
||||
}
|
||||
|
||||
msgs := getMultipleAddMessages(r.Context(), setCartItems.Items, setCartItems.Country)
|
||||
|
||||
reply, err := s.ApplyLocal(r.Context(), id, msgs...)
|
||||
groups := buildItemGroups(r.Context(), setCartItems.Items, setCartItems.Country)
|
||||
reply, err := s.applyItemGroups(r.Context(), id, groups)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if reply == nil {
|
||||
grain, err := s.Get(r.Context(), uint64(id))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.WriteResult(w, grain)
|
||||
}
|
||||
return s.WriteResult(w, reply)
|
||||
}
|
||||
|
||||
@@ -201,6 +307,10 @@ type AddRequest struct {
|
||||
Quantity int32 `json:"quantity"`
|
||||
Country string `json:"country"`
|
||||
StoreId *string `json:"storeId"`
|
||||
// Children are sub-articles priced relative to this item (see Item.Children).
|
||||
Children []Item `json:"children,omitempty"`
|
||||
// CustomFields are optional user-supplied input fields for this line.
|
||||
CustomFields map[string]string `json:"customFields,omitempty"`
|
||||
}
|
||||
|
||||
func (s *PoolServer) AddSkuRequestHandler(w http.ResponseWriter, r *http.Request, id cart.CartId) error {
|
||||
@@ -209,16 +319,26 @@ func (s *PoolServer) AddSkuRequestHandler(w http.ResponseWriter, r *http.Request
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
msg, err := GetItemAddMessage(r.Context(), addRequest.Sku, int(addRequest.Quantity), addRequest.Country, addRequest.StoreId)
|
||||
|
||||
item := Item{
|
||||
Sku: addRequest.Sku,
|
||||
Quantity: int(addRequest.Quantity),
|
||||
StoreId: addRequest.StoreId,
|
||||
Children: addRequest.Children,
|
||||
CustomFields: addRequest.CustomFields,
|
||||
}
|
||||
groups := buildItemGroups(r.Context(), []Item{item}, addRequest.Country)
|
||||
reply, err := s.applyItemGroups(r.Context(), id, groups)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
reply, err := s.ApplyLocal(r.Context(), id, msg)
|
||||
if err != nil {
|
||||
return err
|
||||
if reply == nil {
|
||||
grain, err := s.Get(r.Context(), uint64(id))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.WriteResult(w, grain)
|
||||
}
|
||||
|
||||
return s.WriteResult(w, reply)
|
||||
}
|
||||
|
||||
@@ -436,6 +556,29 @@ func (s *PoolServer) RemoveLineItemMarkingHandler(w http.ResponseWriter, r *http
|
||||
return s.WriteResult(w, reply)
|
||||
}
|
||||
|
||||
type SetCustomFieldsRequest struct {
|
||||
CustomFields map[string]string `json:"customFields"`
|
||||
}
|
||||
|
||||
func (s *PoolServer) SetCustomFieldsHandler(w http.ResponseWriter, r *http.Request, cartId cart.CartId) error {
|
||||
itemId, err := strconv.ParseInt(r.PathValue("itemId"), 10, 64)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req := SetCustomFieldsRequest{}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
return err
|
||||
}
|
||||
reply, err := s.ApplyLocal(r.Context(), cartId, &messages.SetLineItemCustomFields{
|
||||
Id: uint32(itemId),
|
||||
CustomFields: req.CustomFields,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.WriteResult(w, reply)
|
||||
}
|
||||
|
||||
func (s *PoolServer) InternalApplyMutationHandler(w http.ResponseWriter, r *http.Request, cartId cart.CartId) error {
|
||||
if r.Method != http.MethodPost {
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
@@ -513,6 +656,7 @@ func (s *PoolServer) Serve(mux *http.ServeMux) {
|
||||
|
||||
handleFunc("PUT /cart/item/{itemId}/marking", CookieCartIdHandler(s.ProxyHandler(s.LineItemMarkingHandler)))
|
||||
handleFunc("DELETE /cart/item/{itemId}/marking", CookieCartIdHandler(s.ProxyHandler(s.RemoveLineItemMarkingHandler)))
|
||||
handleFunc("PUT /cart/item/{itemId}/custom-fields", CookieCartIdHandler(s.ProxyHandler(s.SetCustomFieldsHandler)))
|
||||
|
||||
//mux.HandleFunc("GET /cart/checkout", CookieCartIdHandler(s.ProxyHandler(s.HandleCheckout)))
|
||||
//mux.HandleFunc("GET /cart/confirmation/{orderId}", CookieCartIdHandler(s.ProxyHandler(s.HandleConfirmation)))
|
||||
@@ -527,4 +671,5 @@ func (s *PoolServer) Serve(mux *http.ServeMux) {
|
||||
handleFunc("PUT /cart/byid/{id}/user", CartIdHandler(s.ProxyHandler(s.SetUserIdHandler)))
|
||||
handleFunc("PUT /cart/byid/{id}/item/{itemId}/marking", CartIdHandler(s.ProxyHandler(s.LineItemMarkingHandler)))
|
||||
handleFunc("DELETE /cart/byid/{id}/item/{itemId}/marking", CartIdHandler(s.ProxyHandler(s.RemoveLineItemMarkingHandler)))
|
||||
handleFunc("PUT /cart/byid/{id}/item/{itemId}/custom-fields", CartIdHandler(s.ProxyHandler(s.SetCustomFieldsHandler)))
|
||||
}
|
||||
|
||||
+180
-111
@@ -4,34 +4,77 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"git.k6n.net/go-cart-actor/pkg/cart"
|
||||
messages "git.k6n.net/go-cart-actor/proto/cart"
|
||||
|
||||
"github.com/matst80/slask-finder/pkg/index"
|
||||
)
|
||||
|
||||
// TODO make this configurable
|
||||
func getBaseUrl(country string) string {
|
||||
// if country == "se" {
|
||||
// return "http://s10n-se:8080"
|
||||
// }
|
||||
if country == "no" {
|
||||
return "http://s10n-no.s10n:8080"
|
||||
}
|
||||
if country == "se" {
|
||||
return "http://s10n-se.s10n:8080"
|
||||
}
|
||||
return "http://s10n-se.s10n:8080"
|
||||
// consumedKeys are product-document keys that are mapped to typed AddItem
|
||||
// fields. They are stripped from the dynamic remainder so Extra only holds
|
||||
// genuinely dynamic data.
|
||||
var consumedKeys = []string{
|
||||
"id", "sku", "title", "img", "price", "vat",
|
||||
"discount", "inStock", "supplierId", "supplierName", "deleted",
|
||||
}
|
||||
|
||||
func FetchItem(ctx context.Context, sku string, country string) (*index.DataItem, error) {
|
||||
// getBaseUrl returns the product service base url. Overridable via
|
||||
// PRODUCT_BASE_URL (the country argument is currently unused but kept for when
|
||||
// per-market routing returns).
|
||||
func getBaseUrl(country string) string {
|
||||
return getEnv("PRODUCT_BASE_URL", "http://localhost:8082")
|
||||
}
|
||||
|
||||
// ProductItem is the flat product document served by GET /api/get/{sku}.
|
||||
// Only the fields the cart currently maps mechanically are decoded; the rest
|
||||
// of the (dynamic) document is intentionally ignored until the cart item model
|
||||
// is reworked to carry arbitrary data.
|
||||
type ProductItem struct {
|
||||
Id uint64 `json:"id"`
|
||||
Sku string `json:"sku"`
|
||||
Title string `json:"title"`
|
||||
Img string `json:"img"`
|
||||
Price float64 `json:"price"`
|
||||
Vat int `json:"vat"`
|
||||
Discount int `json:"discount"` // percent off the original price
|
||||
InStock int32 `json:"inStock"`
|
||||
SupplierId int `json:"supplierId"`
|
||||
SupplierName string `json:"supplierName"`
|
||||
Deleted bool `json:"deleted"`
|
||||
|
||||
// Extra is the rest of the product document (everything not mapped to a
|
||||
// typed field above), preserved verbatim and surfaced on the cart item.
|
||||
Extra map[string]json.RawMessage `json:"-"`
|
||||
}
|
||||
|
||||
// numberField reads a numeric dynamic property from the product document
|
||||
// (width, height, ...). Like JS Number(), it accepts a JSON number or a numeric
|
||||
// string. Returns false when the key is absent or not numeric.
|
||||
func (p *ProductItem) numberField(key string) (float64, bool) {
|
||||
raw, ok := p.Extra[key]
|
||||
if !ok {
|
||||
return 0, false
|
||||
}
|
||||
var f float64
|
||||
if err := json.Unmarshal(raw, &f); err == nil {
|
||||
return f, true
|
||||
}
|
||||
var s string
|
||||
if err := json.Unmarshal(raw, &s); err == nil {
|
||||
if f, err := strconv.ParseFloat(s, 64); err == nil {
|
||||
return f, true
|
||||
}
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
func FetchItem(ctx context.Context, sku string, country string) (*ProductItem, error) {
|
||||
baseUrl := getBaseUrl(country)
|
||||
req, err := http.NewRequest("GET", fmt.Sprintf("%s/api/by-sku/%s", baseUrl, sku), nil)
|
||||
innerCtx, span := tracer.Start(ctx, fmt.Sprintf("fetching data for %s", sku))
|
||||
defer span.End()
|
||||
req = req.WithContext(innerCtx)
|
||||
req, err := http.NewRequestWithContext(innerCtx, http.MethodGet, fmt.Sprintf("%s/api/get/%s", baseUrl, sku), nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -40,111 +83,137 @@ func FetchItem(ctx context.Context, sku string, country string) (*index.DataItem
|
||||
return nil, err
|
||||
}
|
||||
defer res.Body.Close()
|
||||
var item index.DataItem
|
||||
err = json.NewDecoder(res.Body).Decode(&item)
|
||||
return &item, err
|
||||
if res.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("product service returned %d for sku %s", res.StatusCode, sku)
|
||||
}
|
||||
body, err := io.ReadAll(res.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var item ProductItem
|
||||
if err := json.Unmarshal(body, &item); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Capture everything not mapped to a typed field as dynamic data.
|
||||
all := map[string]json.RawMessage{}
|
||||
if err := json.Unmarshal(body, &all); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, k := range consumedKeys {
|
||||
delete(all, k)
|
||||
}
|
||||
if len(all) > 0 {
|
||||
item.Extra = all
|
||||
}
|
||||
|
||||
return &item, nil
|
||||
}
|
||||
|
||||
func GetItemAddMessage(ctx context.Context, sku string, qty int, country string, storeId *string) (*messages.AddItem, error) {
|
||||
msg, _, err := BuildItemMessage(ctx, sku, qty, country, storeId, nil)
|
||||
return msg, err
|
||||
}
|
||||
|
||||
// BuildItemMessage fetches a product and builds its AddItem mutation, returning
|
||||
// the fetched product too so it can serve as the parent for child items. When
|
||||
// parent is non-nil the line is priced as a child of that parent.
|
||||
func BuildItemMessage(ctx context.Context, sku string, qty int, country string, storeId *string, parent *ProductItem) (*messages.AddItem, *ProductItem, error) {
|
||||
item, err := FetchItem(ctx, sku, country)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, nil, err
|
||||
}
|
||||
return ToItemAddMessage(item, storeId, qty, country)
|
||||
}
|
||||
|
||||
func ToItemAddMessage(item *index.DataItem, storeId *string, qty int, country string) (*messages.AddItem, error) {
|
||||
orgPrice, _ := getInt(item.GetNumberFieldValue(5)) // getInt(item.Fields[5])
|
||||
|
||||
price, err := getInt(item.GetNumberFieldValue(4)) //Fields[4]
|
||||
msg, err := ToItemAddMessage(item, parent, storeId, qty, country)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, nil, err
|
||||
}
|
||||
stk := item.GetStock()
|
||||
stock := cart.StockStatus(0)
|
||||
return msg, item, nil
|
||||
}
|
||||
|
||||
// Glass is billed at a minimum surface even when the real pane is smaller, so a
|
||||
// small window doesn't get a near-zero per-m² price.
|
||||
const minGlassArea = 0.4 // m²
|
||||
|
||||
// areaFromItem derives the main article's *visible glass* surface in m² from a
|
||||
// resolved configurator selection. The catalog width/height are facet codes;
|
||||
// the outer (karmytter) size is `code × 100 − margin` mm and the visible glass
|
||||
// is that minus the frame border per axis, e.g. width 4, widthMargin 20,
|
||||
// borderWidth 194 → 400 − 20 − 194 = 186 mm. Area = glassW(mm) × glassH(mm) /
|
||||
// 1e6, clamped up to the minGlassArea billing floor. Returns 0 when a dimension
|
||||
// is missing/non-numeric (non-window PDP, or no product resolved yet).
|
||||
func areaFromItem(item *ProductItem) float64 {
|
||||
if item == nil {
|
||||
return 0
|
||||
}
|
||||
w, okW := item.numberField("width")
|
||||
h, okH := item.numberField("height")
|
||||
if !okW || !okH || w <= 0 || h <= 0 {
|
||||
return 0
|
||||
}
|
||||
glassW := w * 100 // - (item.numberField("widthMargin") + item.numberField("borderWidth"))
|
||||
glassH := h * 100 // - (item.numberField("heightMargin") + item.numberField("borderHeight"))
|
||||
if glassW <= 0 || glassH <= 0 {
|
||||
return 0
|
||||
}
|
||||
return math.Max((glassW*glassH)/1_000_000, minGlassArea)
|
||||
}
|
||||
|
||||
// accessoryPrice computes a child line's unit price (inc vat, minor units): the
|
||||
// child's own per-m² price scaled by the parent window's billed glass area.
|
||||
// When the parent has no usable dimensions the area is 0, so the price is 0.
|
||||
func accessoryPrice(parent *ProductItem, child *ProductItem) int64 {
|
||||
area := areaFromItem(parent)
|
||||
return int64(child.Price*100*area + 0.5)
|
||||
}
|
||||
|
||||
// orgPriceFromDiscount reconstructs the pre-discount price from a percentage.
|
||||
// Returns 0 when there is no usable discount, in which case OrgPrice is omitted.
|
||||
func orgPriceFromDiscount(price int64, discountPercent int) int64 {
|
||||
if discountPercent <= 0 || discountPercent >= 100 {
|
||||
return 0
|
||||
}
|
||||
return int64(float64(price)/(1-float64(discountPercent)/100) + 0.5)
|
||||
}
|
||||
|
||||
func ToItemAddMessage(item *ProductItem, parent *ProductItem, storeId *string, qty int, country string) (*messages.AddItem, error) {
|
||||
// Central stock only: store-specific stock is no longer in the payload, so a
|
||||
// storeId request currently resolves to zero stock.
|
||||
var stock int32
|
||||
if storeId == nil {
|
||||
centralStock, ok := stk[country]
|
||||
if ok {
|
||||
if !item.Buyable {
|
||||
return nil, fmt.Errorf("item not available")
|
||||
}
|
||||
stock = item.InStock
|
||||
}
|
||||
|
||||
if centralStock == 0 && item.SaleStatus == "TBD" {
|
||||
return nil, fmt.Errorf("no items available")
|
||||
}
|
||||
stock = cart.StockStatus(centralStock)
|
||||
// Top-level items price from their own product; children are priced from the
|
||||
// parent product via the accessory-price hook.
|
||||
price := int64(item.Price * 100)
|
||||
if parent != nil {
|
||||
price = accessoryPrice(parent, item)
|
||||
}
|
||||
|
||||
msg := &messages.AddItem{
|
||||
ItemId: uint32(item.Id),
|
||||
Quantity: int32(qty),
|
||||
Price: price,
|
||||
OrgPrice: orgPriceFromDiscount(price, item.Discount),
|
||||
Sku: item.Sku,
|
||||
Name: item.Title,
|
||||
Image: item.Img,
|
||||
Stock: stock,
|
||||
Tax: int32(item.Vat * 100),
|
||||
SellerId: strconv.Itoa(item.SupplierId),
|
||||
SellerName: item.SupplierName,
|
||||
Country: country,
|
||||
StoreId: storeId,
|
||||
}
|
||||
|
||||
if len(item.Extra) > 0 {
|
||||
extra, err := json.Marshal(item.Extra)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshal extra product data: %w", err)
|
||||
}
|
||||
|
||||
} else {
|
||||
if !item.BuyableInStore {
|
||||
return nil, fmt.Errorf("item not available in store")
|
||||
}
|
||||
storeStock, ok := stk[*storeId]
|
||||
if ok {
|
||||
stock = cart.StockStatus(storeStock)
|
||||
}
|
||||
|
||||
msg.ExtraJson = extra
|
||||
}
|
||||
|
||||
articleType, _ := item.GetStringFieldValue(1) //.Fields[1].(string)
|
||||
outletGrade, ok := item.GetStringFieldValue(20) //.Fields[20].(string)
|
||||
var outlet *string
|
||||
if ok {
|
||||
outlet = &outletGrade
|
||||
}
|
||||
sellerId, _ := item.GetStringFieldValue(24) // .Fields[24].(string)
|
||||
sellerName, _ := item.GetStringFieldValue(9) // .Fields[9].(string)
|
||||
|
||||
brand, _ := item.GetStringFieldValue(2) //.Fields[2].(string)
|
||||
category, _ := item.GetStringFieldValue(10) //.Fields[10].(string)
|
||||
category2, _ := item.GetStringFieldValue(11) //.Fields[11].(string)
|
||||
category3, _ := item.GetStringFieldValue(12) //.Fields[12].(string)
|
||||
category4, _ := item.GetStringFieldValue(13) //Fields[13].(string)
|
||||
category5, _ := item.GetStringFieldValue(14) //.Fields[14].(string)
|
||||
|
||||
cgm, _ := item.GetStringFieldValue(35) // Customer Group Membership
|
||||
|
||||
return &messages.AddItem{
|
||||
ItemId: uint32(item.Id),
|
||||
Quantity: int32(qty),
|
||||
Price: int64(price),
|
||||
OrgPrice: int64(orgPrice),
|
||||
Sku: item.GetSku(),
|
||||
Name: item.Title,
|
||||
Image: item.Img,
|
||||
Stock: int32(stock),
|
||||
Brand: brand,
|
||||
Category: category,
|
||||
Category2: category2,
|
||||
Category3: category3,
|
||||
Category4: category4,
|
||||
Category5: category5,
|
||||
Tax: getTax(articleType),
|
||||
SellerId: sellerId,
|
||||
SellerName: sellerName,
|
||||
ArticleType: articleType,
|
||||
Disclaimer: item.Disclaimer,
|
||||
Country: country,
|
||||
Outlet: outlet,
|
||||
StoreId: storeId,
|
||||
SaleStatus: item.SaleStatus,
|
||||
Cgm: cgm,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func getTax(articleType string) int32 {
|
||||
switch articleType {
|
||||
case "ZDIE":
|
||||
return 600
|
||||
default:
|
||||
return 2500
|
||||
}
|
||||
}
|
||||
|
||||
func getInt(data float64, ok bool) (int, error) {
|
||||
if !ok {
|
||||
return 0, fmt.Errorf("invalid type")
|
||||
}
|
||||
return int(data), nil
|
||||
return msg, nil
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func makeProduct(price float64, extra map[string]any) *ProductItem {
|
||||
m := map[string]json.RawMessage{}
|
||||
for k, v := range extra {
|
||||
b, _ := json.Marshal(v)
|
||||
m[k] = b
|
||||
}
|
||||
return &ProductItem{Price: price, Extra: m}
|
||||
}
|
||||
|
||||
func TestAreaFromItem(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
extra map[string]any
|
||||
want float64
|
||||
}{
|
||||
{"floors small window to min", map[string]any{"width": 5, "height": 6}, 0.4}, // 500*600/1e6 = 0.30 -> floor 0.4
|
||||
{"normal window", map[string]any{"width": 10, "height": 10}, 1.0}, // 1000*1000/1e6 = 1.0
|
||||
{"numeric strings", map[string]any{"width": "10", "height": "10"}, 1.0}, // JS Number() parity
|
||||
{"missing height", map[string]any{"width": 10}, 0},
|
||||
{"zero width", map[string]any{"width": 0, "height": 10}, 0},
|
||||
{"non-numeric", map[string]any{"width": "abc", "height": 10}, 0},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := areaFromItem(makeProduct(0, tc.extra))
|
||||
if got != tc.want {
|
||||
t.Errorf("areaFromItem = %v, want %v", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
if got := areaFromItem(nil); got != 0 {
|
||||
t.Errorf("areaFromItem(nil) = %v, want 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccessoryPrice(t *testing.T) {
|
||||
// area 1.0 m^2, child 100.00 -> 100.00 * 100 * 1.0 = 10000 minor units.
|
||||
parent := makeProduct(0, map[string]any{"width": 10, "height": 10})
|
||||
if got := accessoryPrice(parent, makeProduct(100, nil)); got != 10000 {
|
||||
t.Errorf("accessoryPrice = %d, want 10000", got)
|
||||
}
|
||||
|
||||
// area floored to 0.4, child 9952.78 -> round(9952.78 * 100 * 0.4) = 398111.
|
||||
small := makeProduct(0, map[string]any{"width": 5, "height": 6})
|
||||
if got := accessoryPrice(small, makeProduct(9952.78, nil)); got != 398111 {
|
||||
t.Errorf("accessoryPrice = %d, want 398111", got)
|
||||
}
|
||||
|
||||
// parent without dimensions -> area 0 -> price 0.
|
||||
if got := accessoryPrice(makeProduct(0, nil), makeProduct(500, nil)); got != 0 {
|
||||
t.Errorf("accessoryPrice (no dims) = %d, want 0", got)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user