From 60722e3414119153a4a2333e6a10bab2371849b9 Mon Sep 17 00:00:00 2001 From: matst80 Date: Tue, 16 Jun 2026 13:14:35 +0200 Subject: [PATCH] fixes --- Makefile | 17 +- cmd/cart/main.go | 160 ++++---- cmd/cart/openapi.json | 85 +++- cmd/cart/pool-server.go | 221 +++++++++-- cmd/cart/product-fetcher.go | 291 ++++++++------ cmd/cart/product-fetcher_integration_test.go | 390 +++++++++++++++++++ cmd/cart/product-fetcher_test.go | 60 +++ data/16076883129799757619.events.log | 37 ++ data/promotions.json | 1 + go.mod | 74 ++-- go.sum | 156 ++++---- pkg/actor/keyed_mutex.go | 50 +++ pkg/actor/simple_grain_pool.go | 30 +- pkg/cart/cart-grain.go | 55 +-- pkg/cart/cart-mutation-helper.go | 1 + pkg/cart/cart_item_json.go | 77 ++++ pkg/cart/cart_item_json_test.go | 94 +++++ pkg/cart/mutation_add_item.go | 36 +- pkg/cart/mutation_add_item_test.go | 40 ++ pkg/cart/mutation_custom_fields_test.go | 65 ++++ pkg/cart/mutation_remove_item.go | 50 ++- pkg/cart/mutation_remove_item_test.go | 70 ++++ pkg/cart/mutation_set_custom_fields.go | 31 ++ pkg/proxy/remotehost.go | 48 ++- proto/cart.proto | 15 + proto/cart/cart.pb.go | 217 ++++++++--- proto/checkout/checkout.pb.go | 4 +- proto/control/control_plane.pb.go | 4 +- proto/control/control_plane_grpc.pb.go | 22 +- 29 files changed, 1946 insertions(+), 455 deletions(-) create mode 100644 cmd/cart/product-fetcher_integration_test.go create mode 100644 cmd/cart/product-fetcher_test.go create mode 100644 data/16076883129799757619.events.log create mode 100644 data/promotions.json create mode 100644 pkg/actor/keyed_mutex.go create mode 100644 pkg/cart/cart_item_json.go create mode 100644 pkg/cart/cart_item_json_test.go create mode 100644 pkg/cart/mutation_add_item_test.go create mode 100644 pkg/cart/mutation_custom_fields_test.go create mode 100644 pkg/cart/mutation_remove_item_test.go create mode 100644 pkg/cart/mutation_set_custom_fields.go diff --git a/Makefile b/Makefile index 4e1e7f6..95cff39 100644 --- a/Makefile +++ b/Makefile @@ -101,17 +101,12 @@ verify_proto: fi @echo "$(GREEN)Proto layout OK (no root-level *.pb.go files).$(RESET)" - - - - - - - - - - - +run: + REDIS_ADDRESS=10.10.3.18:6379 \ + REDIS_PASSWORD=slaskredis \ + CART_DEBUG_PORT=8091 \ + CART_PORT=8090 \ + go run ./cmd/cart/ tidy: @echo "$(YELLOW)Running go mod tidy...$(RESET)" diff --git a/cmd/cart/main.go b/cmd/cart/main.go index fca039b..5227cf9 100644 --- a/cmd/cart/main.go +++ b/cmd/cart/main.go @@ -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: diff --git a/cmd/cart/openapi.json b/cmd/cart/openapi.json index 06d559f..37669cf 100644 --- a/cmd/cart/openapi.json +++ b/cmd/cart/openapi.json @@ -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": { diff --git a/cmd/cart/pool-server.go b/cmd/cart/pool-server.go index 89f1eb2..7b74051 100644 --- a/cmd/cart/pool-server.go +++ b/cmd/cart/pool-server.go @@ -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))) } diff --git a/cmd/cart/product-fetcher.go b/cmd/cart/product-fetcher.go index d0c3e97..f916328 100644 --- a/cmd/cart/product-fetcher.go +++ b/cmd/cart/product-fetcher.go @@ -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 } diff --git a/cmd/cart/product-fetcher_integration_test.go b/cmd/cart/product-fetcher_integration_test.go new file mode 100644 index 0000000..33c582b --- /dev/null +++ b/cmd/cart/product-fetcher_integration_test.go @@ -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) +} diff --git a/cmd/cart/product-fetcher_test.go b/cmd/cart/product-fetcher_test.go new file mode 100644 index 0000000..c9fa17f --- /dev/null +++ b/cmd/cart/product-fetcher_test.go @@ -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) + } +} diff --git a/data/16076883129799757619.events.log b/data/16076883129799757619.events.log new file mode 100644 index 0000000..cdd3dad --- /dev/null +++ b/data/16076883129799757619.events.log @@ -0,0 +1,37 @@ +{"type":"AddItem","timestamp":"2026-06-14T12:06:33.026264929+02:00","mutation":{"item_id":147528,"quantity":1,"price":18559,"orgPrice":23199,"sku":"A0161291","name":"SF vridfönster 2380x680mm 2-luft, insida trä utsida aluminium, 3-glas ","image":"/A0161291.wimg","tax":2500,"sellerId":"1","sellerName":"Svenska Fönster","extra_json":"eyJib3JkZXJIZWlnaHQiOjE5NCwiYm9yZGVyV2lkdGgiOjE5NCwiYnVsa3kiOjEsImNhdGVnb3J5IjowLCJkZWxldGUiOmZhbHNlLCJkZWxpdmVyeVN0cmluZyI6Ik5vcm1hbHQgNC02IGFyYmV0c3ZlY2tvciIsImRlbGl2ZXJ5V2VlayI6MTAsImRlc2NyaXB0aW9uIjoiS2FybXl0dGVybcOldHQgYnJlZGQgeCBow7ZqZC4iLCJkaXZpZGVyU2l6ZSI6MTY0LCJnbGFzc01hcmdpblBvc3QiOjcxLCJncm91cCI6IjEzNyIsImhlaWdodCI6NywiaGVpZ2h0TWFyZ2luIjoyMCwiaXNBY2Nlc3NvcnkiOjAsImx1ZnQiOiIyIiwibHVmdF90aXRsZSI6IjItTHVmdCAodHbDpSDDtnBwbmluZ3NiYXJhIGLDpWdhciBpIHNhbW1hIGthcm0pIiwibW9kZWwiOiIxMzciLCJtb2RlbERlc2NyaXB0aW9uIjoiU1AgQmFsYW5zIGbDtm5zdGVyIHZyaWQiLCJwYWdlSWQiOjg4NDM2LCJwb3N0U2l6ZSI6NzEsInByZXZpb3VzRGlzY291bnQiOjMwLCJzcHJvanNTaXplIjozMSwic3RvcmxlayI6IjI0MDciLCJ0YXhvbm9teSI6W10sInVuaXQiOiJzdCIsIndpZHRoIjoyNCwid2lkdGhNYXJnaW4iOjIwfQ=="}} +{"type":"ChangeQuantity","timestamp":"2026-06-14T12:06:41.00641672+02:00","mutation":{"Id":1,"quantity":2}} +{"type":"ChangeQuantity","timestamp":"2026-06-14T12:06:50.402587934+02:00","mutation":{"Id":1,"quantity":1}} +{"type":"ChangeQuantity","timestamp":"2026-06-14T12:08:25.714006052+02:00","mutation":{"Id":1,"quantity":2}} +{"type":"ChangeQuantity","timestamp":"2026-06-14T12:11:38.722916072+02:00","mutation":{"Id":1,"quantity":1}} +{"type":"ChangeQuantity","timestamp":"2026-06-14T12:14:12.485362998+02:00","mutation":{"Id":1,"quantity":2}} +{"type":"ChangeQuantity","timestamp":"2026-06-14T12:14:13.259209602+02:00","mutation":{"Id":1,"quantity":1}} +{"type":"AddItem","timestamp":"2026-06-14T12:14:19.560413353+02:00","mutation":{"item_id":146107,"quantity":1,"price":1812900,"orgPrice":2266125,"sku":"A0158995","name":"SF vridfönster 1680x1380mm 1-luft, insida trä utsida aluminium, 3-glas ","image":"/A0158995.wimg","tax":2500,"sellerId":"1","sellerName":"Svenska Fönster","extra_json":"eyJib3JkZXJIZWlnaHQiOjE5NCwiYm9yZGVyV2lkdGgiOjE5NCwiYnVsa3kiOjEsImNhdGVnb3J5IjowLCJkZWxldGUiOmZhbHNlLCJkZWxpdmVyeVN0cmluZyI6Ik5vcm1hbHQgNC02IGFyYmV0c3ZlY2tvciIsImRlbGl2ZXJ5V2VlayI6MTAsImRlc2NyaXB0aW9uIjoiS2FybXl0dGVybcOldHQgYnJlZGQgeCBow7ZqZC4iLCJkaXZpZGVyU2l6ZSI6MTY0LCJnbGFzc01hcmdpblBvc3QiOjcxLCJncm91cCI6IjEzNyIsImhlaWdodCI6MTQsImhlaWdodE1hcmdpbiI6MjAsImlzQWNjZXNzb3J5IjowLCJsdWZ0IjoiMSIsImx1ZnRfdGl0bGUiOiIxLUx1ZnQgKGVuIMO2cHBuaW5nc2JhciBiw6VnZSBpIHNhbW1hIGthcm0pIiwibW9kZWwiOiIxMzciLCJtb2RlbERlc2NyaXB0aW9uIjoiU1AgQmFsYW5zIGbDtm5zdGVyIHZyaWQiLCJwYWdlSWQiOjg4NDM2LCJwb3N0U2l6ZSI6NzEsInByZXZpb3VzRGlzY291bnQiOjMwLCJzcHJvanNTaXplIjozMSwic3RvcmxlayI6IjE3MTQiLCJ0YXhvbm9teSI6W10sInVuaXQiOiJzdCIsIndpZHRoIjoxNywid2lkdGhNYXJnaW4iOjIwfQ=="}} +{"type":"RemoveItem","timestamp":"2026-06-14T12:16:35.832642257+02:00","mutation":{"Id":1}} +{"type":"AddItem","timestamp":"2026-06-14T12:47:41.110158952+02:00","mutation":{"item_id":144047,"quantity":1,"price":690193,"orgPrice":862741,"sku":"A0162590","name":"SF vridfönster 480x580mm 1-luft, insida trä utsida aluminium, 3-glas ","image":"/A0162590.wimg","tax":2500,"sellerId":"1","sellerName":"Svenska Fönster","extra_json":"eyJib3JkZXJIZWlnaHQiOjE5NCwiYm9yZGVyV2lkdGgiOjE5NCwiYnVsa3kiOjEsImNhdGVnb3J5IjowLCJkZWxldGUiOmZhbHNlLCJkZWxpdmVyeVN0cmluZyI6Ik5vcm1hbHQgNC02IGFyYmV0c3ZlY2tvciIsImRlbGl2ZXJ5V2VlayI6MTAsImRlc2NyaXB0aW9uIjoiS2FybXl0dGVybcOldHQgYnJlZGQgeCBow7ZqZC4iLCJkaXZpZGVyU2l6ZSI6MTY0LCJnbGFzc01hcmdpblBvc3QiOjcxLCJncm91cCI6IjEzNyIsImhlaWdodCI6NiwiaGVpZ2h0TWFyZ2luIjoyMCwiaXNBY2Nlc3NvcnkiOjAsImx1ZnQiOiIxIiwibHVmdF90aXRsZSI6IjEtTHVmdCAoZW4gw7ZwcG5pbmdzYmFyIGLDpWdlIGkgc2FtbWEga2FybSkiLCJtb2RlbCI6IjEzNyIsIm1vZGVsRGVzY3JpcHRpb24iOiJTUCBCYWxhbnMgZsO2bnN0ZXIgdnJpZCIsInBhZ2VJZCI6ODg0MzYsInBvc3RTaXplIjo3MSwicHJldmlvdXNEaXNjb3VudCI6MzAsInNwcm9qc1NpemUiOjMxLCJzdG9ybGVrIjoiMDUwNiIsInRheG9ub215IjpbXSwidW5pdCI6InN0Iiwid2lkdGgiOjUsIndpZHRoTWFyZ2luIjoyMH0="}} +{"type":"AddItem","timestamp":"2026-06-14T12:49:14.15670005+02:00","mutation":{"item_id":144047,"quantity":1,"price":690193,"orgPrice":862741,"sku":"A0162590","name":"SF vridfönster 480x580mm 1-luft, insida trä utsida aluminium, 3-glas ","image":"/A0162590.wimg","tax":2500,"sellerId":"1","sellerName":"Svenska Fönster","extra_json":"eyJib3JkZXJIZWlnaHQiOjE5NCwiYm9yZGVyV2lkdGgiOjE5NCwiYnVsa3kiOjEsImNhdGVnb3J5IjowLCJkZWxldGUiOmZhbHNlLCJkZWxpdmVyeVN0cmluZyI6Ik5vcm1hbHQgNC02IGFyYmV0c3ZlY2tvciIsImRlbGl2ZXJ5V2VlayI6MTAsImRlc2NyaXB0aW9uIjoiS2FybXl0dGVybcOldHQgYnJlZGQgeCBow7ZqZC4iLCJkaXZpZGVyU2l6ZSI6MTY0LCJnbGFzc01hcmdpblBvc3QiOjcxLCJncm91cCI6IjEzNyIsImhlaWdodCI6NiwiaGVpZ2h0TWFyZ2luIjoyMCwiaXNBY2Nlc3NvcnkiOjAsImx1ZnQiOiIxIiwibHVmdF90aXRsZSI6IjEtTHVmdCAoZW4gw7ZwcG5pbmdzYmFyIGLDpWdlIGkgc2FtbWEga2FybSkiLCJtb2RlbCI6IjEzNyIsIm1vZGVsRGVzY3JpcHRpb24iOiJTUCBCYWxhbnMgZsO2bnN0ZXIgdnJpZCIsInBhZ2VJZCI6ODg0MzYsInBvc3RTaXplIjo3MSwicHJldmlvdXNEaXNjb3VudCI6MzAsInNwcm9qc1NpemUiOjMxLCJzdG9ybGVrIjoiMDUwNiIsInRheG9ub215IjpbXSwidW5pdCI6InN0Iiwid2lkdGgiOjUsIndpZHRoTWFyZ2luIjoyMH0="}} +{"type":"ChangeQuantity","timestamp":"2026-06-14T12:49:19.389741453+02:00","mutation":{"Id":3,"quantity":1}} +{"type":"ChangeQuantity","timestamp":"2026-06-14T12:49:19.68037225+02:00","mutation":{"Id":3}} +{"type":"ChangeQuantity","timestamp":"2026-06-14T12:49:20.250569359+02:00","mutation":{"Id":2}} +{"type":"AddItem","timestamp":"2026-06-14T12:49:22.994875367+02:00","mutation":{"item_id":144047,"quantity":1,"price":690193,"orgPrice":862741,"sku":"A0162590","name":"SF vridfönster 480x580mm 1-luft, insida trä utsida aluminium, 3-glas ","image":"/A0162590.wimg","tax":2500,"sellerId":"1","sellerName":"Svenska Fönster","extra_json":"eyJib3JkZXJIZWlnaHQiOjE5NCwiYm9yZGVyV2lkdGgiOjE5NCwiYnVsa3kiOjEsImNhdGVnb3J5IjowLCJkZWxldGUiOmZhbHNlLCJkZWxpdmVyeVN0cmluZyI6Ik5vcm1hbHQgNC02IGFyYmV0c3ZlY2tvciIsImRlbGl2ZXJ5V2VlayI6MTAsImRlc2NyaXB0aW9uIjoiS2FybXl0dGVybcOldHQgYnJlZGQgeCBow7ZqZC4iLCJkaXZpZGVyU2l6ZSI6MTY0LCJnbGFzc01hcmdpblBvc3QiOjcxLCJncm91cCI6IjEzNyIsImhlaWdodCI6NiwiaGVpZ2h0TWFyZ2luIjoyMCwiaXNBY2Nlc3NvcnkiOjAsImx1ZnQiOiIxIiwibHVmdF90aXRsZSI6IjEtTHVmdCAoZW4gw7ZwcG5pbmdzYmFyIGLDpWdlIGkgc2FtbWEga2FybSkiLCJtb2RlbCI6IjEzNyIsIm1vZGVsRGVzY3JpcHRpb24iOiJTUCBCYWxhbnMgZsO2bnN0ZXIgdnJpZCIsInBhZ2VJZCI6ODg0MzYsInBvc3RTaXplIjo3MSwicHJldmlvdXNEaXNjb3VudCI6MzAsInNwcm9qc1NpemUiOjMxLCJzdG9ybGVrIjoiMDUwNiIsInRheG9ub215IjpbXSwidW5pdCI6InN0Iiwid2lkdGgiOjUsIndpZHRoTWFyZ2luIjoyMH0="}} +{"type":"AddItem","timestamp":"2026-06-14T13:02:45.511064864+02:00","mutation":{"item_id":146248,"quantity":1,"price":1842370,"orgPrice":2302963,"sku":"A0159038","name":"SF vridfönster 1780x780mm 2-luft, insida trä utsida aluminium, 3-glas ","image":"/A0159038.wimg","tax":2500,"sellerId":"1","sellerName":"Svenska Fönster","extra_json":"eyJib3JkZXJIZWlnaHQiOjE5NCwiYm9yZGVyV2lkdGgiOjE5NCwiYnVsa3kiOjEsImNhdGVnb3J5IjowLCJkZWxldGUiOmZhbHNlLCJkZWxpdmVyeVN0cmluZyI6Ik5vcm1hbHQgNC02IGFyYmV0c3ZlY2tvciIsImRlbGl2ZXJ5V2VlayI6MTAsImRlc2NyaXB0aW9uIjoiS2FybXl0dGVybcOldHQgYnJlZGQgeCBow7ZqZC4iLCJkaXZpZGVyU2l6ZSI6MTY0LCJnbGFzc01hcmdpblBvc3QiOjcxLCJncm91cCI6IjEzNyIsImhlaWdodCI6OCwiaGVpZ2h0TWFyZ2luIjoyMCwiaXNBY2Nlc3NvcnkiOjAsImx1ZnQiOiIyIiwibHVmdF90aXRsZSI6IjItTHVmdCAodHbDpSDDtnBwbmluZ3NiYXJhIGLDpWdhciBpIHNhbW1hIGthcm0pIiwibW9kZWwiOiIxMzciLCJtb2RlbERlc2NyaXB0aW9uIjoiU1AgQmFsYW5zIGbDtm5zdGVyIHZyaWQiLCJwYWdlSWQiOjg4NDM2LCJwb3N0U2l6ZSI6NzEsInByZXZpb3VzRGlzY291bnQiOjMwLCJzcHJvanNTaXplIjozMSwic3RvcmxlayI6IjE4MDgiLCJ0YXhvbm9teSI6W10sInVuaXQiOiJzdCIsIndpZHRoIjoxOCwid2lkdGhNYXJnaW4iOjIwfQ=="}} +{"type":"ChangeQuantity","timestamp":"2026-06-14T13:02:48.965993343+02:00","mutation":{"Id":5}} +{"type":"ChangeQuantity","timestamp":"2026-06-14T13:02:49.533960677+02:00","mutation":{"Id":4}} +{"type":"AddItem","timestamp":"2026-06-14T13:03:02.543932484+02:00","mutation":{"item_id":146620,"quantity":1,"price":3043620,"orgPrice":3804525,"sku":"A0164975","name":"SF vridfönster 1880x1180mm 3-luft, insida trä utsida aluminium, 3-glas ","image":"/A0164975.wimg","tax":2500,"sellerId":"1","sellerName":"Svenska Fönster","extra_json":"eyJib3JkZXJIZWlnaHQiOjE5NCwiYm9yZGVyV2lkdGgiOjE5NCwiYnVsa3kiOjEsImNhdGVnb3J5IjowLCJkZWxldGUiOmZhbHNlLCJkZWxpdmVyeVN0cmluZyI6Ik5vcm1hbHQgNC02IGFyYmV0c3ZlY2tvciIsImRlbGl2ZXJ5V2VlayI6MTAsImRlc2NyaXB0aW9uIjoiS2FybXl0dGVybcOldHQgYnJlZGQgeCBow7ZqZC4iLCJkaXZpZGVyU2l6ZSI6MTY0LCJnbGFzc01hcmdpblBvc3QiOjcxLCJncm91cCI6IjEzNyIsImhlaWdodCI6MTIsImhlaWdodE1hcmdpbiI6MjAsImlzQWNjZXNzb3J5IjowLCJsdWZ0IjoiMyIsImx1ZnRfdGl0bGUiOiIzLUx1ZnQgKHRyZSDDtnBwbmluZ3NiYXJhIGLDpWdhciBpIHNhbW1hIGthcm0pIiwibW9kZWwiOiIxMzciLCJtb2RlbERlc2NyaXB0aW9uIjoiU1AgQmFsYW5zIGbDtm5zdGVyIHZyaWQiLCJwYWdlSWQiOjg4NDM2LCJwb3N0U2l6ZSI6NzEsInByZXZpb3VzRGlzY291bnQiOjMwLCJzcHJvanNTaXplIjozMSwic3RvcmxlayI6IjE5MTIiLCJ0YXhvbm9teSI6W10sInVuaXQiOiJzdCIsIndpZHRoIjoxOSwid2lkdGhNYXJnaW4iOjIwfQ=="}} +{"type":"ChangeQuantity","timestamp":"2026-06-14T13:06:48.388062792+02:00","mutation":{"Id":6}} +{"type":"AddItem","timestamp":"2026-06-14T13:07:01.83789281+02:00","mutation":{"item_id":146387,"quantity":1,"price":1767450,"orgPrice":2524929,"sku":"A0164933","name":"SF vridfönster 1780x1180mm 1-luft, insida trä utsida aluminium, 3-glas ","image":"/A0164933.wimg","tax":2500,"sellerId":"1","sellerName":"Svenska Fönster","extra_json":"eyJib3JkZXJIZWlnaHQiOjE5NCwiYm9yZGVyV2lkdGgiOjE5NCwiYnVsa3kiOjEsImNhdGVnb3J5IjowLCJkZWxldGUiOmZhbHNlLCJkZWxpdmVyeVN0cmluZyI6Ik5vcm1hbHQgNC02IGFyYmV0c3ZlY2tvciIsImRlbGl2ZXJ5V2VlayI6MTAsImRlc2NyaXB0aW9uIjoiS2FybXl0dGVybcOldHQgYnJlZGQgeCBow7ZqZC4iLCJkaXZpZGVyU2l6ZSI6MTY0LCJnbGFzc01hcmdpblBvc3QiOjcxLCJncm91cCI6IjEzNyIsImhlaWdodCI6MTIsImhlaWdodE1hcmdpbiI6MjAsImlzQWNjZXNzb3J5IjowLCJsdWZ0IjoiMSIsImx1ZnRfdGl0bGUiOiIxLUx1ZnQgKGVuIMO2cHBuaW5nc2JhciBiw6VnZSBpIHNhbW1hIGthcm0pIiwibW9kZWwiOiIxMzciLCJtb2RlbERlc2NyaXB0aW9uIjoiU1AgQmFsYW5zIGbDtm5zdGVyIHZyaWQiLCJwYWdlSWQiOjg4NDM2LCJwb3N0U2l6ZSI6NzEsInByZXZpb3VzRGlzY291bnQiOjMwLCJzcHJvanNTaXplIjozMSwic3RvcmxlayI6IjE4MTIiLCJ0YXhvbm9teSI6W10sInVuaXQiOiJzdCIsIndpZHRoIjoxOCwid2lkdGhNYXJnaW4iOjIwfQ=="}} +{"type":"AddItem","timestamp":"2026-06-14T13:07:01.837946101+02:00","mutation":{"item_id":170852,"quantity":1,"sku":"A0190103","name":"Rundad profil på karm \u0026 båge, SP utseende","image":"/UserFiles/Svenska_Fonster/Diverse/Genomskarning_rund_profil.jpg","tax":2500,"sellerId":"7","sellerName":"Kaski","parentId":7,"extra_json":"eyJidWxreSI6MCwiY2F0ZWdvcnkiOjE3NCwiZGVsZXRlIjpmYWxzZSwiZGVsaXZlcnlTdHJpbmciOiJOb3JtYWx0IDQtNiBhcmJldHN2ZWNrb3IiLCJkZWxpdmVyeVdlZWsiOjEwLCJkZXNjcmlwdGlvbiI6IiIsImhlaWdodE1hcmdpbiI6MTAsImlzQWNjZXNzb3J5Ijp0cnVlLCJtb2RlbCI6IllEIiwibW9kZWxEZXNjcmlwdGlvbiI6Ikthc2tpIFl0dGVyZMO2cnJhciB0aGVybW8iLCJwYWdlSWQiOjAsInByZXZpb3VzRGlzY291bnQiOjIwLCJ0YXhvbm9teSI6W10sInVuaXQiOiJzdCIsIndpZHRoTWFyZ2luIjoxMH0="}} +{"type":"AddItem","timestamp":"2026-06-14T13:07:01.837975176+02:00","mutation":{"item_id":123075,"quantity":1,"price":995278,"sku":"A0125821","name":"Ställkostnad egen kulör Ncs/Ral nr (ej lasyr) / leverans","image":"/UserFiles/Fargkarta.jpg","tax":2500,"sellerId":"0","parentId":7,"extra_json":"eyJhY2Nlc3NvcnlNYXRjaCI6IiIsImJ1bGt5IjowLCJjYXRlZ29yeSI6NjUsImRlbGV0ZSI6ZmFsc2UsImRlc2NyaXB0aW9uIjoiIiwiZ3JvdXAiOiIiLCJpc0FjY2Vzc29yeSI6dHJ1ZSwibW9kZWwiOiIiLCJwYWdlSWQiOjAsInRheG9ub215IjpbXSwidW5pdCI6InN0In0="}} +{"type":"AddItem","timestamp":"2026-06-14T13:07:01.837991607+02:00","mutation":{"item_id":123076,"quantity":1,"price":2420510,"sku":"A0125831","name":"Ställkostnad egen kulör utsida NCS S / leverans (väljs på en produkt)","image":"/UserFiles/Fargkarta.jpg","tax":2500,"sellerId":"0","parentId":7,"extra_json":"eyJhY2Nlc3NvcnlNYXRjaCI6IiIsImJ1bGt5IjowLCJjYXRlZ29yeSI6NjYsImRlbGV0ZSI6ZmFsc2UsImRlc2NyaXB0aW9uIjoiIiwiZ3JvdXAiOiIiLCJpc0FjY2Vzc29yeSI6dHJ1ZSwibW9kZWwiOiIiLCJwYWdlSWQiOjAsInRheG9ub215IjpbXSwidW5pdCI6InN0In0="}} +{"type":"ChangeQuantity","timestamp":"2026-06-14T13:28:50.254122718+02:00","mutation":{"Id":7}} +{"type":"AddItem","timestamp":"2026-06-14T15:15:53.256709127+02:00","mutation":{"item_id":148722,"quantity":1,"price":2531850,"orgPrice":3164813,"sku":"A0166079","name":"SF vridfönster 1980x1580mm 1-luft, insida trä utsida aluminium, 2-glas pga storleken","image":"/A0166079.wimg","tax":2500,"sellerId":"1","sellerName":"Svenska Fönster","extra_json":"eyJhY2Nlc3NvcnlNYXRjaCI6IiIsImJvcmRlckhlaWdodCI6MTk0LCJib3JkZXJXaWR0aCI6MTk0LCJidWxreSI6MSwiY2F0ZWdvcnkiOjAsImRlbGV0ZSI6ZmFsc2UsImRlbGl2ZXJ5U3RyaW5nIjoiTm9ybWFsdCA0LTYgYXJiZXRzdmVja29yIiwiZGVsaXZlcnlXZWVrIjoxMCwiZGVzY3JpcHRpb24iOiJLYXJteXR0ZXJtw6V0dCBicmVkZCB4IGjDtmpkLiIsImRpdmlkZXJTaXplIjoxNjQsImdsYXNzTWFyZ2luUG9zdCI6NzEsImdyb3VwIjoiMTM3IiwiaGVpZ2h0IjoxNiwiaGVpZ2h0TWFyZ2luIjoyMCwiaXNBY2Nlc3NvcnkiOjAsImx1ZnQiOiIxIiwibHVmdF90aXRsZSI6IjEtTHVmdCAoZW4gw7ZwcG5pbmdzYmFyIGLDpWdlIGkgc2FtbWEga2FybSkiLCJtb2RlbCI6IjEzNyIsIm1vZGVsRGVzY3JpcHRpb24iOiJTUCBCYWxhbnMgZsO2bnN0ZXIgdnJpZCIsInBhZ2VJZCI6ODg0MzYsInBvc3RTaXplIjo3MSwicHJldmlvdXNEaXNjb3VudCI6MzAsInNwcm9qc1NpemUiOjMxLCJzdG9ybGVrIjoiMjAxNiIsInRheG9ub215IjpbXSwidW5pdCI6InN0Iiwid2lkdGgiOjIwLCJ3aWR0aE1hcmdpbiI6MjB9"}} +{"type":"AddItem","timestamp":"2026-06-14T15:15:53.256762929+02:00","mutation":{"item_id":170852,"quantity":1,"sku":"A0190103","name":"Rundad profil på karm \u0026 båge, SP utseende","image":"/UserFiles/Svenska_Fonster/Diverse/Genomskarning_rund_profil.jpg","tax":2500,"sellerId":"7","sellerName":"Kaski","parentId":11,"extra_json":"eyJidWxreSI6MCwiY2F0ZWdvcnkiOjE3NCwiZGVsZXRlIjpmYWxzZSwiZGVsaXZlcnlTdHJpbmciOiJOb3JtYWx0IDQtNiBhcmJldHN2ZWNrb3IiLCJkZWxpdmVyeVdlZWsiOjEwLCJkZXNjcmlwdGlvbiI6IiIsImhlaWdodE1hcmdpbiI6MTAsImlzQWNjZXNzb3J5Ijp0cnVlLCJtb2RlbCI6IllEIiwibW9kZWxEZXNjcmlwdGlvbiI6Ikthc2tpIFl0dGVyZMO2cnJhciB0aGVybW8iLCJwYWdlSWQiOjAsInByZXZpb3VzRGlzY291bnQiOjIwLCJ0YXhvbm9teSI6W10sInVuaXQiOiJzdCIsIndpZHRoTWFyZ2luIjoxMH0="}} +{"type":"AddItem","timestamp":"2026-06-14T15:15:53.256793727+02:00","mutation":{"item_id":97103,"quantity":1,"price":4139744,"sku":"A0086072","name":"Brand klassat glas EI30 (välj även förankring)","image":"/UserFiles/Svenska_Fonster/Glas/Brand_Ei30.jpg","tax":2500,"sellerId":"0","parentId":11,"extra_json":"eyJhY2Nlc3NvcnlNYXRjaCI6IiIsImJ1bGt5IjowLCJjYXRlZ29yeSI6MjAsImRlbGV0ZSI6ZmFsc2UsImRlc2NyaXB0aW9uIjoiRXR0IGdsYXMgc29tIGtsYXJhciBrcmF2ZW4gRUkzMCIsImdyb3VwIjoiIiwiaXNBY2Nlc3NvcnkiOnRydWUsIm1vZGVsIjoiIiwicGFnZUlkIjowLCJ0YXhvbm9teSI6W10sInVuaXQiOiJzdCJ9"}} +{"type":"ChangeQuantity","timestamp":"2026-06-14T15:16:03.078662993+02:00","mutation":{"Id":8,"quantity":1}} +{"type":"ChangeQuantity","timestamp":"2026-06-14T15:16:03.54547595+02:00","mutation":{"Id":8}} +{"type":"ChangeQuantity","timestamp":"2026-06-14T15:16:04.240661903+02:00","mutation":{"Id":9}} +{"type":"ChangeQuantity","timestamp":"2026-06-14T15:16:05.581768231+02:00","mutation":{"Id":10}} +{"type":"ChangeQuantity","timestamp":"2026-06-14T15:16:09.40673458+02:00","mutation":{"Id":11}} +{"type":"ChangeQuantity","timestamp":"2026-06-14T15:16:11.41069986+02:00","mutation":{"Id":12}} +{"type":"AddItem","timestamp":"2026-06-14T15:24:17.09761136+02:00","mutation":{"item_id":148722,"quantity":1,"price":2531850,"orgPrice":3164813,"sku":"A0166079","name":"SF vridfönster 1980x1580mm 1-luft, insida trä utsida aluminium, 2-glas pga storleken","image":"/A0166079.wimg","tax":2500,"sellerId":"1","sellerName":"Svenska Fönster","extra_json":"eyJhY2Nlc3NvcnlNYXRjaCI6IiIsImJvcmRlckhlaWdodCI6MTk0LCJib3JkZXJXaWR0aCI6MTk0LCJidWxreSI6MSwiY2F0ZWdvcnkiOjAsImRlbGV0ZSI6ZmFsc2UsImRlbGl2ZXJ5U3RyaW5nIjoiTm9ybWFsdCA0LTYgYXJiZXRzdmVja29yIiwiZGVsaXZlcnlXZWVrIjoxMCwiZGVzY3JpcHRpb24iOiJLYXJteXR0ZXJtw6V0dCBicmVkZCB4IGjDtmpkLiIsImRpdmlkZXJTaXplIjoxNjQsImdsYXNzTWFyZ2luUG9zdCI6NzEsImdyb3VwIjoiMTM3IiwiaGVpZ2h0IjoxNiwiaGVpZ2h0TWFyZ2luIjoyMCwiaXNBY2Nlc3NvcnkiOjAsImx1ZnQiOiIxIiwibHVmdF90aXRsZSI6IjEtTHVmdCAoZW4gw7ZwcG5pbmdzYmFyIGLDpWdlIGkgc2FtbWEga2FybSkiLCJtb2RlbCI6IjEzNyIsIm1vZGVsRGVzY3JpcHRpb24iOiJTUCBCYWxhbnMgZsO2bnN0ZXIgdnJpZCIsInBhZ2VJZCI6ODg0MzYsInBvc3RTaXplIjo3MSwicHJldmlvdXNEaXNjb3VudCI6MzAsInNwcm9qc1NpemUiOjMxLCJzdG9ybGVrIjoiMjAxNiIsInRheG9ub215IjpbXSwidW5pdCI6InN0Iiwid2lkdGgiOjIwLCJ3aWR0aE1hcmdpbiI6MjB9"}} +{"type":"AddItem","timestamp":"2026-06-14T15:24:17.097670772+02:00","mutation":{"item_id":170852,"quantity":1,"sku":"A0190103","name":"Rundad profil på karm \u0026 båge, SP utseende","image":"/UserFiles/Svenska_Fonster/Diverse/Genomskarning_rund_profil.jpg","tax":2500,"sellerId":"7","sellerName":"Kaski","parentId":13,"extra_json":"eyJidWxreSI6MCwiY2F0ZWdvcnkiOjE3NCwiZGVsZXRlIjpmYWxzZSwiZGVsaXZlcnlTdHJpbmciOiJOb3JtYWx0IDQtNiBhcmJldHN2ZWNrb3IiLCJkZWxpdmVyeVdlZWsiOjEwLCJkZXNjcmlwdGlvbiI6IiIsImhlaWdodE1hcmdpbiI6MTAsImlzQWNjZXNzb3J5Ijp0cnVlLCJtb2RlbCI6IllEIiwibW9kZWxEZXNjcmlwdGlvbiI6Ikthc2tpIFl0dGVyZMO2cnJhciB0aGVybW8iLCJwYWdlSWQiOjAsInByZXZpb3VzRGlzY291bnQiOjIwLCJ0YXhvbm9teSI6W10sInVuaXQiOiJzdCIsIndpZHRoTWFyZ2luIjoxMH0="}} +{"type":"AddItem","timestamp":"2026-06-14T15:24:17.097710397+02:00","mutation":{"item_id":97103,"quantity":1,"price":4139744,"sku":"A0086072","name":"Brand klassat glas EI30 (välj även förankring)","image":"/UserFiles/Svenska_Fonster/Glas/Brand_Ei30.jpg","tax":2500,"sellerId":"0","parentId":13,"extra_json":"eyJhY2Nlc3NvcnlNYXRjaCI6IiIsImJ1bGt5IjowLCJjYXRlZ29yeSI6MjAsImRlbGV0ZSI6ZmFsc2UsImRlc2NyaXB0aW9uIjoiRXR0IGdsYXMgc29tIGtsYXJhciBrcmF2ZW4gRUkzMCIsImdyb3VwIjoiIiwiaXNBY2Nlc3NvcnkiOnRydWUsIm1vZGVsIjoiIiwicGFnZUlkIjowLCJ0YXhvbm9teSI6W10sInVuaXQiOiJzdCJ9"}} diff --git a/data/promotions.json b/data/promotions.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/data/promotions.json @@ -0,0 +1 @@ +{} diff --git a/go.mod b/go.mod index bdf0dd8..8c48956 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module git.k6n.net/go-cart-actor -go 1.25.4 +go 1.26.3 require ( github.com/adyen/adyen-go-api-library/v21 v21.1.0 @@ -8,35 +8,34 @@ require ( github.com/matst80/go-redis-inventory v0.0.0-20251126173508-51b30de2d86e github.com/matst80/slask-finder v0.0.0-20251125182907-9e57f193127a github.com/prometheus/client_golang v1.23.2 - github.com/rabbitmq/amqp091-go v1.10.0 - github.com/redis/go-redis/v9 v9.17.0 - go.opentelemetry.io/contrib/bridges/otelslog v0.13.0 - go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0 - go.opentelemetry.io/otel v1.38.0 - go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.14.0 - go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.38.0 - go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.38.0 - go.opentelemetry.io/otel/log v0.14.0 - go.opentelemetry.io/otel/metric v1.38.0 - go.opentelemetry.io/otel/sdk v1.38.0 - go.opentelemetry.io/otel/sdk/log v0.14.0 - go.opentelemetry.io/otel/sdk/metric v1.38.0 - go.opentelemetry.io/otel/trace v1.38.0 - google.golang.org/grpc v1.77.0 - google.golang.org/protobuf v1.36.10 + github.com/rabbitmq/amqp091-go v1.11.0 + github.com/redis/go-redis/v9 v9.20.0 + go.opentelemetry.io/contrib/bridges/otelslog v0.19.0 + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 + go.opentelemetry.io/otel v1.44.0 + go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.20.0 + go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.44.0 + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.44.0 + go.opentelemetry.io/otel/log v0.20.0 + go.opentelemetry.io/otel/metric v1.44.0 + go.opentelemetry.io/otel/sdk v1.44.0 + go.opentelemetry.io/otel/sdk/log v0.20.0 + go.opentelemetry.io/otel/sdk/metric v1.44.0 + go.opentelemetry.io/otel/trace v1.44.0 + google.golang.org/grpc v1.81.1 + google.golang.org/protobuf v1.36.11 k8s.io/api v0.34.2 k8s.io/apimachinery v0.34.2 k8s.io/client-go v0.34.2 ) require ( - github.com/RoaringBitmap/roaring/v2 v2.14.4 // indirect + github.com/RoaringBitmap/roaring/v2 v2.18.2 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/bits-and-blooms/bitset v1.24.4 // indirect github.com/cenkalti/backoff/v5 v5.0.3 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect - github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect github.com/dprotaso/go-yit v0.0.0-20251117151522-da16f3077589 // indirect github.com/emicklei/go-restful/v3 v3.13.0 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect @@ -62,7 +61,7 @@ require ( github.com/google/gnostic-models v0.7.1 // indirect github.com/google/go-cmp v0.7.0 // indirect github.com/gorilla/schema v1.4.1 // indirect - github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3 // indirect + github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 // indirect github.com/josharian/intern v1.0.0 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/mailru/easyjson v0.9.1 // indirect @@ -77,8 +76,8 @@ require ( github.com/perimeterx/marshmallow v1.1.5 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/prometheus/client_model v0.6.2 // indirect - github.com/prometheus/common v0.67.4 // indirect - github.com/prometheus/procfs v0.19.2 // indirect + github.com/prometheus/common v0.68.0 // indirect + github.com/prometheus/procfs v0.20.1 // indirect github.com/speakeasy-api/jsonpath v0.6.2 // indirect github.com/speakeasy-api/openapi-overlay v0.10.3 // indirect github.com/spf13/pflag v1.0.10 // indirect @@ -86,22 +85,23 @@ require ( github.com/woodsbury/decimal128 v1.4.0 // indirect github.com/x448/float16 v0.8.4 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect - go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.38.0 // indirect - go.opentelemetry.io/proto/otlp v1.9.0 // indirect - go.yaml.in/yaml/v2 v2.4.3 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0 // indirect + go.opentelemetry.io/proto/otlp v1.10.0 // indirect + go.uber.org/atomic v1.11.0 // indirect + go.yaml.in/yaml/v2 v2.4.4 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect go.yaml.in/yaml/v4 v4.0.0-rc.3 // indirect - golang.org/x/mod v0.30.0 // indirect - golang.org/x/net v0.47.0 // indirect - golang.org/x/oauth2 v0.33.0 // indirect - golang.org/x/sync v0.18.0 // indirect - golang.org/x/sys v0.38.0 // indirect - golang.org/x/term v0.37.0 // indirect - golang.org/x/text v0.31.0 // indirect - golang.org/x/time v0.14.0 // indirect - golang.org/x/tools v0.39.0 // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20251124214823-79d6a2a48846 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20251124214823-79d6a2a48846 // indirect + golang.org/x/mod v0.35.0 // indirect + golang.org/x/net v0.55.0 // indirect + golang.org/x/oauth2 v0.36.0 // indirect + golang.org/x/sync v0.20.0 // indirect + golang.org/x/sys v0.45.0 // indirect + golang.org/x/term v0.43.0 // indirect + golang.org/x/text v0.37.0 // indirect + golang.org/x/time v0.15.0 // indirect + golang.org/x/tools v0.44.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa // indirect gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect @@ -116,3 +116,5 @@ require ( ) tool github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen + +replace github.com/matst80/slask-finder => /home/mats/github.com/matst80/slask-finder diff --git a/go.sum b/go.sum index 2a460f0..6783032 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,5 @@ -github.com/RoaringBitmap/roaring/v2 v2.14.4 h1:4aKySrrg9G/5oRtJ3TrZLObVqxgQ9f1znCRBwEwjuVw= -github.com/RoaringBitmap/roaring/v2 v2.14.4/go.mod h1:oMvV6omPWr+2ifRdeZvVJyaz+aoEUopyv5iH0u/+wbY= +github.com/RoaringBitmap/roaring/v2 v2.18.2 h1:oPq3Cgx//iDuJQVp6xSInAKW34J9CEwE5GmLI2z+Eic= +github.com/RoaringBitmap/roaring/v2 v2.18.2/go.mod h1:eq4wdNXxtJIS/oikeCzdX1rBzek7ANzbth041hrU8Q4= github.com/adyen/adyen-go-api-library/v21 v21.1.0 h1:QIKtn99yoBdt2R4PhuMdmY/DTm6Ex5HYd0cB7Sh3y6Y= github.com/adyen/adyen-go-api-library/v21 v21.1.0/go.mod h1:qsAGYetm761eDAz+f2OQoY4qC+tKNhZOHil1b4FO5zE= github.com/alicebob/miniredis/v2 v2.35.0 h1:QwLphYqCEAo1eu1TqPRN2jgVMPBweeQcR21jeqDCONI= @@ -20,8 +20,6 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= -github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= github.com/dprotaso/go-yit v0.0.0-20191028211022-135eb7262960/go.mod h1:9HQzr9D/0PGwMEbC3d5AB7oi67+h4TsQqItC1GVYG58= github.com/dprotaso/go-yit v0.0.0-20251117151522-da16f3077589 h1:VJ/jVUWr+r4MQA7U/cscbbXRuwh1PfPCUUItYAjlKN4= github.com/dprotaso/go-yit v0.0.0-20251117151522-da16f3077589/go.mod h1:IeI20psFPeg2n1jxwbkYCmkpYsXsJqB7qmoqCIlX80s= @@ -95,8 +93,8 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/schema v1.4.1 h1:jUg5hUjCSDZpNGLuXQOgIWGdlgrIdYvgQ0wZtdK1M3E= github.com/gorilla/schema v1.4.1/go.mod h1:Dg5SSm5PV60mhF2NFaTV1xuYYj8tV8NOPRo4FggUMnM= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3 h1:NmZ1PKzSTQbuGHw9DGPFomqkkLWMC+vZCkfs+FHv1Vg= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3/go.mod h1:zQrxl1YP88HQlA6i9c63DSVPFklWpGX4OWAc9bFuaH4= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 h1:5VipnvEpbqr2gA2VbM+nYVbkIF28c5ZQfqCBQ5g2xfk= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0/go.mod h1:Hyl3n6Twe1hvtd9XUXDec4pTvgMSEixRuQKPTMH2bNs= github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= @@ -108,6 +106,8 @@ github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= +github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE= +github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= @@ -121,8 +121,6 @@ github.com/mailru/easyjson v0.9.1 h1:LbtsOm5WAswyWbvTEOqhypdPeZzHavpZx96/n553mR8 github.com/mailru/easyjson v0.9.1/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= github.com/matst80/go-redis-inventory v0.0.0-20251126173508-51b30de2d86e h1:Z7A73W6jsxFuFKWvB1efQmTjs0s7+x2B7IBM2ukkI6Y= github.com/matst80/go-redis-inventory v0.0.0-20251126173508-51b30de2d86e/go.mod h1:9P52UwIlLWLZvObfO29aKTWUCA9Gm62IuPJ/qv4Xvs0= -github.com/matst80/slask-finder v0.0.0-20251125182907-9e57f193127a h1:EfUO5BNDK3a563zQlwJYTNNv46aJFT9gbSItAwZOZ/Y= -github.com/matst80/slask-finder v0.0.0-20251125182907-9e57f193127a/go.mod h1:VIPNkIvU0dZKwbSuv75zZcB93MXISm2UyiIPly/ucXQ= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= @@ -161,14 +159,14 @@ github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= -github.com/prometheus/common v0.67.4 h1:yR3NqWO1/UyO1w2PhUvXlGQs/PtFmoveVO0KZ4+Lvsc= -github.com/prometheus/common v0.67.4/go.mod h1:gP0fq6YjjNCLssJCQp0yk4M8W6ikLURwkdd/YKtTbyI= -github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws= -github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw= -github.com/rabbitmq/amqp091-go v1.10.0 h1:STpn5XsHlHGcecLmMFCtg7mqq0RnD+zFr4uzukfVhBw= -github.com/rabbitmq/amqp091-go v1.10.0/go.mod h1:Hy4jKW5kQART1u+JkDTF9YYOQUHXqMuhrgxOEeS7G4o= -github.com/redis/go-redis/v9 v9.17.0 h1:K6E+ZlYN95KSMmZeEQPbU/c++wfmEvfFB17yEAq/VhM= -github.com/redis/go-redis/v9 v9.17.0/go.mod h1:u410H11HMLoB+TP67dz8rL9s6QW2j76l0//kSOd3370= +github.com/prometheus/common v0.68.0 h1:8rQJvQmYltsR2L7h8Zw0Iyj8WYNNmpwikoQTZXwfVeA= +github.com/prometheus/common v0.68.0/go.mod h1:4soH+U8yJSROk7OJ//hmTiWKsxapv6zRGgTt3keN8gQ= +github.com/prometheus/procfs v0.20.1 h1:XwbrGOIplXW/AU3YhIhLODXMJYyC1isLFfYCsTEycfc= +github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo= +github.com/rabbitmq/amqp091-go v1.11.0 h1:HxIctVm9Gid/Vtn706necmZ7Wj6pgGI2eqplRbEY8O8= +github.com/rabbitmq/amqp091-go v1.11.0/go.mod h1:Hy4jKW5kQART1u+JkDTF9YYOQUHXqMuhrgxOEeS7G4o= +github.com/redis/go-redis/v9 v9.20.0 h1:WnQYxLkgO2xiXTCJY0ldIiI8dNqCDlQAG+AtaH7a2a0= +github.com/redis/go-redis/v9 v9.20.0/go.mod h1:v/M13XI1PVCDcm01VtPFOADfZtHf8YW3baQf57KlIkA= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0= @@ -199,42 +197,48 @@ github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9de github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M= github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw= +github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs= +github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= -go.opentelemetry.io/contrib/bridges/otelslog v0.13.0 h1:bwnLpizECbPr1RrQ27waeY2SPIPeccCx/xLuoYADZ9s= -go.opentelemetry.io/contrib/bridges/otelslog v0.13.0/go.mod h1:3nWlOiiqA9UtUnrcNk82mYasNxD8ehOspL0gOfEo6Y4= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0 h1:RbKq8BG0FI8OiXhBfcRtqqHcZcka+gU3cskNuf05R18= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0/go.mod h1:h06DGIukJOevXaj/xrNjhi/2098RZzcLTbc0jDAUbsg= -go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8= -go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM= -go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.14.0 h1:OMqPldHt79PqWKOMYIAQs3CxAi7RLgPxwfFSwr4ZxtM= -go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.14.0/go.mod h1:1biG4qiqTxKiUCtoWDPpL3fB3KxVwCiGw81j3nKMuHE= -go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.38.0 h1:vl9obrcoWVKp/lwl8tRE33853I8Xru9HFbw/skNeLs8= -go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.38.0/go.mod h1:GAXRxmLJcVM3u22IjTg74zWBrRCKq8BnOqUVLodpcpw= -go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.38.0 h1:GqRJVj7UmLjCVyVJ3ZFLdPRmhDUp2zFmQe3RHIOsw24= -go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.38.0/go.mod h1:ri3aaHSmCTVYu2AWv44YMauwAQc0aqI9gHKIcSbI1pU= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.38.0 h1:lwI4Dc5leUqENgGuQImwLo4WnuXFPetmPpkLi2IrX54= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.38.0/go.mod h1:Kz/oCE7z5wuyhPxsXDuaPteSWqjSBD5YaSdbxZYGbGk= -go.opentelemetry.io/otel/log v0.14.0 h1:2rzJ+pOAZ8qmZ3DDHg73NEKzSZkhkGIua9gXtxNGgrM= -go.opentelemetry.io/otel/log v0.14.0/go.mod h1:5jRG92fEAgx0SU/vFPxmJvhIuDU9E1SUnEQrMlJpOno= -go.opentelemetry.io/otel/metric v1.38.0 h1:Kl6lzIYGAh5M159u9NgiRkmoMKjvbsKtYRwgfrA6WpA= -go.opentelemetry.io/otel/metric v1.38.0/go.mod h1:kB5n/QoRM8YwmUahxvI3bO34eVtQf2i4utNVLr9gEmI= -go.opentelemetry.io/otel/sdk v1.38.0 h1:l48sr5YbNf2hpCUj/FoGhW9yDkl+Ma+LrVl8qaM5b+E= -go.opentelemetry.io/otel/sdk v1.38.0/go.mod h1:ghmNdGlVemJI3+ZB5iDEuk4bWA3GkTpW+DOoZMYBVVg= -go.opentelemetry.io/otel/sdk/log v0.14.0 h1:JU/U3O7N6fsAXj0+CXz21Czg532dW2V4gG1HE/e8Zrg= -go.opentelemetry.io/otel/sdk/log v0.14.0/go.mod h1:imQvII+0ZylXfKU7/wtOND8Hn4OpT3YUoIgqJVksUkM= -go.opentelemetry.io/otel/sdk/log/logtest v0.14.0 h1:Ijbtz+JKXl8T2MngiwqBlPaHqc4YCaP/i13Qrow6gAM= -go.opentelemetry.io/otel/sdk/log/logtest v0.14.0/go.mod h1:dCU8aEL6q+L9cYTqcVOk8rM9Tp8WdnHOPLiBgp0SGOA= -go.opentelemetry.io/otel/sdk/metric v1.38.0 h1:aSH66iL0aZqo//xXzQLYozmWrXxyFkBJ6qT5wthqPoM= -go.opentelemetry.io/otel/sdk/metric v1.38.0/go.mod h1:dg9PBnW9XdQ1Hd6ZnRz689CbtrUp0wMMs9iPcgT9EZA= -go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE= -go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs= -go.opentelemetry.io/proto/otlp v1.9.0 h1:l706jCMITVouPOqEnii2fIAuO3IVGBRPV5ICjceRb/A= -go.opentelemetry.io/proto/otlp v1.9.0/go.mod h1:xE+Cx5E/eEHw+ISFkwPLwCZefwVjY+pqKg1qcK03+/4= +go.opentelemetry.io/contrib/bridges/otelslog v0.19.0 h1:5RgvxieNq9tS3ewrV1vnODvbHPfKUIJcYtF9Cvz+6aQ= +go.opentelemetry.io/contrib/bridges/otelslog v0.19.0/go.mod h1:iTBIdNwx/xmUhfgJs6+84S4dIK059811cO1eUBjKcHY= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 h1:8tvICD4vSTOOsNrsI4Ljf6C+6UKvpTEH5XY3JMoyPoo= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0/go.mod h1:z9+yiacE0IHRqM4qFfkbt/JYlmYXgss8GY/jXoNuPJI= +go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU= +go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc= +go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.20.0 h1:rydZ9sxbcFdm/oWrVyfLTjHIygMgv0bEeMd+3B/BvoM= +go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.20.0/go.mod h1:earQ25dooT0Hhspq59DZ8YCC50jWfOlFEeWoxy/P444= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.44.0 h1:SUplec5dp06reu1zaXmOXdvqH398taqrDXqUl99jxSc= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.44.0/go.mod h1:ho2g4N+ane+swq5I/VBkKWnRDY4kUINH3FuqyZqX/Ug= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0 h1:4YsVu3B8+3qtWYYrsUYgn0OG78pN0rnNPRGX4SbokQI= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0/go.mod h1:+wnlSn0mD1ADVMe3v9Z/WIaiz6q6gL2J/ejaAmdmv80= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.44.0 h1:qazEJlUOQzhCpzQpFETGby7EdqjI1wsd0W+6Gg1SCTU= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.44.0/go.mod h1:fOD2Yefuxixkx3ahVNf0O/PERb6r4OlbxfATVnYvzCo= +go.opentelemetry.io/otel/log v0.20.0 h1:/5i0vuHxCLWUfChWG41K9wkM0jafruPw9NU1/RCJirs= +go.opentelemetry.io/otel/log v0.20.0/go.mod h1:wOcMcjsZpG8x7Bak7IhSi/lg8wscV2C1VdrKCLPlt0E= +go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc= +go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo= +go.opentelemetry.io/otel/metric/x v0.66.0 h1:YkCrx1zLOChi9ZcZ6euupOcsgzbVlec7D/xoEU1+cTA= +go.opentelemetry.io/otel/metric/x v0.66.0/go.mod h1:d1+BDj9t96do0/1LoU1ayfCv79ZgNE41qbhBvnMOBZk= +go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58= +go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0= +go.opentelemetry.io/otel/sdk/log v0.20.0 h1:vM3xI7TQgKPiSghe6urZtAkyFY7SodrSpC83CffDFuY= +go.opentelemetry.io/otel/sdk/log v0.20.0/go.mod h1:Knej2nmsTUzN79T2eeXdRsjjPcoxoq2pUyUHz9TFyyU= +go.opentelemetry.io/otel/sdk/log/logtest v0.20.0 h1:OqdRZ1guyzamK3M6LlRsmGqRrjkHWw6WZOKKli5ELpg= +go.opentelemetry.io/otel/sdk/log/logtest v0.20.0/go.mod h1:PuMIlm7zAt7c3z8zfOI5ox4iT1Z87We+PF6YoINux/M= +go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI= +go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA= +go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk= +go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE= +go.opentelemetry.io/proto/otlp v1.10.0 h1:IQRWgT5srOCYfiWnpqUYz9CVmbO8bFmKcwYxpuCSL2g= +go.opentelemetry.io/proto/otlp v1.10.0/go.mod h1:/CV4QoCR/S9yaPj8utp3lvQPoqMtxXdzn7ozvvozVqk= +go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= +go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= -go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= -go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= +go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ= +go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= go.yaml.in/yaml/v4 v4.0.0-rc.3 h1:3h1fjsh1CTAPjW7q/EMe+C8shx5d8ctzZTrLcs/j8Go= @@ -244,57 +248,57 @@ golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8U golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk= -golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc= +golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM= +golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU= golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= -golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= -golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= -golang.org/x/oauth2 v0.33.0 h1:4Q+qn+E5z8gPRJfmRy7C2gGG3T4jIprK6aSYgTXGRpo= -golang.org/x/oauth2 v0.33.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= +golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= +golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= +golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= +golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I= -golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= -golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU= -golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254= +golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= +golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4= +golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= -golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= -golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= -golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= +golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= +golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= +golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= +golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= -golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ= -golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ= +golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c= +golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= -gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= -google.golang.org/genproto/googleapis/api v0.0.0-20251124214823-79d6a2a48846 h1:ZdyUkS9po3H7G0tuh955QVyyotWvOD4W0aEapeGeUYk= -google.golang.org/genproto/googleapis/api v0.0.0-20251124214823-79d6a2a48846/go.mod h1:Fk4kyraUvqD7i5H6S43sj2W98fbZa75lpZz/eUyhfO0= -google.golang.org/genproto/googleapis/rpc v0.0.0-20251124214823-79d6a2a48846 h1:Wgl1rcDNThT+Zn47YyCXOXyX/COgMTIdhJ717F0l4xk= -google.golang.org/genproto/googleapis/rpc v0.0.0-20251124214823-79d6a2a48846/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk= -google.golang.org/grpc v1.77.0 h1:wVVY6/8cGA6vvffn+wWK5ToddbgdU3d8MNENr4evgXM= -google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig= -google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= -google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= +gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= +google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa h1:Kjn0N0tCrDgiAFW+lGO4JZ3ck44CehvJQMAwj9QF0G8= +google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:q4lMZS6kskjT5HvCPrnnypcDPVJqT/f4nfxmkE7gryY= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa h1:mZHHdPZl0dbGHCflZgAq/Q468DWVFcU2whhB2KAo8fk= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/grpc v1.81.1 h1:VnnIIZ88UzOOKLukQi+ImGz8O1Wdp8nAGGnvOfEIWQQ= +google.golang.org/grpc v1.81.1/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= diff --git a/pkg/actor/keyed_mutex.go b/pkg/actor/keyed_mutex.go new file mode 100644 index 0000000..64e08bb --- /dev/null +++ b/pkg/actor/keyed_mutex.go @@ -0,0 +1,50 @@ +package actor + +import "sync" + +// keyedMutex provides one logical mutex per key (grain id). It is the +// mechanism that enforces the core actor guarantee: a single grain only ever +// processes one message (spawn / mutation / read) at a time, while distinct +// grains run fully in parallel. +// +// Locks are reference counted and removed once no caller holds or waits on +// them, so memory stays proportional to in-flight grains rather than the total +// number of grains ever touched. +type keyedMutex struct { + mu sync.Mutex + locks map[uint64]*keyedMutexEntry +} + +type keyedMutexEntry struct { + mu sync.Mutex + refs int +} + +func newKeyedMutex() *keyedMutex { + return &keyedMutex{locks: make(map[uint64]*keyedMutexEntry)} +} + +// lock acquires the mutex for id and returns an unlock function. The returned +// function must be called exactly once. +func (k *keyedMutex) lock(id uint64) func() { + k.mu.Lock() + entry, ok := k.locks[id] + if !ok { + entry = &keyedMutexEntry{} + k.locks[id] = entry + } + entry.refs++ + k.mu.Unlock() + + entry.mu.Lock() + + return func() { + entry.mu.Unlock() + k.mu.Lock() + entry.refs-- + if entry.refs == 0 { + delete(k.locks, id) + } + k.mu.Unlock() + } +} diff --git a/pkg/actor/simple_grain_pool.go b/pkg/actor/simple_grain_pool.go index 6fe942f..991ef28 100644 --- a/pkg/actor/simple_grain_pool.go +++ b/pkg/actor/simple_grain_pool.go @@ -24,6 +24,10 @@ type SimpleGrainPool[V any] struct { ttl time.Duration poolSize int + // grainLocks serializes spawn + mutation + read per grain id so that each + // grain processes a single message at a time (the actor guarantee). + grainLocks *keyedMutex + // Cluster coordination -------------------------------------------------- hostname string remoteMu sync.RWMutex @@ -59,6 +63,7 @@ func NewSimpleGrainPool[V any](config GrainPoolConfig[V]) (*SimpleGrainPool[V], hostname: config.Hostname, remoteOwners: make(map[uint64]Host[V]), remoteHosts: make(map[string]Host[V]), + grainLocks: newKeyedMutex(), } p.purgeTicker = time.NewTicker(time.Minute) @@ -187,7 +192,17 @@ func (p *SimpleGrainPool[V]) AddRemote(host string) (Host[V], error) { return nil, err } + + // Re-check under the write lock: spawnHost opened a real connection above + // without holding the lock, so a concurrent AddRemote for the same host may + // have already registered one. Keep the existing entry and close ours to + // avoid leaking the gRPC connection / HTTP transport (and its file handles). p.remoteMu.Lock() + if existing, found := p.remoteHosts[host]; found { + p.remoteMu.Unlock() + go remote.Close() + return existing, nil + } p.remoteHosts[host] = remote p.remoteMu.Unlock() // connectedRemotes.Set(float64(p.RemoteCount())) @@ -221,7 +236,6 @@ func (p *SimpleGrainPool[V]) RemoveHost(host string) { remote, exists := p.remoteHosts[host] if exists { - go remote.Close() delete(p.remoteHosts, host) } count := 0 @@ -234,6 +248,7 @@ func (p *SimpleGrainPool[V]) RemoveHost(host string) { log.Printf("Removing host %s, grains: %d", host, count) p.remoteMu.Unlock() + // Close once, outside the lock. if exists { remote.Close() } @@ -275,7 +290,9 @@ func (p *SimpleGrainPool[V]) pingLoop(remote Host[V]) { if !remote.Ping() { if !remote.IsHealthy() { log.Printf("Remote %s unhealthy, removing", remote.Name()) - p.Close() + // Remove only this host. Previously this called p.Close(), + // which tore down every remote connection and stopped the + // purge ticker for the whole pool. p.RemoveHost(remote.Name()) return } @@ -397,6 +414,12 @@ func (p *SimpleGrainPool[V]) getOrClaimGrain(ctx context.Context, id uint64) (Gr // Apply applies a mutation to a grain. func (p *SimpleGrainPool[V]) Apply(ctx context.Context, id uint64, mutation ...proto.Message) (*MutationResult[V], error) { + // Serialize all access to this grain: spawn, mutation handlers and the + // final state read happen atomically with respect to other callers of the + // same id. Different ids never contend. + unlock := p.grainLocks.lock(id) + defer unlock() + grain, err := p.getOrClaimGrain(ctx, id) if err != nil { return nil, err @@ -429,6 +452,9 @@ func (p *SimpleGrainPool[V]) Apply(ctx context.Context, id uint64, mutation ...p // Get returns the current state of a grain. func (p *SimpleGrainPool[V]) Get(ctx context.Context, id uint64) (*V, error) { + unlock := p.grainLocks.lock(id) + defer unlock() + grain, err := p.getOrClaimGrain(ctx, id) if err != nil { return nil, err diff --git a/pkg/cart/cart-grain.go b/pkg/cart/cart-grain.go index 2a3cb80..b52f4ee 100644 --- a/pkg/cart/cart-grain.go +++ b/pkg/cart/cart-grain.go @@ -28,29 +28,38 @@ type ItemMeta struct { } type CartItem struct { - Id uint32 `json:"id"` - ItemId uint32 `json:"itemId,omitempty"` - ParentId *uint32 `json:"parentId,omitempty"` - Sku string `json:"sku"` - Price Price `json:"price"` - TotalPrice Price `json:"totalPrice"` - SellerId string `json:"sellerId,omitempty"` - OrgPrice *Price `json:"orgPrice,omitempty"` - Cgm string `json:"cgm,omitempty"` - Tax int - Stock uint16 `json:"stock"` - Quantity uint16 `json:"qty"` - Discount *Price `json:"discount,omitempty"` - Disclaimer string `json:"disclaimer,omitempty"` - ArticleType string `json:"type,omitempty"` - StoreId *string `json:"storeId,omitempty"` - Meta *ItemMeta `json:"meta,omitempty"` - SaleStatus string `json:"saleStatus"` - Marking *Marking `json:"marking,omitempty"` - SubscriptionDetailsId string `json:"subscriptionDetailsId,omitempty"` - OrderReference string `json:"orderReference,omitempty"` - IsSubscribed bool `json:"isSubscribed,omitempty"` - ReservationEndTime *time.Time `json:"reservationEndTime,omitempty"` + Id uint32 `json:"id"` + ItemId uint32 `json:"itemId,omitempty"` + ParentId *uint32 `json:"parentId,omitempty"` + Sku string `json:"sku"` + Price Price `json:"price"` + TotalPrice Price `json:"totalPrice"` + SellerId string `json:"sellerId,omitempty"` + OrgPrice *Price `json:"orgPrice,omitempty"` + Cgm string `json:"cgm,omitempty"` + Tax int + Stock uint16 `json:"stock"` + Quantity uint16 `json:"qty"` + Discount *Price `json:"discount,omitempty"` + Disclaimer string `json:"disclaimer,omitempty"` + ArticleType string `json:"type,omitempty"` + StoreId *string `json:"storeId,omitempty"` + Meta *ItemMeta `json:"meta,omitempty"` + SaleStatus string `json:"saleStatus"` + Marking *Marking `json:"marking,omitempty"` + // CustomFields holds optional user-supplied input fields for this line + // (engraving text, configurator notes, ...), keyed by field name. + CustomFields map[string]string `json:"customFields,omitempty"` + SubscriptionDetailsId string `json:"subscriptionDetailsId,omitempty"` + OrderReference string `json:"orderReference,omitempty"` + IsSubscribed bool `json:"isSubscribed,omitempty"` + ReservationEndTime *time.Time `json:"reservationEndTime,omitempty"` + + // Extra holds arbitrary dynamic product data. Its keys are flattened onto + // the item object in JSON (see cart_item_json.go), so they are returned + // over the API alongside the typed fields. Typed fields win on key + // collisions. + Extra map[string]json.RawMessage `json:"-"` } type CartNotification struct { diff --git a/pkg/cart/cart-mutation-helper.go b/pkg/cart/cart-mutation-helper.go index e0965a8..ff0392e 100644 --- a/pkg/cart/cart-mutation-helper.go +++ b/pkg/cart/cart-mutation-helper.go @@ -79,6 +79,7 @@ func NewCartMultationRegistry(context *CartMutationContext) actor.MutationRegist actor.NewMutation(SetUserId), actor.NewMutation(LineItemMarking), actor.NewMutation(RemoveLineItemMarking), + actor.NewMutation(SetLineItemCustomFields), actor.NewMutation(SubscriptionAdded), // actor.NewMutation(SubscriptionRemoved), ) diff --git a/pkg/cart/cart_item_json.go b/pkg/cart/cart_item_json.go new file mode 100644 index 0000000..2a26776 --- /dev/null +++ b/pkg/cart/cart_item_json.go @@ -0,0 +1,77 @@ +package cart + +import ( + "encoding/json" + "reflect" + "strings" +) + +// cartItemAlias mirrors CartItem but has no custom (Un)MarshalJSON, so it is +// used to (de)serialize the typed fields without recursing. +type cartItemAlias CartItem + +// knownItemKeys is the set of top-level JSON object keys owned by typed +// CartItem fields. Any other key in an item object is dynamic and kept in +// CartItem.Extra. +var knownItemKeys = buildKnownItemKeys() + +func buildKnownItemKeys() map[string]struct{} { + keys := make(map[string]struct{}) + t := reflect.TypeOf(CartItem{}) + for i := 0; i < t.NumField(); i++ { + name, _, _ := strings.Cut(t.Field(i).Tag.Get("json"), ",") + if name == "" { + name = t.Field(i).Name // untagged fields serialize under their Go name + } + if name == "-" { + continue // e.g. Extra itself + } + keys[name] = struct{}{} + } + return keys +} + +// MarshalJSON emits the typed fields and flattens Extra onto the same object. +// Typed fields take precedence on key collisions. +func (c CartItem) MarshalJSON() ([]byte, error) { + core, err := json.Marshal(cartItemAlias(c)) + if err != nil || len(c.Extra) == 0 { + return core, err + } + merged := make(map[string]json.RawMessage, len(c.Extra)+8) + if err := json.Unmarshal(core, &merged); err != nil { + return nil, err + } + for k, v := range c.Extra { + if _, taken := merged[k]; taken { + continue + } + merged[k] = v + } + return json.Marshal(merged) +} + +// UnmarshalJSON reads the typed fields and collects every other key into Extra. +func (c *CartItem) UnmarshalJSON(data []byte) error { + var alias cartItemAlias + if err := json.Unmarshal(data, &alias); err != nil { + return err + } + *c = CartItem(alias) + + all := make(map[string]json.RawMessage) + if err := json.Unmarshal(data, &all); err != nil { + return err + } + for k := range all { + if _, known := knownItemKeys[k]; known { + delete(all, k) + } + } + if len(all) > 0 { + c.Extra = all + } else { + c.Extra = nil + } + return nil +} diff --git a/pkg/cart/cart_item_json_test.go b/pkg/cart/cart_item_json_test.go new file mode 100644 index 0000000..9e6fb60 --- /dev/null +++ b/pkg/cart/cart_item_json_test.go @@ -0,0 +1,94 @@ +package cart + +import ( + "encoding/json" + "testing" +) + +func TestCartItem_FlattensDynamicKeys(t *testing.T) { + item := CartItem{ + Id: 7, + Sku: "YD10050921H11406", + Quantity: 2, + Extra: map[string]json.RawMessage{ + "glas": json.RawMessage(`"V"`), + "hangning": json.RawMessage(`"H"`), + "deliveryWeek": json.RawMessage(`10`), + }, + } + + b, err := json.Marshal(&item) + if err != nil { + t.Fatalf("marshal: %v", err) + } + + var flat map[string]json.RawMessage + if err := json.Unmarshal(b, &flat); err != nil { + t.Fatalf("unmarshal to map: %v", err) + } + + // Typed field present at top level. + if _, ok := flat["sku"]; !ok { + t.Error("expected typed key \"sku\" at top level") + } + // Dynamic keys flattened to top level (not nested under \"extra\"). + for _, k := range []string{"glas", "hangning", "deliveryWeek"} { + if _, ok := flat[k]; !ok { + t.Errorf("expected dynamic key %q flattened to top level", k) + } + } + if string(flat["glas"]) != `"V"` { + t.Errorf("glas = %s, want \"V\"", flat["glas"]) + } +} + +func TestCartItem_RoundTrip(t *testing.T) { + original := CartItem{ + Id: 3, + Sku: "ABC", + Quantity: 1, + Extra: map[string]json.RawMessage{ + "color": json.RawMessage(`"red"`), + "weight": json.RawMessage(`12.5`), + }, + } + + b, err := json.Marshal(&original) + if err != nil { + t.Fatalf("marshal: %v", err) + } + + var got CartItem + if err := json.Unmarshal(b, &got); err != nil { + t.Fatalf("unmarshal: %v", err) + } + + if got.Sku != original.Sku || got.Id != original.Id || got.Quantity != original.Quantity { + t.Errorf("typed fields not preserved: got %+v", got) + } + if len(got.Extra) != 2 { + t.Fatalf("Extra len = %d, want 2 (%v)", len(got.Extra), got.Extra) + } + if string(got.Extra["color"]) != `"red"` { + t.Errorf("color = %s, want \"red\"", got.Extra["color"]) + } + // A typed key must never leak into Extra. + if _, leaked := got.Extra["sku"]; leaked { + t.Error("typed key \"sku\" leaked into Extra") + } +} + +func TestCartItem_NoExtraIsCompact(t *testing.T) { + item := CartItem{Id: 1, Sku: "X", Quantity: 1} + b, err := json.Marshal(&item) + if err != nil { + t.Fatalf("marshal: %v", err) + } + var flat map[string]json.RawMessage + if err := json.Unmarshal(b, &flat); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if _, ok := flat["sku"]; !ok { + t.Error("expected \"sku\" in output") + } +} diff --git a/pkg/cart/mutation_add_item.go b/pkg/cart/mutation_add_item.go index f929792..4dc76c6 100644 --- a/pkg/cart/mutation_add_item.go +++ b/pkg/cart/mutation_add_item.go @@ -2,6 +2,7 @@ package cart import ( "context" + "encoding/json" "errors" "fmt" "log" @@ -11,6 +12,20 @@ import ( "google.golang.org/protobuf/types/known/timestamppb" ) +// decodeExtra unpacks the dynamic product data carried as raw JSON. Invalid +// payloads are logged and dropped rather than failing the mutation. +func decodeExtra(b []byte) map[string]json.RawMessage { + if len(b) == 0 { + return nil + } + m := map[string]json.RawMessage{} + if err := json.Unmarshal(b, &m); err != nil { + log.Printf("AddItem: invalid extra_json: %v", err) + return nil + } + return m +} + // mutation_add_item.go // // Registers the AddItem cart mutation in the generic mutation registry. @@ -18,10 +33,13 @@ import ( // // Behavior: // - Validates quantity > 0 -// - If an item with same SKU exists -> increases quantity +// - If an item with the same item id (ItemId) exists -> increases quantity // - Else creates a new CartItem with computed tax amounts // - Totals recalculated automatically via WithTotals() // +// Item identity is the catalog item id (ItemId), not the SKU: the product +// service is looked up by id and the returned SKU is reference-only. +// // NOTE: Any future field additions in messages.AddItem that affect pricing / tax // must keep this handler in sync. var ErrPaymentInProgress = errors.New("payment in progress") @@ -35,9 +53,10 @@ func (c *CartMutationContext) AddItem(g *CartGrain, m *cart_messages.AddItem) er return fmt.Errorf("AddItem: invalid quantity %d", m.Quantity) } - // Merge with any existing item having same SKU and matching StoreId (including both nil). + // Merge with any existing item having the same item id and matching StoreId + // (including both nil). Identity is the id; SKU is reference-only. for _, existing := range g.Items { - if existing.Sku != m.Sku { + if existing.ItemId != m.ItemId { continue } sameStore := (existing.StoreId == nil && m.StoreId == nil) || @@ -61,6 +80,14 @@ func (c *CartMutationContext) AddItem(g *CartGrain, m *cart_messages.AddItem) er if existing.StoreId == nil && m.StoreId != nil { existing.StoreId = m.StoreId } + // Refresh dynamic product data with the latest payload. + if extra := decodeExtra(m.ExtraJson); extra != nil { + existing.Extra = extra + } + // Replace custom fields when provided on the re-add. + if len(m.CustomFields) > 0 { + existing.CustomFields = m.CustomFields + } return nil } @@ -114,6 +141,9 @@ func (c *CartMutationContext) AddItem(g *CartGrain, m *cart_messages.AddItem) er ArticleType: m.ArticleType, StoreId: m.StoreId, + + Extra: decodeExtra(m.ExtraJson), + CustomFields: m.CustomFields, } if needsReservation && c.UseReservations(cartItem) { diff --git a/pkg/cart/mutation_add_item_test.go b/pkg/cart/mutation_add_item_test.go new file mode 100644 index 0000000..3945747 --- /dev/null +++ b/pkg/cart/mutation_add_item_test.go @@ -0,0 +1,40 @@ +package cart + +import ( + "context" + "testing" + "time" + + cart_messages "git.k6n.net/go-cart-actor/proto/cart" +) + +// Item identity is the catalog item id, not the (reference-only) SKU: two adds +// with the same ItemId but different SKU strings must merge into one line. +func TestAddItem_MergesByItemIdNotSku(t *testing.T) { + reg := NewCartMultationRegistry(NewCartMutationContext(nil)) + g := NewCartGrain(1, time.Now()) + ctx := context.Background() + + if _, err := reg.Apply(ctx, g, &cart_messages.AddItem{ItemId: 144047, Sku: "A0162590", Quantity: 1, Price: 1000}); err != nil { + t.Fatalf("first add: %v", err) + } + // Same id, different (reference) sku string -> should merge, not create a line. + if _, err := reg.Apply(ctx, g, &cart_messages.AddItem{ItemId: 144047, Sku: "STALE-REF", Quantity: 2, Price: 1000}); err != nil { + t.Fatalf("second add: %v", err) + } + + if len(g.Items) != 1 { + t.Fatalf("items = %d, want 1 (merged by id)", len(g.Items)) + } + if g.Items[0].Quantity != 3 { + t.Errorf("quantity = %d, want 3", g.Items[0].Quantity) + } + + // A different id is a distinct line. + if _, err := reg.Apply(ctx, g, &cart_messages.AddItem{ItemId: 170852, Sku: "A0190103", Quantity: 1, Price: 500}); err != nil { + t.Fatalf("third add: %v", err) + } + if len(g.Items) != 2 { + t.Fatalf("items = %d, want 2", len(g.Items)) + } +} diff --git a/pkg/cart/mutation_custom_fields_test.go b/pkg/cart/mutation_custom_fields_test.go new file mode 100644 index 0000000..9e1435c --- /dev/null +++ b/pkg/cart/mutation_custom_fields_test.go @@ -0,0 +1,65 @@ +package cart + +import ( + "context" + "testing" + "time" + + cart_messages "git.k6n.net/go-cart-actor/proto/cart" +) + +func TestAddItem_StoresCustomFields(t *testing.T) { + reg := NewCartMultationRegistry(NewCartMutationContext(nil)) + g := NewCartGrain(1, time.Now()) + + mustApply(t, reg, g, &cart_messages.AddItem{ + ItemId: 100, Sku: "P", Quantity: 1, Price: 1000, + CustomFields: map[string]string{"engraving": "Happy Birthday", "color": "blue"}, + }) + + if len(g.Items) != 1 { + t.Fatalf("items = %d, want 1", len(g.Items)) + } + cf := g.Items[0].CustomFields + if cf["engraving"] != "Happy Birthday" || cf["color"] != "blue" { + t.Fatalf("custom fields = %v, want engraving+color", cf) + } +} + +func TestSetLineItemCustomFields_Merges(t *testing.T) { + reg := NewCartMultationRegistry(NewCartMutationContext(nil)) + g := NewCartGrain(1, time.Now()) + + mustApply(t, reg, g, &cart_messages.AddItem{ + ItemId: 100, Sku: "P", Quantity: 1, Price: 1000, + CustomFields: map[string]string{"engraving": "v1"}, + }) + line := g.Items[0].Id + + // Upsert: overwrite "engraving", add "note", leave others alone. + mustApply(t, reg, g, &cart_messages.SetLineItemCustomFields{ + Id: line, + CustomFields: map[string]string{"engraving": "v2", "note": "gift wrap"}, + }) + + cf := g.Items[0].CustomFields + if cf["engraving"] != "v2" { + t.Errorf("engraving = %q, want v2", cf["engraving"]) + } + if cf["note"] != "gift wrap" { + t.Errorf("note = %q, want 'gift wrap'", cf["note"]) + } +} + +func TestSetLineItemCustomFields_UnknownItem(t *testing.T) { + reg := NewCartMultationRegistry(NewCartMutationContext(nil)) + g := NewCartGrain(1, time.Now()) + // The handler error is reported per-mutation in the ApplyResult (the + // registry only returns a top-level error for unregistered mutations). + results, _ := reg.Apply(context.Background(), g, &cart_messages.SetLineItemCustomFields{ + Id: 999, CustomFields: map[string]string{"x": "y"}, + }) + if len(results) != 1 || results[0].Error == nil { + t.Errorf("expected per-mutation error for unknown item id, got %+v", results) + } +} diff --git a/pkg/cart/mutation_remove_item.go b/pkg/cart/mutation_remove_item.go index dbd6eb4..d653cc3 100644 --- a/pkg/cart/mutation_remove_item.go +++ b/pkg/cart/mutation_remove_item.go @@ -15,15 +15,17 @@ import ( // // Behavior: // - Removes the cart line whose local cart line Id == payload.Id +// - Cascades: also removes any line whose ParentId points at a removed line +// (transitively), so removing a parent removes its child sub-articles // - If no such line exists returns an error -// - Recalculates cart totals (WithTotals) +// - Releases reservations for every removed line and recalculates totals // // Notes: -// - This removes only the line item; any deliveries referencing the removed +// - This removes only the line items; any deliveries referencing a removed // item are NOT automatically adjusted (mirrors prior logic). If future // semantics require pruning delivery.item_ids you can extend this handler. -// - If multiple lines somehow shared the same Id (should not happen), only -// the first match would be removed—data integrity relies on unique line Ids. +// - If multiple lines somehow shared the same Id (should not happen), all +// matches are removed—data integrity relies on unique line Ids. func (c *CartMutationContext) RemoveItem(g *CartGrain, m *messages.RemoveItem) error { if m == nil { @@ -32,26 +34,46 @@ func (c *CartMutationContext) RemoveItem(g *CartGrain, m *messages.RemoveItem) e targetID := uint32(m.Id) - index := -1 - for i, it := range g.Items { + found := false + for _, it := range g.Items { if it.Id == targetID { - index = i + found = true break } } - if index == -1 { + if !found { return fmt.Errorf("RemoveItem: item id %d not found", m.Id) } - item := g.Items[index] - if item.ReservationEndTime != nil && item.ReservationEndTime.After(time.Now()) { - err := c.ReleaseItem(context.Background(), g.Id, item.Sku, item.StoreId) - if err != nil { - log.Printf("unable to release item reservation") + // Collect the target and, transitively, any children pointing at a removed + // line. Loops until no further descendants are found (handles nesting). + remove := map[uint32]bool{targetID: true} + for { + grew := false + for _, it := range g.Items { + if it.ParentId != nil && remove[*it.ParentId] && !remove[it.Id] { + remove[it.Id] = true + grew = true + } + } + if !grew { + break } } - g.Items = append(g.Items[:index], g.Items[index+1:]...) + kept := g.Items[:0] + for _, it := range g.Items { + if remove[it.Id] { + if it.ReservationEndTime != nil && it.ReservationEndTime.After(time.Now()) { + if err := c.ReleaseItem(context.Background(), g.Id, it.Sku, it.StoreId); err != nil { + log.Printf("unable to release reservation for item %d: %v", it.Id, err) + } + } + continue + } + kept = append(kept, it) + } + g.Items = kept g.UpdateTotals() return nil } diff --git a/pkg/cart/mutation_remove_item_test.go b/pkg/cart/mutation_remove_item_test.go new file mode 100644 index 0000000..26e2200 --- /dev/null +++ b/pkg/cart/mutation_remove_item_test.go @@ -0,0 +1,70 @@ +package cart + +import ( + "context" + "testing" + "time" + + "git.k6n.net/go-cart-actor/pkg/actor" + cart_messages "git.k6n.net/go-cart-actor/proto/cart" + "google.golang.org/protobuf/proto" +) + +// Removing a parent line cascades to its child sub-articles, while unrelated +// lines are left untouched. +func TestRemoveItem_CascadesToChildren(t *testing.T) { + reg := NewCartMultationRegistry(NewCartMutationContext(nil)) + g := NewCartGrain(1, time.Now()) + ctx := context.Background() + + // Parent (line 1) + two children (lines 2,3) + an unrelated item (line 4). + mustApply(t, reg, g, &cart_messages.AddItem{ItemId: 100, Sku: "P", Quantity: 1, Price: 1000}) + parentLine := g.Items[0].Id + mustApply(t, reg, g, &cart_messages.AddItem{ItemId: 101, Sku: "C1", Quantity: 1, Price: 100, ParentId: &parentLine}) + mustApply(t, reg, g, &cart_messages.AddItem{ItemId: 102, Sku: "C2", Quantity: 1, Price: 200, ParentId: &parentLine}) + mustApply(t, reg, g, &cart_messages.AddItem{ItemId: 200, Sku: "OTHER", Quantity: 1, Price: 300}) + + if len(g.Items) != 4 { + t.Fatalf("setup: items = %d, want 4", len(g.Items)) + } + + if _, err := reg.Apply(ctx, g, &cart_messages.RemoveItem{Id: parentLine}); err != nil { + t.Fatalf("remove parent: %v", err) + } + + if len(g.Items) != 1 { + t.Fatalf("after remove: items = %d, want 1 (only the unrelated line)", len(g.Items)) + } + if g.Items[0].Sku != "OTHER" { + t.Errorf("remaining item sku = %q, want OTHER", g.Items[0].Sku) + } +} + +// Removing a child leaves the parent and siblings intact. +func TestRemoveItem_ChildDoesNotRemoveParent(t *testing.T) { + reg := NewCartMultationRegistry(NewCartMutationContext(nil)) + g := NewCartGrain(1, time.Now()) + ctx := context.Background() + + mustApply(t, reg, g, &cart_messages.AddItem{ItemId: 100, Sku: "P", Quantity: 1, Price: 1000}) + parentLine := g.Items[0].Id + mustApply(t, reg, g, &cart_messages.AddItem{ItemId: 101, Sku: "C1", Quantity: 1, Price: 100, ParentId: &parentLine}) + childLine := g.Items[1].Id + + if _, err := reg.Apply(ctx, g, &cart_messages.RemoveItem{Id: childLine}); err != nil { + t.Fatalf("remove child: %v", err) + } + if len(g.Items) != 1 { + t.Fatalf("items = %d, want 1 (parent remains)", len(g.Items)) + } + if g.Items[0].Id != parentLine { + t.Errorf("remaining line = %d, want parent %d", g.Items[0].Id, parentLine) + } +} + +func mustApply(t *testing.T, reg actor.MutationRegistry, g *CartGrain, m proto.Message) { + t.Helper() + if _, err := reg.Apply(context.Background(), g, m); err != nil { + t.Fatalf("apply %T: %v", m, err) + } +} diff --git a/pkg/cart/mutation_set_custom_fields.go b/pkg/cart/mutation_set_custom_fields.go new file mode 100644 index 0000000..50006eb --- /dev/null +++ b/pkg/cart/mutation_set_custom_fields.go @@ -0,0 +1,31 @@ +package cart + +import ( + "fmt" + + messages "git.k6n.net/go-cart-actor/proto/cart" +) + +// SetLineItemCustomFields sets/merges user-supplied custom input fields on an +// existing cart line. It is the dict equivalent of LineItemMarking: keys in the +// request are upserted; existing keys not present in the request are left +// untouched. (Send an empty value to clear a single field at the API layer if +// desired.) +func SetLineItemCustomFields(grain *CartGrain, req *messages.SetLineItemCustomFields) error { + for _, item := range grain.Items { + if item.Id != req.Id { + continue + } + if len(req.CustomFields) == 0 { + return nil + } + if item.CustomFields == nil { + item.CustomFields = make(map[string]string, len(req.CustomFields)) + } + for k, v := range req.CustomFields { + item.CustomFields[k] = v + } + return nil + } + return fmt.Errorf("item with ID %d not found", req.Id) +} diff --git a/pkg/proxy/remotehost.go b/pkg/proxy/remotehost.go index a8aa9ec..2b6c925 100644 --- a/pkg/proxy/remotehost.go +++ b/pkg/proxy/remotehost.go @@ -69,7 +69,15 @@ func (m *MockResponseWriter) WriteHeader(statusCode int) { m.StatusCode = statusCode } -func NewRemoteHost[V any](host string) (*RemoteHost[V], error) { +// NewRemoteHost connects to a peer node. httpPort overrides the peer's HTTP +// port used for request proxying (defaults to "8080"); the gRPC control port +// is always 1337. +func NewRemoteHost[V any](host string, httpPort ...string) (*RemoteHost[V], error) { + + port := "8080" + if len(httpPort) > 0 && httpPort[0] != "" { + port = httpPort[0] + } target := fmt.Sprintf("%s:1337", host) @@ -85,14 +93,18 @@ func NewRemoteHost[V any](host string) (*RemoteHost[V], error) { transport := &http.Transport{ MaxIdleConns: 100, MaxIdleConnsPerHost: 100, - DisableKeepAlives: false, - IdleConnTimeout: 120 * time.Second, + // Bound concurrently-open connections to this peer so a load spike + // can't exhaust file handles. Excess requests wait for a free conn + // rather than opening unbounded sockets. + MaxConnsPerHost: 256, + DisableKeepAlives: false, + IdleConnTimeout: 120 * time.Second, } client := &http.Client{Transport: transport, Timeout: 10 * time.Second} return &RemoteHost[V]{ host: host, - httpBase: fmt.Sprintf("http://%s:8080", host), + httpBase: fmt.Sprintf("http://%s:%s", host, port), conn: conn, transport: transport, client: client, @@ -101,6 +113,25 @@ func NewRemoteHost[V any](host string) (*RemoteHost[V], error) { }, nil } +// hopByHopHeaders are connection-specific headers that must not be forwarded +// by a proxy (RFC 7230 §6.1). +var hopByHopHeaders = map[string]struct{}{ + "Connection": {}, + "Proxy-Connection": {}, + "Keep-Alive": {}, + "Proxy-Authenticate": {}, + "Proxy-Authorization": {}, + "Te": {}, + "Trailer": {}, + "Transfer-Encoding": {}, + "Upgrade": {}, +} + +func isHopByHopHeader(key string) bool { + _, ok := hopByHopHeaders[http.CanonicalHeaderKey(key)] + return ok +} + func (h *RemoteHost[V]) Name() string { return h.host } @@ -277,6 +308,12 @@ func (h *RemoteHost[V]) Proxy(id uint64, w http.ResponseWriter, r *http.Request, req.Header.Set("X-Forwarded-Host", r.Host) for k, v := range r.Header { + if isHopByHopHeader(k) { + // Don't forward connection-management headers (notably an inbound + // "Connection: close" would disable keep-alive on the proxied leg + // and churn outbound connections / file handles). + continue + } for _, vv := range v { req.Header.Add(k, vv) } @@ -290,6 +327,9 @@ func (h *RemoteHost[V]) Proxy(id uint64, w http.ResponseWriter, r *http.Request, defer res.Body.Close() span.SetAttributes(attribute.Int("status_code", res.StatusCode)) for k, v := range res.Header { + if isHopByHopHeader(k) { + continue + } for _, vv := range v { w.Header().Add(k, vv) } diff --git a/proto/cart.proto b/proto/cart.proto index 685255e..36ab6de 100644 --- a/proto/cart.proto +++ b/proto/cart.proto @@ -34,6 +34,13 @@ message AddItem { optional uint32 parentId = 23; string cgm = 25; optional google.protobuf.Timestamp reservationEndTime = 26; + // extra_json carries arbitrary product data (the flat product document minus + // the fields mapped above) as raw JSON. It is stored losslessly and surfaced + // as flattened keys on the cart item. + bytes extra_json = 27; + // custom_fields holds optional user-supplied input fields for this line + // (e.g. engraving text, configurator notes), keyed by field name. + map custom_fields = 28; } message RemoveItem { uint32 Id = 1; } @@ -57,6 +64,13 @@ message RemoveLineItemMarking { uint32 id = 1; } +// SetLineItemCustomFields sets/merges user-supplied custom input fields on an +// existing cart line (the dict equivalent of a line item marking). +message SetLineItemCustomFields { + uint32 id = 1; + map custom_fields = 2; +} + message SubscriptionAdded { uint32 itemId = 1; string detailsId = 3; @@ -89,6 +103,7 @@ message Mutation { LineItemMarking line_item_marking = 6; RemoveLineItemMarking remove_line_item_marking = 7; SubscriptionAdded subscription_added = 8; + SetLineItemCustomFields set_line_item_custom_fields = 9; AddVoucher add_voucher = 20; RemoveVoucher remove_voucher = 21; UpsertSubscriptionDetails upsert_subscription_details = 22; diff --git a/proto/cart/cart.pb.go b/proto/cart/cart.pb.go index 6f4849a..bda2145 100644 --- a/proto/cart/cart.pb.go +++ b/proto/cart/cart.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: -// protoc-gen-go v1.36.10 -// protoc v6.33.1 +// protoc-gen-go v1.36.11 +// protoc v7.35.0 // source: cart.proto package cart_messages @@ -87,8 +87,15 @@ type AddItem struct { ParentId *uint32 `protobuf:"varint,23,opt,name=parentId,proto3,oneof" json:"parentId,omitempty"` Cgm string `protobuf:"bytes,25,opt,name=cgm,proto3" json:"cgm,omitempty"` ReservationEndTime *timestamppb.Timestamp `protobuf:"bytes,26,opt,name=reservationEndTime,proto3,oneof" json:"reservationEndTime,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // extra_json carries arbitrary product data (the flat product document minus + // the fields mapped above) as raw JSON. It is stored losslessly and surfaced + // as flattened keys on the cart item. + ExtraJson []byte `protobuf:"bytes,27,opt,name=extra_json,json=extraJson,proto3" json:"extra_json,omitempty"` + // custom_fields holds optional user-supplied input fields for this line + // (e.g. engraving text, configurator notes), keyed by field name. + CustomFields map[string]string `protobuf:"bytes,28,rep,name=custom_fields,json=customFields,proto3" json:"custom_fields,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *AddItem) Reset() { @@ -303,6 +310,20 @@ func (x *AddItem) GetReservationEndTime() *timestamppb.Timestamp { return nil } +func (x *AddItem) GetExtraJson() []byte { + if x != nil { + return x.ExtraJson + } + return nil +} + +func (x *AddItem) GetCustomFields() map[string]string { + if x != nil { + return x.CustomFields + } + return nil +} + type RemoveItem struct { state protoimpl.MessageState `protogen:"open.v1"` Id uint32 `protobuf:"varint,1,opt,name=Id,proto3" json:"Id,omitempty"` @@ -547,6 +568,60 @@ func (x *RemoveLineItemMarking) GetId() uint32 { return 0 } +// SetLineItemCustomFields sets/merges user-supplied custom input fields on an +// existing cart line (the dict equivalent of a line item marking). +type SetLineItemCustomFields struct { + state protoimpl.MessageState `protogen:"open.v1"` + Id uint32 `protobuf:"varint,1,opt,name=id,proto3" json:"id,omitempty"` + CustomFields map[string]string `protobuf:"bytes,2,rep,name=custom_fields,json=customFields,proto3" json:"custom_fields,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SetLineItemCustomFields) Reset() { + *x = SetLineItemCustomFields{} + mi := &file_cart_proto_msgTypes[7] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SetLineItemCustomFields) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SetLineItemCustomFields) ProtoMessage() {} + +func (x *SetLineItemCustomFields) ProtoReflect() protoreflect.Message { + mi := &file_cart_proto_msgTypes[7] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use SetLineItemCustomFields.ProtoReflect.Descriptor instead. +func (*SetLineItemCustomFields) Descriptor() ([]byte, []int) { + return file_cart_proto_rawDescGZIP(), []int{7} +} + +func (x *SetLineItemCustomFields) GetId() uint32 { + if x != nil { + return x.Id + } + return 0 +} + +func (x *SetLineItemCustomFields) GetCustomFields() map[string]string { + if x != nil { + return x.CustomFields + } + return nil +} + type SubscriptionAdded struct { state protoimpl.MessageState `protogen:"open.v1"` ItemId uint32 `protobuf:"varint,1,opt,name=itemId,proto3" json:"itemId,omitempty"` @@ -558,7 +633,7 @@ type SubscriptionAdded struct { func (x *SubscriptionAdded) Reset() { *x = SubscriptionAdded{} - mi := &file_cart_proto_msgTypes[7] + mi := &file_cart_proto_msgTypes[8] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -570,7 +645,7 @@ func (x *SubscriptionAdded) String() string { func (*SubscriptionAdded) ProtoMessage() {} func (x *SubscriptionAdded) ProtoReflect() protoreflect.Message { - mi := &file_cart_proto_msgTypes[7] + mi := &file_cart_proto_msgTypes[8] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -583,7 +658,7 @@ func (x *SubscriptionAdded) ProtoReflect() protoreflect.Message { // Deprecated: Use SubscriptionAdded.ProtoReflect.Descriptor instead. func (*SubscriptionAdded) Descriptor() ([]byte, []int) { - return file_cart_proto_rawDescGZIP(), []int{7} + return file_cart_proto_rawDescGZIP(), []int{8} } func (x *SubscriptionAdded) GetItemId() uint32 { @@ -619,7 +694,7 @@ type AddVoucher struct { func (x *AddVoucher) Reset() { *x = AddVoucher{} - mi := &file_cart_proto_msgTypes[8] + mi := &file_cart_proto_msgTypes[9] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -631,7 +706,7 @@ func (x *AddVoucher) String() string { func (*AddVoucher) ProtoMessage() {} func (x *AddVoucher) ProtoReflect() protoreflect.Message { - mi := &file_cart_proto_msgTypes[8] + mi := &file_cart_proto_msgTypes[9] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -644,7 +719,7 @@ func (x *AddVoucher) ProtoReflect() protoreflect.Message { // Deprecated: Use AddVoucher.ProtoReflect.Descriptor instead. func (*AddVoucher) Descriptor() ([]byte, []int) { - return file_cart_proto_rawDescGZIP(), []int{8} + return file_cart_proto_rawDescGZIP(), []int{9} } func (x *AddVoucher) GetCode() string { @@ -684,7 +759,7 @@ type RemoveVoucher struct { func (x *RemoveVoucher) Reset() { *x = RemoveVoucher{} - mi := &file_cart_proto_msgTypes[9] + mi := &file_cart_proto_msgTypes[10] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -696,7 +771,7 @@ func (x *RemoveVoucher) String() string { func (*RemoveVoucher) ProtoMessage() {} func (x *RemoveVoucher) ProtoReflect() protoreflect.Message { - mi := &file_cart_proto_msgTypes[9] + mi := &file_cart_proto_msgTypes[10] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -709,7 +784,7 @@ func (x *RemoveVoucher) ProtoReflect() protoreflect.Message { // Deprecated: Use RemoveVoucher.ProtoReflect.Descriptor instead. func (*RemoveVoucher) Descriptor() ([]byte, []int) { - return file_cart_proto_rawDescGZIP(), []int{9} + return file_cart_proto_rawDescGZIP(), []int{10} } func (x *RemoveVoucher) GetId() uint32 { @@ -731,7 +806,7 @@ type UpsertSubscriptionDetails struct { func (x *UpsertSubscriptionDetails) Reset() { *x = UpsertSubscriptionDetails{} - mi := &file_cart_proto_msgTypes[10] + mi := &file_cart_proto_msgTypes[11] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -743,7 +818,7 @@ func (x *UpsertSubscriptionDetails) String() string { func (*UpsertSubscriptionDetails) ProtoMessage() {} func (x *UpsertSubscriptionDetails) ProtoReflect() protoreflect.Message { - mi := &file_cart_proto_msgTypes[10] + mi := &file_cart_proto_msgTypes[11] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -756,7 +831,7 @@ func (x *UpsertSubscriptionDetails) ProtoReflect() protoreflect.Message { // Deprecated: Use UpsertSubscriptionDetails.ProtoReflect.Descriptor instead. func (*UpsertSubscriptionDetails) Descriptor() ([]byte, []int) { - return file_cart_proto_rawDescGZIP(), []int{10} + return file_cart_proto_rawDescGZIP(), []int{11} } func (x *UpsertSubscriptionDetails) GetId() string { @@ -799,6 +874,7 @@ type Mutation struct { // *Mutation_LineItemMarking // *Mutation_RemoveLineItemMarking // *Mutation_SubscriptionAdded + // *Mutation_SetLineItemCustomFields // *Mutation_AddVoucher // *Mutation_RemoveVoucher // *Mutation_UpsertSubscriptionDetails @@ -809,7 +885,7 @@ type Mutation struct { func (x *Mutation) Reset() { *x = Mutation{} - mi := &file_cart_proto_msgTypes[11] + mi := &file_cart_proto_msgTypes[12] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -821,7 +897,7 @@ func (x *Mutation) String() string { func (*Mutation) ProtoMessage() {} func (x *Mutation) ProtoReflect() protoreflect.Message { - mi := &file_cart_proto_msgTypes[11] + mi := &file_cart_proto_msgTypes[12] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -834,7 +910,7 @@ func (x *Mutation) ProtoReflect() protoreflect.Message { // Deprecated: Use Mutation.ProtoReflect.Descriptor instead. func (*Mutation) Descriptor() ([]byte, []int) { - return file_cart_proto_rawDescGZIP(), []int{11} + return file_cart_proto_rawDescGZIP(), []int{12} } func (x *Mutation) GetType() isMutation_Type { @@ -916,6 +992,15 @@ func (x *Mutation) GetSubscriptionAdded() *SubscriptionAdded { return nil } +func (x *Mutation) GetSetLineItemCustomFields() *SetLineItemCustomFields { + if x != nil { + if x, ok := x.Type.(*Mutation_SetLineItemCustomFields); ok { + return x.SetLineItemCustomFields + } + } + return nil +} + func (x *Mutation) GetAddVoucher() *AddVoucher { if x != nil { if x, ok := x.Type.(*Mutation_AddVoucher); ok { @@ -979,6 +1064,10 @@ type Mutation_SubscriptionAdded struct { SubscriptionAdded *SubscriptionAdded `protobuf:"bytes,8,opt,name=subscription_added,json=subscriptionAdded,proto3,oneof"` } +type Mutation_SetLineItemCustomFields struct { + SetLineItemCustomFields *SetLineItemCustomFields `protobuf:"bytes,9,opt,name=set_line_item_custom_fields,json=setLineItemCustomFields,proto3,oneof"` +} + type Mutation_AddVoucher struct { AddVoucher *AddVoucher `protobuf:"bytes,20,opt,name=add_voucher,json=addVoucher,proto3,oneof"` } @@ -1007,6 +1096,8 @@ func (*Mutation_RemoveLineItemMarking) isMutation_Type() {} func (*Mutation_SubscriptionAdded) isMutation_Type() {} +func (*Mutation_SetLineItemCustomFields) isMutation_Type() {} + func (*Mutation_AddVoucher) isMutation_Type() {} func (*Mutation_RemoveVoucher) isMutation_Type() {} @@ -1019,7 +1110,7 @@ const file_cart_proto_rawDesc = "" + "\n" + "\n" + "cart.proto\x12\rcart_messages\x1a\x19google/protobuf/any.proto\x1a\x1fgoogle/protobuf/timestamp.proto\"\x12\n" + - "\x10ClearCartRequest\"\xb1\x06\n" + + "\x10ClearCartRequest\"\xe0\a\n" + "\aAddItem\x12\x17\n" + "\aitem_id\x18\x01 \x01(\rR\x06itemId\x12\x1a\n" + "\bquantity\x18\x02 \x01(\x05R\bquantity\x12\x14\n" + @@ -1053,7 +1144,13 @@ const file_cart_proto_rawDesc = "" + "\astoreId\x18\x16 \x01(\tH\x01R\astoreId\x88\x01\x01\x12\x1f\n" + "\bparentId\x18\x17 \x01(\rH\x02R\bparentId\x88\x01\x01\x12\x10\n" + "\x03cgm\x18\x19 \x01(\tR\x03cgm\x12O\n" + - "\x12reservationEndTime\x18\x1a \x01(\v2\x1a.google.protobuf.TimestampH\x03R\x12reservationEndTime\x88\x01\x01B\t\n" + + "\x12reservationEndTime\x18\x1a \x01(\v2\x1a.google.protobuf.TimestampH\x03R\x12reservationEndTime\x88\x01\x01\x12\x1d\n" + + "\n" + + "extra_json\x18\x1b \x01(\fR\textraJson\x12M\n" + + "\rcustom_fields\x18\x1c \x03(\v2(.cart_messages.AddItem.CustomFieldsEntryR\fcustomFields\x1a?\n" + + "\x11CustomFieldsEntry\x12\x10\n" + + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01B\t\n" + "\a_outletB\n" + "\n" + "\b_storeIdB\v\n" + @@ -1072,7 +1169,13 @@ const file_cart_proto_rawDesc = "" + "\x04type\x18\x02 \x01(\rR\x04type\x12\x18\n" + "\amarking\x18\x03 \x01(\tR\amarking\"'\n" + "\x15RemoveLineItemMarking\x12\x0e\n" + - "\x02id\x18\x01 \x01(\rR\x02id\"q\n" + + "\x02id\x18\x01 \x01(\rR\x02id\"\xc9\x01\n" + + "\x17SetLineItemCustomFields\x12\x0e\n" + + "\x02id\x18\x01 \x01(\rR\x02id\x12]\n" + + "\rcustom_fields\x18\x02 \x03(\v28.cart_messages.SetLineItemCustomFields.CustomFieldsEntryR\fcustomFields\x1a?\n" + + "\x11CustomFieldsEntry\x12\x10\n" + + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"q\n" + "\x11SubscriptionAdded\x12\x16\n" + "\x06itemId\x18\x01 \x01(\rR\x06itemId\x12\x1c\n" + "\tdetailsId\x18\x03 \x01(\tR\tdetailsId\x12&\n" + @@ -1090,7 +1193,7 @@ const file_cart_proto_rawDesc = "" + "\fofferingCode\x18\x02 \x01(\tR\fofferingCode\x12 \n" + "\vsigningType\x18\x03 \x01(\tR\vsigningType\x12(\n" + "\x04data\x18\x04 \x01(\v2\x14.google.protobuf.AnyR\x04dataB\x05\n" + - "\x03_id\"\xc0\x06\n" + + "\x03_id\"\xa8\a\n" + "\bMutation\x12@\n" + "\n" + "clear_cart\x18\x01 \x01(\v2\x1f.cart_messages.ClearCartRequestH\x00R\tclearCart\x123\n" + @@ -1101,7 +1204,8 @@ const file_cart_proto_rawDesc = "" + "\vset_user_id\x18\x05 \x01(\v2\x18.cart_messages.SetUserIdH\x00R\tsetUserId\x12L\n" + "\x11line_item_marking\x18\x06 \x01(\v2\x1e.cart_messages.LineItemMarkingH\x00R\x0flineItemMarking\x12_\n" + "\x18remove_line_item_marking\x18\a \x01(\v2$.cart_messages.RemoveLineItemMarkingH\x00R\x15removeLineItemMarking\x12Q\n" + - "\x12subscription_added\x18\b \x01(\v2 .cart_messages.SubscriptionAddedH\x00R\x11subscriptionAdded\x12<\n" + + "\x12subscription_added\x18\b \x01(\v2 .cart_messages.SubscriptionAddedH\x00R\x11subscriptionAdded\x12f\n" + + "\x1bset_line_item_custom_fields\x18\t \x01(\v2&.cart_messages.SetLineItemCustomFieldsH\x00R\x17setLineItemCustomFields\x12<\n" + "\vadd_voucher\x18\x14 \x01(\v2\x19.cart_messages.AddVoucherH\x00R\n" + "addVoucher\x12E\n" + "\x0eremove_voucher\x18\x15 \x01(\v2\x1c.cart_messages.RemoveVoucherH\x00R\rremoveVoucher\x12j\n" + @@ -1120,7 +1224,7 @@ func file_cart_proto_rawDescGZIP() []byte { return file_cart_proto_rawDescData } -var file_cart_proto_msgTypes = make([]protoimpl.MessageInfo, 12) +var file_cart_proto_msgTypes = make([]protoimpl.MessageInfo, 15) var file_cart_proto_goTypes = []any{ (*ClearCartRequest)(nil), // 0: cart_messages.ClearCartRequest (*AddItem)(nil), // 1: cart_messages.AddItem @@ -1129,33 +1233,39 @@ var file_cart_proto_goTypes = []any{ (*SetUserId)(nil), // 4: cart_messages.SetUserId (*LineItemMarking)(nil), // 5: cart_messages.LineItemMarking (*RemoveLineItemMarking)(nil), // 6: cart_messages.RemoveLineItemMarking - (*SubscriptionAdded)(nil), // 7: cart_messages.SubscriptionAdded - (*AddVoucher)(nil), // 8: cart_messages.AddVoucher - (*RemoveVoucher)(nil), // 9: cart_messages.RemoveVoucher - (*UpsertSubscriptionDetails)(nil), // 10: cart_messages.UpsertSubscriptionDetails - (*Mutation)(nil), // 11: cart_messages.Mutation - (*timestamppb.Timestamp)(nil), // 12: google.protobuf.Timestamp - (*anypb.Any)(nil), // 13: google.protobuf.Any + (*SetLineItemCustomFields)(nil), // 7: cart_messages.SetLineItemCustomFields + (*SubscriptionAdded)(nil), // 8: cart_messages.SubscriptionAdded + (*AddVoucher)(nil), // 9: cart_messages.AddVoucher + (*RemoveVoucher)(nil), // 10: cart_messages.RemoveVoucher + (*UpsertSubscriptionDetails)(nil), // 11: cart_messages.UpsertSubscriptionDetails + (*Mutation)(nil), // 12: cart_messages.Mutation + nil, // 13: cart_messages.AddItem.CustomFieldsEntry + nil, // 14: cart_messages.SetLineItemCustomFields.CustomFieldsEntry + (*timestamppb.Timestamp)(nil), // 15: google.protobuf.Timestamp + (*anypb.Any)(nil), // 16: google.protobuf.Any } var file_cart_proto_depIdxs = []int32{ - 12, // 0: cart_messages.AddItem.reservationEndTime:type_name -> google.protobuf.Timestamp - 13, // 1: cart_messages.UpsertSubscriptionDetails.data:type_name -> google.protobuf.Any - 0, // 2: cart_messages.Mutation.clear_cart:type_name -> cart_messages.ClearCartRequest - 1, // 3: cart_messages.Mutation.add_item:type_name -> cart_messages.AddItem - 2, // 4: cart_messages.Mutation.remove_item:type_name -> cart_messages.RemoveItem - 3, // 5: cart_messages.Mutation.change_quantity:type_name -> cart_messages.ChangeQuantity - 4, // 6: cart_messages.Mutation.set_user_id:type_name -> cart_messages.SetUserId - 5, // 7: cart_messages.Mutation.line_item_marking:type_name -> cart_messages.LineItemMarking - 6, // 8: cart_messages.Mutation.remove_line_item_marking:type_name -> cart_messages.RemoveLineItemMarking - 7, // 9: cart_messages.Mutation.subscription_added:type_name -> cart_messages.SubscriptionAdded - 8, // 10: cart_messages.Mutation.add_voucher:type_name -> cart_messages.AddVoucher - 9, // 11: cart_messages.Mutation.remove_voucher:type_name -> cart_messages.RemoveVoucher - 10, // 12: cart_messages.Mutation.upsert_subscription_details:type_name -> cart_messages.UpsertSubscriptionDetails - 13, // [13:13] is the sub-list for method output_type - 13, // [13:13] is the sub-list for method input_type - 13, // [13:13] is the sub-list for extension type_name - 13, // [13:13] is the sub-list for extension extendee - 0, // [0:13] is the sub-list for field type_name + 15, // 0: cart_messages.AddItem.reservationEndTime:type_name -> google.protobuf.Timestamp + 13, // 1: cart_messages.AddItem.custom_fields:type_name -> cart_messages.AddItem.CustomFieldsEntry + 14, // 2: cart_messages.SetLineItemCustomFields.custom_fields:type_name -> cart_messages.SetLineItemCustomFields.CustomFieldsEntry + 16, // 3: cart_messages.UpsertSubscriptionDetails.data:type_name -> google.protobuf.Any + 0, // 4: cart_messages.Mutation.clear_cart:type_name -> cart_messages.ClearCartRequest + 1, // 5: cart_messages.Mutation.add_item:type_name -> cart_messages.AddItem + 2, // 6: cart_messages.Mutation.remove_item:type_name -> cart_messages.RemoveItem + 3, // 7: cart_messages.Mutation.change_quantity:type_name -> cart_messages.ChangeQuantity + 4, // 8: cart_messages.Mutation.set_user_id:type_name -> cart_messages.SetUserId + 5, // 9: cart_messages.Mutation.line_item_marking:type_name -> cart_messages.LineItemMarking + 6, // 10: cart_messages.Mutation.remove_line_item_marking:type_name -> cart_messages.RemoveLineItemMarking + 8, // 11: cart_messages.Mutation.subscription_added:type_name -> cart_messages.SubscriptionAdded + 7, // 12: cart_messages.Mutation.set_line_item_custom_fields:type_name -> cart_messages.SetLineItemCustomFields + 9, // 13: cart_messages.Mutation.add_voucher:type_name -> cart_messages.AddVoucher + 10, // 14: cart_messages.Mutation.remove_voucher:type_name -> cart_messages.RemoveVoucher + 11, // 15: cart_messages.Mutation.upsert_subscription_details:type_name -> cart_messages.UpsertSubscriptionDetails + 16, // [16:16] is the sub-list for method output_type + 16, // [16:16] is the sub-list for method input_type + 16, // [16:16] is the sub-list for extension type_name + 16, // [16:16] is the sub-list for extension extendee + 0, // [0:16] is the sub-list for field type_name } func init() { file_cart_proto_init() } @@ -1164,8 +1274,8 @@ func file_cart_proto_init() { return } file_cart_proto_msgTypes[1].OneofWrappers = []any{} - file_cart_proto_msgTypes[10].OneofWrappers = []any{} - file_cart_proto_msgTypes[11].OneofWrappers = []any{ + file_cart_proto_msgTypes[11].OneofWrappers = []any{} + file_cart_proto_msgTypes[12].OneofWrappers = []any{ (*Mutation_ClearCart)(nil), (*Mutation_AddItem)(nil), (*Mutation_RemoveItem)(nil), @@ -1174,6 +1284,7 @@ func file_cart_proto_init() { (*Mutation_LineItemMarking)(nil), (*Mutation_RemoveLineItemMarking)(nil), (*Mutation_SubscriptionAdded)(nil), + (*Mutation_SetLineItemCustomFields)(nil), (*Mutation_AddVoucher)(nil), (*Mutation_RemoveVoucher)(nil), (*Mutation_UpsertSubscriptionDetails)(nil), @@ -1184,7 +1295,7 @@ func file_cart_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_cart_proto_rawDesc), len(file_cart_proto_rawDesc)), NumEnums: 0, - NumMessages: 12, + NumMessages: 15, NumExtensions: 0, NumServices: 0, }, diff --git a/proto/checkout/checkout.pb.go b/proto/checkout/checkout.pb.go index c5dfd58..0d94592 100644 --- a/proto/checkout/checkout.pb.go +++ b/proto/checkout/checkout.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: -// protoc-gen-go v1.36.10 -// protoc v6.33.1 +// protoc-gen-go v1.36.11 +// protoc v7.35.0 // source: checkout.proto package checkout_messages diff --git a/proto/control/control_plane.pb.go b/proto/control/control_plane.pb.go index 8239ca7..06ceb0c 100644 --- a/proto/control/control_plane.pb.go +++ b/proto/control/control_plane.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: -// protoc-gen-go v1.36.10 -// protoc v6.33.1 +// protoc-gen-go v1.36.11 +// protoc v7.35.0 // source: control_plane.proto package control_plane_messages diff --git a/proto/control/control_plane_grpc.pb.go b/proto/control/control_plane_grpc.pb.go index 912ed94..d961876 100644 --- a/proto/control/control_plane_grpc.pb.go +++ b/proto/control/control_plane_grpc.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go-grpc. DO NOT EDIT. // versions: -// - protoc-gen-go-grpc v1.5.1 -// - protoc v6.33.1 +// - protoc-gen-go-grpc v1.6.2 +// - protoc v7.35.0 // source: control_plane.proto package control_plane_messages @@ -170,28 +170,28 @@ type ControlPlaneServer interface { type UnimplementedControlPlaneServer struct{} func (UnimplementedControlPlaneServer) Ping(context.Context, *Empty) (*PingReply, error) { - return nil, status.Errorf(codes.Unimplemented, "method Ping not implemented") + return nil, status.Error(codes.Unimplemented, "method Ping not implemented") } func (UnimplementedControlPlaneServer) Negotiate(context.Context, *NegotiateRequest) (*NegotiateReply, error) { - return nil, status.Errorf(codes.Unimplemented, "method Negotiate not implemented") + return nil, status.Error(codes.Unimplemented, "method Negotiate not implemented") } func (UnimplementedControlPlaneServer) GetLocalActorIds(context.Context, *Empty) (*ActorIdsReply, error) { - return nil, status.Errorf(codes.Unimplemented, "method GetLocalActorIds not implemented") + return nil, status.Error(codes.Unimplemented, "method GetLocalActorIds not implemented") } func (UnimplementedControlPlaneServer) AnnounceOwnership(context.Context, *OwnershipAnnounce) (*OwnerChangeAck, error) { - return nil, status.Errorf(codes.Unimplemented, "method AnnounceOwnership not implemented") + return nil, status.Error(codes.Unimplemented, "method AnnounceOwnership not implemented") } func (UnimplementedControlPlaneServer) Apply(context.Context, *ApplyRequest) (*ApplyResult, error) { - return nil, status.Errorf(codes.Unimplemented, "method Apply not implemented") + return nil, status.Error(codes.Unimplemented, "method Apply not implemented") } func (UnimplementedControlPlaneServer) AnnounceExpiry(context.Context, *ExpiryAnnounce) (*OwnerChangeAck, error) { - return nil, status.Errorf(codes.Unimplemented, "method AnnounceExpiry not implemented") + return nil, status.Error(codes.Unimplemented, "method AnnounceExpiry not implemented") } func (UnimplementedControlPlaneServer) Closing(context.Context, *ClosingNotice) (*OwnerChangeAck, error) { - return nil, status.Errorf(codes.Unimplemented, "method Closing not implemented") + return nil, status.Error(codes.Unimplemented, "method Closing not implemented") } func (UnimplementedControlPlaneServer) Get(context.Context, *GetRequest) (*GetReply, error) { - return nil, status.Errorf(codes.Unimplemented, "method Get not implemented") + return nil, status.Error(codes.Unimplemented, "method Get not implemented") } func (UnimplementedControlPlaneServer) mustEmbedUnimplementedControlPlaneServer() {} func (UnimplementedControlPlaneServer) testEmbeddedByValue() {} @@ -204,7 +204,7 @@ type UnsafeControlPlaneServer interface { } func RegisterControlPlaneServer(s grpc.ServiceRegistrar, srv ControlPlaneServer) { - // If the following call pancis, it indicates UnimplementedControlPlaneServer was + // If the following call panics, it indicates UnimplementedControlPlaneServer was // embedded by pointer and is nil. This will cause panics if an // unimplemented method is ever invoked, so we test this at initialization // time to prevent it from happening at runtime later due to I/O.