From 8615c44de8d02c3ed2b270c13bee2aa67e7a08fb Mon Sep 17 00:00:00 2001 From: Oleg XXL Date: Mon, 12 Oct 2026 16:02:19 +0300 Subject: [PATCH] fix(cart): send item-level option group ids in basket options Wolt keys a dish's options by an item-level "id" and references the shared option-library group through "option_id". buildItemPayloadFromAssortment replaced the item-level group with the library group, so the basket was written with library ids. The basket API accepts that, but the Wolt app only recognises item-level ids and silently shows the default choices instead. Rewrite the resolved group to carry the item-level id (keeping the library id as option_id) and let --option accept either id. --- internal/cli/cart_helpers.go | 34 ++++++++++++- internal/cli/cart_helpers_test.go | 54 +++++++++++++++++++++ internal/service/payloadutil/payloadutil.go | 4 ++ 3 files changed, 90 insertions(+), 2 deletions(-) diff --git a/internal/cli/cart_helpers.go b/internal/cli/cart_helpers.go index 8c9c037..28627bb 100644 --- a/internal/cli/cart_helpers.go +++ b/internal/cli/cart_helpers.go @@ -231,11 +231,36 @@ func buildItemPayloadFromAssortment(assortment map[string]any, itemID string) ma optionGroupIndex[strings.ToLower(groupID)] = group } } + // The basket API (and the Wolt app) key options by the item-level option id + // ("id"), while the shared option library is keyed by "option_id". Map one to the other. + itemOptionIDs := map[string]string{} + for _, rawOption := range asSlice(item["options"]) { + option := asMap(rawOption) + if option == nil { + continue + } + libraryID := strings.TrimSpace(asString(option["option_id"])) + itemLevelID := strings.TrimSpace(asString(option["id"])) + if libraryID != "" && itemLevelID != "" { + itemOptionIDs[strings.ToLower(libraryID)] = itemLevelID + } + } optionGroups := make([]any, 0, len(optionGroupIDs)) for _, groupID := range optionGroupIDs { - if group, ok := optionGroupIndex[strings.ToLower(groupID)]; ok { - optionGroups = append(optionGroups, group) + group, ok := optionGroupIndex[strings.ToLower(groupID)] + if !ok { + continue } + if itemLevelID, mapped := itemOptionIDs[strings.ToLower(groupID)]; mapped && !strings.EqualFold(itemLevelID, groupID) { + rewritten := make(map[string]any, len(group)+2) + for key, value := range group { + rewritten[key] = value + } + rewritten["id"] = itemLevelID + rewritten["option_id"] = groupID + group = rewritten + } + optionGroups = append(optionGroups, group) } if len(optionGroups) == 0 { optionGroups = asSlice(coalesceAny(item["option_groups"], item["options"])) @@ -328,6 +353,11 @@ func resolveOptionGroupToken( if _, ok := specs[token]; ok { return token, nil } + for groupID, spec := range specs { + if spec.OptionID != "" && strings.EqualFold(spec.OptionID, token) { + return groupID, nil + } + } groupIDs := make([]string, 0, len(specs)) for groupID := range specs { groupIDs = append(groupIDs, groupID) diff --git a/internal/cli/cart_helpers_test.go b/internal/cli/cart_helpers_test.go index 128ca9d..ae06227 100644 --- a/internal/cli/cart_helpers_test.go +++ b/internal/cli/cart_helpers_test.go @@ -332,6 +332,60 @@ func TestBuildItemPayloadFromAssortment(t *testing.T) { } } +// Wolt keys a dish's options by an item-level "id" and points at the shared +// library group through "option_id". The basket API (and the Wolt app) only +// recognise the item-level id, so the group sent in the basket must carry it. +func TestBuildBasketOptionsUsesItemLevelOptionGroupID(t *testing.T) { + assortment := map[string]any{ + "items": []any{ + map[string]any{ + "id": "item-1", + "name": "Make Your Own Pasta", + "price": 1200, + "options": []any{ + map[string]any{"id": "item-grp-pasta", "option_id": "lib-grp-pasta", "name": "Choose your pasta"}, + }, + }, + }, + "options": []any{ + map[string]any{ + "id": "lib-grp-pasta", + "name": "Choose your pasta", + "values": []any{ + map[string]any{"id": "val-penne", "name": "penne", "price": 0}, + map[string]any{"id": "val-linguine", "name": "linguine", "price": 0}, + }, + }, + }, + } + itemPayload := buildItemPayloadFromAssortment(assortment, "item-1") + if itemPayload == nil { + t.Fatalf("expected assortment payload for item") + } + + for _, group := range []string{"item-grp-pasta", "lib-grp-pasta", "Choose your pasta"} { + t.Run(group, func(t *testing.T) { + options, err := buildBasketOptions(itemPayload, map[string][]optionSelection{ + group: {{ValueID: "val-linguine", Count: 1}}, + }) + if err != nil { + t.Fatalf("buildBasketOptions() error = %v", err) + } + if len(options) != 1 { + t.Fatalf("expected one option group, got %#v", options) + } + got := asMap(options[0]) + if asString(got["id"]) != "item-grp-pasta" { + t.Fatalf("basket option group id = %v, want item-level id item-grp-pasta", got["id"]) + } + values := asSlice(got["values"]) + if len(values) != 1 || asString(asMap(values[0])["id"]) != "val-linguine" { + t.Fatalf("unexpected values: %#v", values) + } + }) + } +} + func TestBuildItemPayloadFromAssortmentMatchesUppercaseID(t *testing.T) { const itemID = "abcdefabcdefabcdefabcdef" payload := buildItemPayloadFromAssortment( diff --git a/internal/service/payloadutil/payloadutil.go b/internal/service/payloadutil/payloadutil.go index e8fedab..9421a2c 100644 --- a/internal/service/payloadutil/payloadutil.go +++ b/internal/service/payloadutil/payloadutil.go @@ -32,6 +32,7 @@ type OptionValueSpec struct { type OptionGroupSpec struct { ID string + OptionID string // shared option-library id, when it differs from ID Name string Required bool MinSelect int @@ -595,6 +596,9 @@ func ExtractOptionSpecs(payload map[string]any) map[string]OptionGroupSpec { spec.ID = canonicalGroupID spec.Values = map[string]OptionValueSpec{} } + if libraryID := strings.TrimSpace(String(group["option_id"])); spec.OptionID == "" && libraryID != "" && !strings.EqualFold(libraryID, canonicalGroupID) { + spec.OptionID = libraryID + } if spec.Name == "" { spec.Name = String(CoalesceAny(group["name"], group["title"])) }