Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 9 additions & 5 deletions .github/workflows/live-smoke.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,13 @@ name: Live smoke (daily)

# Daily smoke against real Wolt consumer-api endpoints. Detects
# upstream contract drift before users do. The read-only surface
# (status/account/feed/venue/cart-show) always runs; a cart round-trip
# (add a live orderable item → CLI checkout preview → MCP checkout preview
# → remove) runs because WOLT_SMOKE_CART=1 is set below. A sell-by-weight leg
# (status/account/feed/venue/cart-show) always runs; a cart round-trip runs
# because WOLT_SMOKE_CART=1 is set below. It adds two units of a live
# orderable item and checks that the line total is count x unit price and
# that Wolt's own basket total equals the CLI subtotal. It previews checkout
# through the CLI, where Wolt's item subtotal must match that total, and
# through MCP. Then it removes one unit, checks the line again and removes
# the rest. A sell-by-weight leg
# follows it: weighted items are priced by the selected weight rather than per
# unit, so it asserts the basket line price, previews checkout, and confirms
# the line refuses partial removal. An option-groups leg adds a restaurant
Expand All @@ -13,8 +17,8 @@ name: Live smoke (daily)
# checkout, and removes one of two units, which rebuilds the remaining line.
# The MCP steps spawn wolt-mcp over stdio for read-only venue detail/hours and
# checkout preview, so the daily run guards the MCP surface as well as the CLI.
# Checkout is preview-only — no real order is ever placed, and only the unit
# added by this run is removed. No login or sync.
# Checkout is preview-only — no real order is ever placed, and only the units
# added by this run are removed. No login or sync.
#
# Required repository secret:
# WOLT_SMOKE_CONFIG_JSON contents of ~/.wolt/.wolt-config.json
Expand Down
6 changes: 4 additions & 2 deletions docs/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -372,8 +372,10 @@ the library ID, `venue item` the item-level ID. `--option` accepts either, and
the basket always stores the item-level ID, the only one the Wolt app shows as
selected. When a dish uses one library group more than once (pick one, two or
three toppings), the library ID is ambiguous and the error lists the
item-level IDs to choose from. The saved line price is the dish price plus the
selected paid option values, so `wolt cart` and `wolt checkout` agree.
item-level IDs to choose from. The saved line price is the whole-line total,
count x (dish price plus the selected paid option values), as the Wolt app
stores it, so `wolt cart`, `wolt checkout` and the Wolt app show the same
total.

Wolt's basket responses list selected option values without their prices.
`cart add`, `cart remove` and `checkout` send every line back, so they take the
Expand Down
7 changes: 7 additions & 0 deletions docs/output-contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -475,6 +475,13 @@ lines[]: {
subtotal, fees, total
```

`line_total` is the price Wolt stores for the whole line: count x (unit price
plus paid option values). `price` is one unit of it. For a weighted line both
are the price of the weighed amount. `subtotal` is the sum of the line totals,
the same item total the Wolt app shows. A line whose stored price does not
divide evenly by its count gets a `null` unit price and a warning; remove the
item and add it again.

### `wolt cart add|remove|clear` — CartMutationResult

```
Expand Down
71 changes: 53 additions & 18 deletions internal/cli/cart_checkout_contract_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,8 @@ func syntheticVenuePayload() map[string]any {
}
}

// configuredRegressionLine builds a basket line whose price is the whole-line
// total, as Wolt stores it.
func configuredRegressionLine(itemID, name string, count, price int) map[string]any {
return map[string]any{
"id": itemID,
Expand Down Expand Up @@ -276,18 +278,47 @@ func TestCartAddSendsWeightedBasketPayload(t *testing.T) {
}
}

func TestBasketLineConfiguredTotalUsesWeightedPriceAsLineTotal(t *testing.T) {
total, err := basketLineConfiguredTotal(map[string]any{
"count": 2,
"price": 1600,
"weighted_item_info": map[string]any{
"count": 2,
"purchased_weight_in_grams": 400,
"weighted_item_input_type": "number_of_items",
func TestBasketItemsSubtotalSumsWholeLinePrices(t *testing.T) {
items := []any{
map[string]any{"count": 2, "price": 700},
map[string]any{
"count": 2,
"price": 3080,
"options": []any{map[string]any{
"id": "refills",
"values": []any{map[string]any{"id": "yogurt", "count": 1, "price": 100}},
}},
},
map[string]any{
"count": 2,
"price": 1600,
"weighted_item_info": map[string]any{
"count": 2,
"purchased_weight_in_grams": 400,
},
},
}
total, err := basketItemsSubtotal(items)
if err != nil || total != 5380 {
t.Fatalf("basket subtotal = %d, %v; want 5380", total, err)
}
}

func TestBasketLineUnitPriceSplitsCountBasedLinesOnly(t *testing.T) {
unit, err := basketLineUnitPrice(map[string]any{"count": 2, "price": 3080})
if err != nil || unit != 1540 {
t.Fatalf("count-based unit price = %d, %v; want 1540", unit, err)
}
unit, err = basketLineUnitPrice(map[string]any{
"count": 2,
"price": 1601,
"weighted_item_info": map[string]any{"count": 2, "purchased_weight_in_grams": 400},
})
if err != nil || total != 1600 {
t.Fatalf("weighted configured total = %d, %v; want 1600", total, err)
if err != nil || unit != 1601 {
t.Fatalf("weighted unit price = %d, %v; want the line price 1601", unit, err)
}
if _, err := basketLineUnitPrice(map[string]any{"count": 2, "price": 625}); err == nil {
t.Fatal("expected an error for a line price that is not a multiple of its count")
}
}

Expand Down Expand Up @@ -396,7 +427,7 @@ func TestCartAddKeepsFallbackCountsConfiguredTotalAndNestedPrices(t *testing.T)
"venue_slug": regressionVenueSlug,
"currency": "EUR",
"items": []any{
configuredRegressionLine(regressionOtherID, "Existing configured item", 2, 300),
configuredRegressionLine(regressionOtherID, "Existing configured item", 2, 800),
},
},
unrelatedRegressionBasket("000000000000000000000299"),
Expand Down Expand Up @@ -502,8 +533,8 @@ func TestCartAddKeepsFallbackCountsConfiguredTotalAndNestedPrices(t *testing.T)
asInt(added["price"]) != 700 {
t.Fatalf("fresh line metadata = %#v", added)
}
// The saved line price is the base price (500) plus the selected paid option
// values (2 x 100), so the cart and checkout preview agree.
// The saved line price is the whole-line total: one unit of the base price
// (500) plus the selected paid option values (2 x 100).
option := asMap(asSlice(added["options"])[0])
value := asMap(asSlice(option["values"])[0])
if asInt(value["price"]) != 100 || asInt(value["count"]) != 2 {
Expand All @@ -517,8 +548,8 @@ func TestCartAddKeepsFallbackCountsConfiguredTotalAndNestedPrices(t *testing.T)
if asInt(data["total_items"]) != 6 {
t.Fatalf("fallback total_items = %v, want 6", data["total_items"])
}
if got := asInt(asMap(data["total"])["amount"]); got != 1700 {
t.Fatalf("fallback total = %d, want configured target-basket total 1700 (fresh line 700 incl. options)", got)
if got := asInt(asMap(data["total"])["amount"]); got != 1500 {
t.Fatalf("fallback total = %d, want target-basket total 1500 (existing line 800 + fresh line 700 incl. options)", got)
}
}

Expand Down Expand Up @@ -627,10 +658,10 @@ func TestCartRemoveKeepsFallbackCountsAndConfiguredTotalOnEmptyRefresh(t *testin
"id": regressionItemID,
"name": "Removed item",
"count": 2,
"price": map[string]any{"amount": 500},
"price": map[string]any{"amount": 1000},
"options": []any{},
},
configuredRegressionLine(regressionOtherID, "Configured remainder", 2, 300),
configuredRegressionLine(regressionOtherID, "Configured remainder", 2, 800),
},
},
unrelatedRegressionBasket("000000000000000000000299"),
Expand Down Expand Up @@ -682,8 +713,12 @@ func TestCartRemoveKeepsFallbackCountsAndConfiguredTotalOnEmptyRefresh(t *testin
if asInt(data["total_items"]) != 6 {
t.Fatalf("fallback total_items = %v, want 6", data["total_items"])
}
remaining := asMap(asSlice(mutation["items"])[0])
if asInt(remaining["count"]) != 1 || asInt(remaining["price"]) != 500 {
t.Fatalf("partially removed line = %#v, want count 1 at line price 500", remaining)
}
if got := asInt(asMap(data["total"])["amount"]); got != 1300 {
t.Fatalf("fallback total = %d, want 1300", got)
t.Fatalf("fallback total = %d, want 1300 (500 + 800)", got)
}
}

Expand Down
4 changes: 2 additions & 2 deletions internal/cli/cart_state_contract_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ func TestBuildCartStateNormalizesMissingCountAndHonorsExplicitZeroTotal(t *testi
map[string]any{
"id": "item-1",
"name": "Example item",
"price": 500,
"price": 600,
"options": []any{
map[string]any{
"id": "extra",
Expand All @@ -64,7 +64,7 @@ func TestBuildCartStateNormalizesMissingCountAndHonorsExplicitZeroTotal(t *testi
t.Fatalf("missing basket count was not normalized: %#v", state)
}
if got := asInt(asMap(state["subtotal"])["amount"]); got != 600 {
t.Fatalf("configured subtotal = %d, want 600", got)
t.Fatalf("subtotal = %d, want the line price 600 (paid options included)", got)
}
total := asMap(state["total"])
if asInt(total["amount"]) != 0 || asString(total["formatted_amount"]) != "€0.00" {
Expand Down
81 changes: 36 additions & 45 deletions internal/cli/commands_cart.go
Original file line number Diff line number Diff line change
Expand Up @@ -563,11 +563,24 @@ func newCartAddCommand(deps Dependencies) *cobra.Command {
warnings,
)
}
// Wolt stores the unit price of a basket line including its paid option
// values (the app does the same). Weighted lines are priced by weight.
// A basket line's price is the whole-line total: count x (unit price
// plus paid option values), as the Wolt app stores it. Weighted lines
// are priced by weight.
linePrice := price
if _, weighted := payloadutil.WeightConfigFromItem(itemPayload); !weighted {
linePrice += basketOptionsTotal(options)
var ok bool
linePrice, ok = payloadutil.CheckedMultiplyInt(price+basketOptionsTotal(options), count)
if !ok {
return emitError(
cmd,
format,
profile,
flags.Locale,
flags.Output,
"WOLT_INVALID_ARGUMENT",
"Item total exceeds the supported integer range; the item was not added.",
)
}
}
newLineItem := map[string]any{
"id": itemID,
Expand Down Expand Up @@ -1417,12 +1430,8 @@ func buildCartState(page map[string]any, venueID string) (map[string]any, []stri
continue
}
count := basketLineCount(item)
price := payloadutil.MinorAmount(item["price"])
lineAmount, totalErr := basketLineConfiguredTotal(item)
if totalErr != nil {
totalsValid = false
lineAmount = 0
} else if nextSubtotal, ok := payloadutil.CheckedAddInt(subtotalAmount, lineAmount); ok {
lineAmount := payloadutil.MinorAmount(item["price"])
if nextSubtotal, ok := payloadutil.CheckedAddInt(subtotalAmount, lineAmount); ok {
subtotalAmount = nextSubtotal
} else {
totalsValid = false
Expand All @@ -1433,6 +1442,13 @@ func buildCartState(page map[string]any, venueID string) (map[string]any, []stri
if !totalsValid {
lineTotalDisplay = nil
}
price, unitErr := basketLineUnitPrice(item)
priceDisplay := any(formatMinorAmount(price, currency))
if unitErr != nil {
price = 0
priceDisplay = nil
warnings = append(warnings, fmt.Sprintf("basket item %q: %v", asString(item["id"]), unitErr))
}
lines = append(lines, map[string]any{
"line_id": asString(item["id"]),
"item_id": asString(item["id"]),
Expand All @@ -1441,7 +1457,7 @@ func buildCartState(page map[string]any, venueID string) (map[string]any, []stri
"options": coalesceAny(item["options"], []any{}),
"price": map[string]any{
"amount": price,
"formatted_amount": formatMinorAmount(price, currency),
"formatted_amount": priceDisplay,
},
"line_total": map[string]any{
"amount": lineAmount,
Expand Down Expand Up @@ -1536,54 +1552,29 @@ func basketLineCount(item map[string]any) int {
return count
}

// basketItemsSubtotal sums the line prices. Each line price is already the
// whole-line total with paid option values included.
func basketItemsSubtotal(items []any) (int, error) {
total := 0
for _, rawItem := range items {
lineTotal, err := basketLineConfiguredTotal(asMap(rawItem))
if err != nil {
return 0, err
}
var ok bool
total, ok = payloadutil.CheckedAddInt(total, lineTotal)
total, ok = payloadutil.CheckedAddInt(total, payloadutil.MinorAmount(asMap(rawItem)["price"]))
if !ok {
return 0, fmt.Errorf("basket subtotal exceeds the supported integer range")
}
}
return total, nil
}

func basketLineConfiguredTotal(item map[string]any) (int, error) {
count := basketLineCount(item)
// basketLineUnitPrice is the price of one unit of a count-based line. A
// weighted line has no per-unit price in the basket, so its line price is
// returned as is.
func basketLineUnitPrice(item map[string]any) (int, error) {
price := payloadutil.MinorAmount(item["price"])
if asMap(item["weighted_item_info"]) != nil {
count = 1
}
unitAmount := payloadutil.MinorAmount(item["price"])
for _, rawOption := range asSlice(item["options"]) {
option := asMap(rawOption)
for _, rawValue := range asSlice(option["values"]) {
value := asMap(rawValue)
valueCount := asInt(value["count"])
if valueCount <= 0 {
valueCount = 1
}
optionAmount, ok := payloadutil.CheckedMultiplyInt(
payloadutil.MinorAmount(value["price"]),
valueCount,
)
if !ok {
return 0, fmt.Errorf("basket option total exceeds the supported integer range")
}
unitAmount, ok = payloadutil.CheckedAddInt(unitAmount, optionAmount)
if !ok {
return 0, fmt.Errorf("basket item unit total exceeds the supported integer range")
}
}
}
lineTotal, ok := payloadutil.CheckedMultiplyInt(unitAmount, count)
if !ok {
return 0, fmt.Errorf("basket item total exceeds the supported integer range")
return price, nil
}
return lineTotal, nil
return payloadutil.LineUnitPrice(price, basketLineCount(item))
}

func buildCartTable(data map[string]any, includeDetails bool) string {
Expand Down
6 changes: 4 additions & 2 deletions internal/mcpserver/cart_checkout_contract_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -602,8 +602,10 @@ func TestCheckoutPayloadPreservesNestedBasketPricesThroughHandler(t *testing.T)
}
plan := asMap(captured["purchase_plan"])
item := asMap(asSlice(plan["menu_items"])[0])
if asInt(item["base_price"]) != 650 {
t.Fatalf("base_price = %v, want 650", item["base_price"])
// base_price is one unit without the paid option value; end_amount is the
// whole-line price with it.
if asInt(item["base_price"]) != 550 || asInt(item["end_amount"]) != 650 {
t.Fatalf("base_price, end_amount = %v, %v; want 550, 650", item["base_price"], item["end_amount"])
}
option := asMap(asSlice(item["options"])[0])
value := asMap(asSlice(option["values"])[0])
Expand Down
8 changes: 7 additions & 1 deletion internal/mcpserver/tools_cart.go
Original file line number Diff line number Diff line change
Expand Up @@ -280,11 +280,17 @@ func (tc *ToolCtx) handleCartAdd(ctx context.Context, _ *mcp.CallToolRequest, in
}
orderAvailability, availabilityWarnings := tc.cartVenueAvailability(ctx, ref, loc)

// A basket line's price is the whole-line total, as the Wolt app stores
// it. Weighted lines are repriced by weight when they are built.
linePrice, ok := payloadutil.CheckedMultiplyInt(price, count)
if !ok {
return nil, CartAddOutput{}, toolErrf("item was NOT added because its total exceeds the supported integer range")
}
newLine := map[string]any{
"id": in.ItemID,
"count": count,
"name": name,
"price": price,
"price": linePrice,
"options": []any{},
"substitution_settings": map[string]any{
"is_allowed": in.AllowSubstitutions,
Expand Down
Loading
Loading