From de6b797d81241b45612f9ca417dce968aa080b79 Mon Sep 17 00:00:00 2001 From: Nikita R Date: Thu, 22 Oct 2026 16:05:11 +0300 Subject: [PATCH 1/2] fix(menu): treat items outside their sale hours as unavailable Wolt assortment items carry available_times: weekly sale windows in the venue's local time (ISO weekdays, milliseconds since local midnight). A lunch dish sold only on Mondays 10:30-15:00 keeps disabled_info = null all week, so the CLI listed it as available, cart add accepted it and checkout preview priced it. Wolt's basket marked the line is_available=false and left it out of the basket total, which failed the daily live smoke with "Wolt basket total 0 != CLI subtotal 2900" (issue #62). The gateway client now checks available_times on every assortment response (full menu, category, items, search). For items outside their window it writes a disabled_info with a reason such as "not sold now; available Mon 10:30-15:00 Europe/Helsinki", which the menu, item, cart add and checkout checks in the CLI and MCP already honour. The venue timezone comes from the static venue page, is fetched only when an item is restricted at all, and is cached per venue. When it cannot be read, items stay as Wolt returned them. time/tzdata is embedded so Windows builds resolve the zone too. `wolt cart` now passes Wolt's per-line is_available through and warns for each unavailable line, since Wolt's total then differs from the subtotal. --- docs/commands.md | 5 +- docs/output-contract.md | 14 +- internal/cli/cart_state_contract_test.go | 39 ++++ internal/cli/commands_cart.go | 17 +- internal/gateway/wolt/client.go | 22 +- internal/gateway/wolt/item_schedule.go | 224 ++++++++++++++++++++ internal/gateway/wolt/item_schedule_test.go | 199 +++++++++++++++++ 7 files changed, 511 insertions(+), 9 deletions(-) create mode 100644 internal/gateway/wolt/item_schedule.go create mode 100644 internal/gateway/wolt/item_schedule_test.go diff --git a/docs/commands.md b/docs/commands.md index 47b80e6..6b74641 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -345,8 +345,9 @@ fails with `WOLT_VENUE_UNRESOLVED` instead of reporting a success that never reaches your cart. It then refreshes the exact current item. A non-null `disabled_info`, -non-positive numeric `purchasable_balance`, or a missing current item blocks -the mutation even when `--price`, `--currency`, and `--name` are supplied. +non-positive numeric `purchasable_balance`, an item outside its weekly sale +hours (`available_times`), or a missing current item blocks the mutation even +when `--price`, `--currency`, and `--name` are supplied. A null balance means Wolt supplied no numeric balance constraint, not unlimited stock. Currency is resolved from current item, basket, or venue metadata; the command fails instead of silently defaulting to EUR. See diff --git a/docs/output-contract.md b/docs/output-contract.md index 8a44a3d..05aa87e 100644 --- a/docs/output-contract.md +++ b/docs/output-contract.md @@ -400,6 +400,13 @@ Availability is derived from Wolt's current `disabled_info` and - `null` means Wolt supplied no numeric balance constraint. It does **not** mean unlimited stock; availability still depends on `disabled_info` and the item being present in the current assortment. +- `available_times` lists the weekly hours an item is sold, in the venue's + local time (for example a lunch dish sold only on Mondays 10:30-15:00). + Wolt keeps `disabled_info` null outside those hours, but its basket marks + such a line unavailable. The CLI and MCP therefore treat an item outside its + hours as disabled, with a reason such as + `not sold now; available Mon 10:30-15:00 Europe/Helsinki`. When the venue + timezone cannot be read, the hours are not checked. `is_sold_out` remains as a deprecated, derived compatibility alias (`!is_available`); raw `is_sold_out` and `sold_out` fields are not expected @@ -470,7 +477,7 @@ basket_id, venue_id, venue_name, venue_slug selection, currency, total_items lines[]: { line_id, item_id, name, count, - options[], price, line_total + options[], price, line_total, is_available? } subtotal, fees, total ``` @@ -482,6 +489,11 @@ 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. +`is_available` is Wolt's own flag for the line and is present when Wolt sends +it. Wolt leaves an unavailable line (sold out, or outside the item's sale +hours) out of `total`, so `total` can be lower than `subtotal`; each such line +also adds a warning. + ### `wolt cart add|remove|clear` — CartMutationResult ``` diff --git a/internal/cli/cart_state_contract_test.go b/internal/cli/cart_state_contract_test.go index 7f3a728..61b20f0 100644 --- a/internal/cli/cart_state_contract_test.go +++ b/internal/cli/cart_state_contract_test.go @@ -72,6 +72,45 @@ func TestBuildCartStateNormalizesMissingCountAndHonorsExplicitZeroTotal(t *testi } } +func TestBuildCartStateFlagsLinesWoltMarksUnavailable(t *testing.T) { + page := map[string]any{ + "baskets": []any{ + map[string]any{ + "id": "basket-1", + "total": "€0.00", + "venue": map[string]any{"id": "venue-1"}, + "items": []any{ + map[string]any{"id": "lunch", "name": "LAMB METHI", "count": 2, "price": 2900, "is_available": false}, + map[string]any{"id": "soup", "name": "Soup", "count": 1, "price": 900, "is_available": true}, + }, + "telemetry": map[string]any{"basket_total": 900}, + }, + }, + } + + state, warnings := buildCartState(page, "venue-1") + lines := asSlice(state["lines"]) + if got := asMap(lines[0])["is_available"]; got != false { + t.Fatalf("lunch is_available = %#v, want false", got) + } + if got := asMap(lines[1])["is_available"]; got != true { + t.Fatalf("soup is_available = %#v, want true", got) + } + want := `basket item "LAMB METHI" is unavailable now; Wolt leaves it out of the basket total` + found := false + for _, warning := range warnings { + if warning == want { + found = true + } + } + if !found { + t.Fatalf("warnings = %#v, want %q", warnings, want) + } + if asInt(asMap(state["subtotal"])["amount"]) != 3800 || asInt(asMap(state["total"])["amount"]) != 900 { + t.Fatalf("subtotal = %#v, total = %#v", state["subtotal"], state["total"]) + } +} + func TestFindBasketLineByIDAcceptsObjectIDCase(t *testing.T) { basket := map[string]any{ "items": []any{ diff --git a/internal/cli/commands_cart.go b/internal/cli/commands_cart.go index 8c8dde8..93bf45c 100644 --- a/internal/cli/commands_cart.go +++ b/internal/cli/commands_cart.go @@ -1449,7 +1449,7 @@ func buildCartState(page map[string]any, venueID string) (map[string]any, []stri priceDisplay = nil warnings = append(warnings, fmt.Sprintf("basket item %q: %v", asString(item["id"]), unitErr)) } - lines = append(lines, map[string]any{ + line := map[string]any{ "line_id": asString(item["id"]), "item_id": asString(item["id"]), "name": asString(item["name"]), @@ -1463,7 +1463,20 @@ func buildCartState(page map[string]any, venueID string) (map[string]any, []stri "amount": lineAmount, "formatted_amount": lineTotalDisplay, }, - }) + } + // Wolt leaves an unavailable line (sold out, or outside the item's + // sale hours) out of the basket total, so the total can be below the + // subtotal of the listed lines. + if available, ok := item["is_available"].(bool); ok { + line["is_available"] = available + if !available { + warnings = append(warnings, fmt.Sprintf( + "basket item %q is unavailable now; Wolt leaves it out of the basket total", + fallbackString(asString(item["name"]), asString(item["id"])), + )) + } + } + lines = append(lines, line) } if !totalsValid { subtotalAmount = 0 diff --git a/internal/gateway/wolt/client.go b/internal/gateway/wolt/client.go index 30e7a52..64f2330 100644 --- a/internal/gateway/wolt/client.go +++ b/internal/gateway/wolt/client.go @@ -123,6 +123,8 @@ type Client struct { nextRequestAt time.Time verboseOutput io.Writer verboseOutputM sync.RWMutex + now func() time.Time + venueLocations sync.Map } // Option applies Client options. @@ -159,6 +161,13 @@ func WithRequestMinInterval(interval time.Duration) Option { } } +// WithClock replaces the clock used to evaluate item sale windows. +func WithClock(now func() time.Time) Option { + return func(c *Client) { + c.now = now + } +} + // WithVerboseOutput enables per-request trace output for upstream HTTP calls. func WithVerboseOutput(out io.Writer) Option { return func(c *Client) { @@ -196,6 +205,7 @@ func NewClient(opts ...Option) *Client { }, locale: "en", webClientID: generateWebClientID(), + now: time.Now, } for _, opt := range opts { opt(c) @@ -649,7 +659,8 @@ func (c *Client) VenuePageDynamic(ctx context.Context, slug string, options Venu // AssortmentByVenueSlug returns full assortment payload for one venue slug. func (c *Client) AssortmentByVenueSlug(ctx context.Context, slug string) (map[string]any, error) { - return c.doJSONRequest(ctx, http.MethodGet, c.endpoints.Assortment+slug+"/assortment", nil, nil, c.headers(nil, nil)) + payload, err := c.doJSONRequest(ctx, http.MethodGet, c.endpoints.Assortment+slug+"/assortment", nil, nil, c.headers(nil, nil)) + return c.withItemSchedules(ctx, slug, payload, err) } // AssortmentCategoryByVenueSlug returns one category assortment payload by category slug. @@ -667,7 +678,8 @@ func (c *Client) AssortmentCategoryByVenueSlug( params.Set("language", lang) } endpoint := c.endpoints.Assortment + slug + "/assortment/categories/slug/" + url.PathEscape(strings.TrimSpace(categorySlug)) - return c.doJSONRequest(ctx, http.MethodGet, endpoint, params, nil, c.headers(nil, &auth)) + payload, err := c.doJSONRequest(ctx, http.MethodGet, endpoint, params, nil, c.headers(nil, &auth)) + return c.withItemSchedules(ctx, slug, payload, err) } // AssortmentItemsByVenueSlug returns detailed item payload for selected assortment item ids. @@ -689,7 +701,7 @@ func (c *Client) AssortmentItemsByVenueSlug( "item_ids": ids, } endpoint := c.endpoints.Assortment + slug + "/assortment/items" - return c.doJSONRequest( + payload, err := c.doJSONRequest( ctx, http.MethodPost, endpoint, @@ -697,6 +709,7 @@ func (c *Client) AssortmentItemsByVenueSlug( body, c.headers(map[string]string{"Content-Type": "application/json"}, &auth), ) + return c.withItemSchedules(ctx, slug, payload, err) } // AssortmentItemsSearchByVenueSlug searches items inside one venue assortment. @@ -717,7 +730,7 @@ func (c *Client) AssortmentItemsSearchByVenueSlug( "q": strings.TrimSpace(query), } endpoint := c.endpoints.Assortment + slug + "/assortment/items/search" - return c.doJSONRequest( + payload, err := c.doJSONRequest( ctx, http.MethodPost, endpoint, @@ -725,6 +738,7 @@ func (c *Client) AssortmentItemsSearchByVenueSlug( body, c.headers(map[string]string{"Content-Type": "application/json"}, &auth), ) + return c.withItemSchedules(ctx, slug, payload, err) } // VenueContentByVenueSlug returns venue-content payload by venue slug. diff --git a/internal/gateway/wolt/item_schedule.go b/internal/gateway/wolt/item_schedule.go new file mode 100644 index 0000000..036f3d4 --- /dev/null +++ b/internal/gateway/wolt/item_schedule.go @@ -0,0 +1,224 @@ +package wolt + +import ( + "context" + "fmt" + "strings" + "time" + + // Venue timezones are resolved by IANA name; Windows hosts ship no zoneinfo. + _ "time/tzdata" +) + +const millisPerDay = 24 * 60 * 60 * 1000 + +// itemScheduleSource tags a disabled_info the client derives from +// available_times, so it is distinguishable from one Wolt sent. +const itemScheduleSource = "available_times" + +// Assortment items may carry available_times: weekly sale windows in the +// venue's local time. days are ISO weekdays (1 = Monday ... 7 = Sunday); +// start_time and end_time are milliseconds since local midnight. Outside its +// windows an item keeps disabled_info = null in the assortment, yet Wolt +// marks the basket line unavailable and leaves it out of the basket total. +// The client therefore writes a disabled_info for such items, which every +// availability check downstream already honours. +type itemWindow struct { + days map[time.Weekday]struct{} + start int64 + end int64 +} + +func (c *Client) withItemSchedules( + ctx context.Context, + slug string, + payload map[string]any, + err error, +) (map[string]any, error) { + if err == nil && payload != nil { + c.markOffScheduleItems(ctx, slug, payload) + } + return payload, err +} + +// markOffScheduleItems disables the top-level assortment items whose +// available_times exclude the current venue-local time. The venue timezone +// is fetched only when some item is restricted at all; when it cannot be +// resolved, items stay as Wolt returned them. +func (c *Client) markOffScheduleItems(ctx context.Context, slug string, payload map[string]any) { + type scheduledItem struct { + item map[string]any + windows []itemWindow + } + restricted := []scheduledItem{} + items, _ := payload["items"].([]any) + for _, raw := range items { + item, ok := raw.(map[string]any) + if !ok || item["disabled_info"] != nil { + continue + } + windows, ok := parseItemWindows(item["available_times"]) + if !ok || coversWholeWeek(windows) { + continue + } + restricted = append(restricted, scheduledItem{item: item, windows: windows}) + } + if len(restricted) == 0 { + return + } + + location, ok := c.venueLocation(ctx, slug) + if !ok { + return + } + clock := c.now + if clock == nil { + clock = time.Now + } + now := clock().In(location) + for _, entry := range restricted { + if windowsContain(entry.windows, now) { + continue + } + entry.item["disabled_info"] = map[string]any{ + "disable_text": "not sold now; available " + describeWindows(entry.windows) + " " + location.String(), + "source": itemScheduleSource, + } + } +} + +// venueLocation resolves the venue timezone from its static page and caches +// it for the client's lifetime. +func (c *Client) venueLocation(ctx context.Context, slug string) (*time.Location, bool) { + key := strings.ToLower(strings.TrimSpace(slug)) + if key == "" { + return nil, false + } + if cached, ok := c.venueLocations.Load(key); ok { + return cached.(*time.Location), true + } + page, err := c.VenuePageStatic(ctx, slug) + if err != nil { + return nil, false + } + venue, _ := page["venue"].(map[string]any) + name, _ := venue["timezone"].(string) + name = strings.TrimSpace(name) + if name == "" { + return nil, false + } + location, err := time.LoadLocation(name) + if err != nil { + return nil, false + } + c.venueLocations.Store(key, location) + return location, true +} + +// parseItemWindows reads available_times. Missing, empty or malformed +// schedules report false and leave the item unrestricted. +func parseItemWindows(raw any) ([]itemWindow, bool) { + entries, ok := raw.([]any) + if !ok || len(entries) == 0 { + return nil, false + } + windows := make([]itemWindow, 0, len(entries)) + for _, rawEntry := range entries { + entry, ok := rawEntry.(map[string]any) + if !ok { + return nil, false + } + start, startOK := wholeNumber(entry["start_time"]) + end, endOK := wholeNumber(entry["end_time"]) + rawDays, daysOK := entry["days"].([]any) + if !startOK || !endOK || !daysOK || start < 0 || end < 0 || start > millisPerDay || end > millisPerDay { + return nil, false + } + window := itemWindow{days: map[time.Weekday]struct{}{}, start: start, end: end} + for _, rawDay := range rawDays { + day, ok := wholeNumber(rawDay) + if !ok || day < 1 || day > 7 { + return nil, false + } + window.days[time.Weekday(day%7)] = struct{}{} + } + windows = append(windows, window) + } + return windows, true +} + +func wholeNumber(raw any) (int64, bool) { + value, ok := raw.(float64) + if !ok || value != float64(int64(value)) { + return 0, false + } + return int64(value), true +} + +func coversWholeWeek(windows []itemWindow) bool { + for day := time.Sunday; day <= time.Saturday; day++ { + covered := false + for _, window := range windows { + if _, ok := window.days[day]; ok && window.start == 0 && window.end == millisPerDay { + covered = true + break + } + } + if !covered { + return false + } + } + return true +} + +// windowsContain reports whether now (venue-local) falls inside a window. A +// window whose end is not after its start runs past midnight into the next +// day. +func windowsContain(windows []itemWindow, now time.Time) bool { + midnight := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()) + sinceMidnight := now.Sub(midnight).Milliseconds() + today := now.Weekday() + yesterday := (today + 6) % 7 + for _, window := range windows { + _, onToday := window.days[today] + if window.start < window.end { + if onToday && sinceMidnight >= window.start && sinceMidnight < window.end { + return true + } + continue + } + if onToday && sinceMidnight >= window.start { + return true + } + if _, onYesterday := window.days[yesterday]; onYesterday && sinceMidnight < window.end { + return true + } + } + return false +} + +func describeWindows(windows []itemWindow) string { + parts := make([]string, 0, len(windows)) + for _, window := range windows { + days := []string{} + for _, day := range []time.Weekday{ + time.Monday, time.Tuesday, time.Wednesday, time.Thursday, time.Friday, time.Saturday, time.Sunday, + } { + if _, ok := window.days[day]; ok { + days = append(days, day.String()[:3]) + } + } + parts = append(parts, fmt.Sprintf( + "%s %s-%s", + strings.Join(days, ","), + clockLabel(window.start), + clockLabel(window.end), + )) + } + return strings.Join(parts, "; ") +} + +func clockLabel(millis int64) string { + minutes := millis / 60000 + return fmt.Sprintf("%02d:%02d", minutes/60, minutes%60) +} diff --git a/internal/gateway/wolt/item_schedule_test.go b/internal/gateway/wolt/item_schedule_test.go new file mode 100644 index 0000000..9b0b18d --- /dev/null +++ b/internal/gateway/wolt/item_schedule_test.go @@ -0,0 +1,199 @@ +package wolt + +import ( + "context" + "io" + "net/http" + "strings" + "testing" + "time" +) + +// routeHTTPClient answers by URL path suffix and counts calls per route. +type routeHTTPClient struct { + routes map[string]string + calls map[string]int +} + +func (c *routeHTTPClient) Do(req *http.Request) (*http.Response, error) { + if c.calls == nil { + c.calls = map[string]int{} + } + for suffix, body := range c.routes { + if strings.HasSuffix(req.URL.Path, suffix) { + c.calls[suffix]++ + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(body)), + Header: make(http.Header), + Request: req, + }, nil + } + } + return &http.Response{ + StatusCode: http.StatusNotFound, + Body: io.NopCloser(strings.NewReader(`{}`)), + Header: make(http.Header), + Request: req, + }, nil +} + +const scheduleTestStatic = `{"venue":{"timezone":"Europe/Helsinki"}}` + +// Monday-only lunch (10:30-15:00), an all-week item and a Friday-to-Saturday +// overnight window (22:00-02:00). +const scheduleTestAssortment = `{"items":[ + {"id":"lunch","name":"LAMB METHI","disabled_info":null, + "available_times":[{"days":[1],"start_time":37800000,"end_time":54000000}]}, + {"id":"always","name":"Soup","disabled_info":null, + "available_times":[{"days":[1,2,3,4,5,6,7],"start_time":0,"end_time":86400000}]}, + {"id":"late","name":"Night snack","disabled_info":null, + "available_times":[{"days":[5],"start_time":79200000,"end_time":7200000}]} +]}` + +func scheduleTestClient(t *testing.T, routes map[string]string, now string) (*Client, *routeHTTPClient) { + t.Helper() + helsinki, err := time.LoadLocation("Europe/Helsinki") + if err != nil { + t.Fatalf("load location: %v", err) + } + at, err := time.ParseInLocation("2006-01-02 15:04", now, helsinki) + if err != nil { + t.Fatalf("parse now: %v", err) + } + httpClient := &routeHTTPClient{routes: routes} + client := NewClient( + WithHTTPClient(httpClient), + WithEndpoints(Endpoints{ + VenuePage: "https://example.test/pages/venue/slug/", + Assortment: "https://example.test/assortment/v1/venues/slug/", + }), + // The clock runs in UTC, as on CI; windows are venue-local. + WithClock(at.UTC), + ) + return client, httpClient +} + +func scheduleTestItems(t *testing.T, payload map[string]any) map[string]map[string]any { + t.Helper() + out := map[string]map[string]any{} + for _, raw := range payload["items"].([]any) { + item := raw.(map[string]any) + out[item["id"].(string)] = item + } + return out +} + +func TestAssortmentDisablesItemsOutsideTheirSaleWindow(t *testing.T) { + client, httpClient := scheduleTestClient(t, map[string]string{ + "/trishuli/assortment": scheduleTestAssortment, + "/trishuli/static": scheduleTestStatic, + }, "2026-10-22 15:51") // Thursday + + payload, err := client.AssortmentByVenueSlug(context.Background(), "trishuli") + if err != nil { + t.Fatalf("AssortmentByVenueSlug() error = %v", err) + } + items := scheduleTestItems(t, payload) + disabled, ok := items["lunch"]["disabled_info"].(map[string]any) + if !ok { + t.Fatalf("lunch disabled_info = %#v, want a map", items["lunch"]["disabled_info"]) + } + if got, want := disabled["disable_text"], "not sold now; available Mon 10:30-15:00 Europe/Helsinki"; got != want { + t.Fatalf("disable_text = %q, want %q", got, want) + } + if disabled["source"] != itemScheduleSource { + t.Fatalf("source = %#v", disabled["source"]) + } + if items["always"]["disabled_info"] != nil || items["late"]["disabled_info"] == nil { + t.Fatalf("always = %#v, late = %#v", items["always"]["disabled_info"], items["late"]["disabled_info"]) + } + + if _, err := client.AssortmentByVenueSlug(context.Background(), "trishuli"); err != nil { + t.Fatalf("second AssortmentByVenueSlug() error = %v", err) + } + if got := httpClient.calls["/trishuli/static"]; got != 1 { + t.Fatalf("static calls = %d, want 1 (timezone is cached per venue)", got) + } +} + +func TestAssortmentKeepsItemsInsideTheirSaleWindow(t *testing.T) { + cases := map[string]struct { + now string + enabled []string + }{ + "monday lunch in venue time": {now: "2026-10-19 10:30", enabled: []string{"lunch", "always"}}, + "monday lunch end is excluded": {now: "2026-10-19 15:00", enabled: []string{"always"}}, + "overnight window before 0:00": {now: "2026-10-23 23:15", enabled: []string{"always", "late"}}, + "overnight window after 0:00": {now: "2026-10-24 01:59", enabled: []string{"always", "late"}}, + "overnight window has ended": {now: "2026-10-24 02:00", enabled: []string{"always"}}, + } + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + client, _ := scheduleTestClient(t, map[string]string{ + "/trishuli/assortment": scheduleTestAssortment, + "/trishuli/static": scheduleTestStatic, + }, tc.now) + payload, err := client.AssortmentByVenueSlug(context.Background(), "trishuli") + if err != nil { + t.Fatalf("AssortmentByVenueSlug() error = %v", err) + } + enabled := []string{} + for _, id := range []string{"lunch", "always", "late"} { + if scheduleTestItems(t, payload)[id]["disabled_info"] == nil { + enabled = append(enabled, id) + } + } + if strings.Join(enabled, ",") != strings.Join(tc.enabled, ",") { + t.Fatalf("enabled = %v, want %v", enabled, tc.enabled) + } + }) + } +} + +func TestAssortmentSkipsTimezoneLookupWithoutRestrictedItems(t *testing.T) { + client, httpClient := scheduleTestClient(t, map[string]string{ + "/trishuli/assortment": `{"items":[{"id":"always","disabled_info":null, + "available_times":[{"days":[1,2,3,4,5,6,7],"start_time":0,"end_time":86400000}]}, + {"id":"plain","disabled_info":null}]}`, + "/trishuli/static": scheduleTestStatic, + }, "2026-10-22 15:51") + if _, err := client.AssortmentByVenueSlug(context.Background(), "trishuli"); err != nil { + t.Fatalf("AssortmentByVenueSlug() error = %v", err) + } + if got := httpClient.calls["/trishuli/static"]; got != 0 { + t.Fatalf("static calls = %d, want 0", got) + } +} + +func TestAssortmentLeavesItemsUntouchedWhenTimezoneIsUnknown(t *testing.T) { + client, _ := scheduleTestClient(t, map[string]string{ + "/trishuli/assortment": scheduleTestAssortment, + "/trishuli/static": `{"venue":{}}`, + }, "2026-10-22 15:51") + payload, err := client.AssortmentByVenueSlug(context.Background(), "trishuli") + if err != nil { + t.Fatalf("AssortmentByVenueSlug() error = %v", err) + } + for id, item := range scheduleTestItems(t, payload) { + if item["disabled_info"] != nil { + t.Fatalf("%s disabled_info = %#v, want nil", id, item["disabled_info"]) + } + } +} + +func TestAssortmentKeepsWoltDisabledInfo(t *testing.T) { + client, _ := scheduleTestClient(t, map[string]string{ + "/trishuli/assortment": `{"items":[{"id":"lunch","disabled_info":{"disable_text":"Sold out"}, + "available_times":[{"days":[1],"start_time":37800000,"end_time":54000000}]}]}`, + "/trishuli/static": scheduleTestStatic, + }, "2026-10-22 15:51") + payload, err := client.AssortmentByVenueSlug(context.Background(), "trishuli") + if err != nil { + t.Fatalf("AssortmentByVenueSlug() error = %v", err) + } + disabled := scheduleTestItems(t, payload)["lunch"]["disabled_info"].(map[string]any) + if disabled["disable_text"] != "Sold out" { + t.Fatalf("disabled_info = %#v, want Wolt's own", disabled) + } +} From c389112e023228749463b484731454a7da69e573 Mon Sep 17 00:00:00 2001 From: Nikita R Date: Thu, 22 Oct 2026 16:25:12 +0300 Subject: [PATCH 2/2] fix(menu): read the venue time from pages already loaded Looking up the venue timezone on the static venue page added one static request per venue menu with restricted items. Wolt rate-limits that endpoint for GitHub runner IPs: after the live smoke's menu crawl, the static lookup inside `cart add ` got 429 and the add failed with WOLT_ITEM_AVAILABILITY_UNKNOWN. The client now records each venue's zone from the venue pages it loads anyway: the IANA zone of a static page (under the requested reference, its slug and its id) and the UTC offset of open_status.now on a dynamic page. A menu load and a cart add therefore need no extra request. When no page was loaded, the client asks the dynamic page, and the static page is the last resort. An offset expires after an hour so a long-running MCP server picks up a DST switch. The unavailable reason says "venue time" instead of the zone name, since an offset has none. --- docs/output-contract.md | 5 +- internal/gateway/wolt/client.go | 14 ++- internal/gateway/wolt/item_schedule.go | 116 +++++++++++++++----- internal/gateway/wolt/item_schedule_test.go | 80 ++++++++++++-- 4 files changed, 177 insertions(+), 38 deletions(-) diff --git a/docs/output-contract.md b/docs/output-contract.md index 05aa87e..8360ca4 100644 --- a/docs/output-contract.md +++ b/docs/output-contract.md @@ -405,8 +405,9 @@ Availability is derived from Wolt's current `disabled_info` and Wolt keeps `disabled_info` null outside those hours, but its basket marks such a line unavailable. The CLI and MCP therefore treat an item outside its hours as disabled, with a reason such as - `not sold now; available Mon 10:30-15:00 Europe/Helsinki`. When the venue - timezone cannot be read, the hours are not checked. + `not sold now; available Mon 10:30-15:00 venue time`. The venue's time + comes from the venue pages the command already loads; when it cannot be + read, the hours are not checked. `is_sold_out` remains as a deprecated, derived compatibility alias (`!is_available`); raw `is_sold_out` and `sold_out` fields are not expected diff --git a/internal/gateway/wolt/client.go b/internal/gateway/wolt/client.go index 64f2330..131c302 100644 --- a/internal/gateway/wolt/client.go +++ b/internal/gateway/wolt/client.go @@ -124,7 +124,7 @@ type Client struct { verboseOutput io.Writer verboseOutputM sync.RWMutex now func() time.Time - venueLocations sync.Map + venueZones sync.Map } // Option applies Client options. @@ -628,7 +628,11 @@ func (c *Client) searchPage(ctx context.Context, body map[string]any, auth AuthC // VenuePageStatic returns venue static page payload. func (c *Client) VenuePageStatic(ctx context.Context, slug string) (map[string]any, error) { - return c.doJSONRequest(ctx, http.MethodGet, c.endpoints.VenuePage+slug+"/static", nil, nil, c.headers(nil, nil)) + payload, err := c.doJSONRequest(ctx, http.MethodGet, c.endpoints.VenuePage+slug+"/static", nil, nil, c.headers(nil, nil)) + if err == nil { + c.rememberVenueStaticZone(slug, payload) + } + return payload, err } // VenuePageDynamic returns venue dynamic page payload. @@ -647,7 +651,7 @@ func (c *Client) VenuePageDynamic(ctx context.Context, slug string, options Venu } params.Set("selected_delivery_method", selectedDeliveryMethod) } - return c.doJSONRequest( + payload, err := c.doJSONRequest( ctx, http.MethodGet, endpoint+slug+"/dynamic/", @@ -655,6 +659,10 @@ func (c *Client) VenuePageDynamic(ctx context.Context, slug string, options Venu nil, c.headers(nil, &options.Auth), ) + if err == nil { + c.rememberVenueDynamicZone(slug, payload) + } + return payload, err } // AssortmentByVenueSlug returns full assortment payload for one venue slug. diff --git a/internal/gateway/wolt/item_schedule.go b/internal/gateway/wolt/item_schedule.go index 036f3d4..d9f5230 100644 --- a/internal/gateway/wolt/item_schedule.go +++ b/internal/gateway/wolt/item_schedule.go @@ -6,7 +6,7 @@ import ( "strings" "time" - // Venue timezones are resolved by IANA name; Windows hosts ship no zoneinfo. + // Static venue pages name an IANA zone; Windows hosts ship no zoneinfo. _ "time/tzdata" ) @@ -29,6 +29,15 @@ type itemWindow struct { end int64 } +// offsetZoneTTL bounds how long a UTC offset read from a venue's current +// time is trusted; a long-running MCP server may outlive a DST switch. +const offsetZoneTTL = time.Hour + +type venueZone struct { + location *time.Location + expires time.Time // zero for an IANA zone, which never goes stale +} + func (c *Client) withItemSchedules( ctx context.Context, slug string, @@ -42,8 +51,8 @@ func (c *Client) withItemSchedules( } // markOffScheduleItems disables the top-level assortment items whose -// available_times exclude the current venue-local time. The venue timezone -// is fetched only when some item is restricted at all; when it cannot be +// available_times exclude the current venue-local time. The venue zone is +// looked up only when some item is restricted at all; when it cannot be // resolved, items stay as Wolt returned them. func (c *Client) markOffScheduleItems(ctx context.Context, slug string, payload map[string]any) { type scheduledItem struct { @@ -71,48 +80,105 @@ func (c *Client) markOffScheduleItems(ctx context.Context, slug string, payload if !ok { return } - clock := c.now - if clock == nil { - clock = time.Now - } - now := clock().In(location) + now := c.clock().In(location) for _, entry := range restricted { if windowsContain(entry.windows, now) { continue } entry.item["disabled_info"] = map[string]any{ - "disable_text": "not sold now; available " + describeWindows(entry.windows) + " " + location.String(), + "disable_text": "not sold now; available " + describeWindows(entry.windows) + " venue time", "source": itemScheduleSource, } } } -// venueLocation resolves the venue timezone from its static page and caches -// it for the client's lifetime. +func (c *Client) clock() time.Time { + if c.now == nil { + return time.Now() + } + return c.now() +} + +// venueLocation returns the venue's zone. Venue pages the client already +// loaded supply it, so a menu load normally costs no extra request. Otherwise +// the dynamic venue page is asked for the venue's current time, and the +// static page, which Wolt rate-limits harder, is the last resort. func (c *Client) venueLocation(ctx context.Context, slug string) (*time.Location, bool) { - key := strings.ToLower(strings.TrimSpace(slug)) + if location, ok := c.lookupVenueZone(slug); ok { + return location, true + } + if _, err := c.VenuePageDynamic(ctx, slug, VenuePageDynamicOptions{}); err == nil { + if location, ok := c.lookupVenueZone(slug); ok { + return location, true + } + } + if _, err := c.VenuePageStatic(ctx, slug); err == nil { + return c.lookupVenueZone(slug) + } + return nil, false +} + +func (c *Client) lookupVenueZone(reference string) (*time.Location, bool) { + key := venueZoneKey(reference) if key == "" { return nil, false } - if cached, ok := c.venueLocations.Load(key); ok { - return cached.(*time.Location), true + cached, ok := c.venueZones.Load(key) + if !ok { + return nil, false } - page, err := c.VenuePageStatic(ctx, slug) - if err != nil { + zone := cached.(venueZone) + if !zone.expires.IsZero() && c.clock().After(zone.expires) { + c.venueZones.Delete(key) return nil, false } - venue, _ := page["venue"].(map[string]any) + return zone.location, true +} + +// rememberVenueStaticZone records the IANA zone of a static venue page under +// the requested reference and the venue's own slug and id. +func (c *Client) rememberVenueStaticZone(reference string, payload map[string]any) { + venue, _ := payload["venue"].(map[string]any) name, _ := venue["timezone"].(string) - name = strings.TrimSpace(name) - if name == "" { - return nil, false + location, err := time.LoadLocation(strings.TrimSpace(name)) + if strings.TrimSpace(name) == "" || err != nil { + return } - location, err := time.LoadLocation(name) - if err != nil { - return nil, false + c.storeVenueZone(venueZone{location: location}, reference, venue["slug"], venue["id"]) +} + +// rememberVenueDynamicZone records the UTC offset of the venue's current time, +// which the dynamic venue page reports as open_status.now. +func (c *Client) rememberVenueDynamicZone(reference string, payload map[string]any) { + venue, _ := payload["venue"].(map[string]any) + for _, key := range []string{"open_status", "delivery_open_status"} { + status, _ := venue[key].(map[string]any) + raw, _ := status["now"].(string) + venueNow, err := time.Parse(time.RFC3339Nano, strings.TrimSpace(raw)) + if err != nil { + continue + } + _, offset := venueNow.Zone() + zone := venueZone{ + location: time.FixedZone(venueNow.Format("-07:00"), offset), + expires: c.clock().Add(offsetZoneTTL), + } + c.storeVenueZone(zone, reference, venue["slug"], venue["id"]) + return + } +} + +func (c *Client) storeVenueZone(zone venueZone, references ...any) { + for _, reference := range references { + name, _ := reference.(string) + if key := venueZoneKey(name); key != "" { + c.venueZones.Store(key, zone) + } } - c.venueLocations.Store(key, location) - return location, true +} + +func venueZoneKey(reference string) string { + return strings.ToLower(strings.TrimSpace(reference)) } // parseItemWindows reads available_times. Missing, empty or malformed diff --git a/internal/gateway/wolt/item_schedule_test.go b/internal/gateway/wolt/item_schedule_test.go index 9b0b18d..15c6694 100644 --- a/internal/gateway/wolt/item_schedule_test.go +++ b/internal/gateway/wolt/item_schedule_test.go @@ -38,7 +38,10 @@ func (c *routeHTTPClient) Do(req *http.Request) (*http.Response, error) { }, nil } -const scheduleTestStatic = `{"venue":{"timezone":"Europe/Helsinki"}}` +const scheduleTestStatic = `{"venue":{"slug":"trishuli","timezone":"Europe/Helsinki"}}` + +// The dynamic page reports the venue's current time with its UTC offset. +const scheduleTestDynamic = `{"venue":{"open_status":{"now":"2026-10-22T15:51:00.123456+03:00"}}}` // Monday-only lunch (10:30-15:00), an all-week item and a Friday-to-Saturday // overnight window (22:00-02:00). @@ -65,8 +68,9 @@ func scheduleTestClient(t *testing.T, routes map[string]string, now string) (*Cl client := NewClient( WithHTTPClient(httpClient), WithEndpoints(Endpoints{ - VenuePage: "https://example.test/pages/venue/slug/", - Assortment: "https://example.test/assortment/v1/venues/slug/", + VenuePage: "https://example.test/pages/venue/slug/", + VenuePageDynamic: "https://example.test/venue/slug/", + Assortment: "https://example.test/assortment/v1/venues/slug/", }), // The clock runs in UTC, as on CI; windows are venue-local. WithClock(at.UTC), @@ -87,6 +91,7 @@ func scheduleTestItems(t *testing.T, payload map[string]any) map[string]map[stri func TestAssortmentDisablesItemsOutsideTheirSaleWindow(t *testing.T) { client, httpClient := scheduleTestClient(t, map[string]string{ "/trishuli/assortment": scheduleTestAssortment, + "/trishuli/dynamic/": scheduleTestDynamic, "/trishuli/static": scheduleTestStatic, }, "2026-10-22 15:51") // Thursday @@ -99,7 +104,7 @@ func TestAssortmentDisablesItemsOutsideTheirSaleWindow(t *testing.T) { if !ok { t.Fatalf("lunch disabled_info = %#v, want a map", items["lunch"]["disabled_info"]) } - if got, want := disabled["disable_text"], "not sold now; available Mon 10:30-15:00 Europe/Helsinki"; got != want { + if got, want := disabled["disable_text"], "not sold now; available Mon 10:30-15:00 venue time"; got != want { t.Fatalf("disable_text = %q, want %q", got, want) } if disabled["source"] != itemScheduleSource { @@ -112,8 +117,11 @@ func TestAssortmentDisablesItemsOutsideTheirSaleWindow(t *testing.T) { if _, err := client.AssortmentByVenueSlug(context.Background(), "trishuli"); err != nil { t.Fatalf("second AssortmentByVenueSlug() error = %v", err) } - if got := httpClient.calls["/trishuli/static"]; got != 1 { - t.Fatalf("static calls = %d, want 1 (timezone is cached per venue)", got) + if got := httpClient.calls["/trishuli/dynamic/"]; got != 1 { + t.Fatalf("dynamic calls = %d, want 1 (the zone is cached per venue)", got) + } + if got := httpClient.calls["/trishuli/static"]; got != 0 { + t.Fatalf("static calls = %d, want 0", got) } } @@ -161,8 +169,64 @@ func TestAssortmentSkipsTimezoneLookupWithoutRestrictedItems(t *testing.T) { if _, err := client.AssortmentByVenueSlug(context.Background(), "trishuli"); err != nil { t.Fatalf("AssortmentByVenueSlug() error = %v", err) } - if got := httpClient.calls["/trishuli/static"]; got != 0 { - t.Fatalf("static calls = %d, want 0", got) + if got := httpClient.calls["/trishuli/static"] + httpClient.calls["/trishuli/dynamic/"]; got != 0 { + t.Fatalf("venue page calls = %d, want 0", got) + } +} + +func TestAssortmentUsesTheZoneOfAVenuePageAlreadyLoaded(t *testing.T) { + client, httpClient := scheduleTestClient(t, map[string]string{ + "/trishuli/assortment": scheduleTestAssortment, + "/6347f894ef53042e6c3eaaa6/static": scheduleTestStatic, + }, "2026-10-22 15:51") + // A cart add by venue id loads the static page first; it names the slug. + if _, err := client.VenuePageStatic(context.Background(), "6347f894ef53042e6c3eaaa6"); err != nil { + t.Fatalf("VenuePageStatic() error = %v", err) + } + payload, err := client.AssortmentByVenueSlug(context.Background(), "trishuli") + if err != nil { + t.Fatalf("AssortmentByVenueSlug() error = %v", err) + } + if scheduleTestItems(t, payload)["lunch"]["disabled_info"] == nil { + t.Fatal("lunch was not disabled with the zone from the loaded static page") + } + if got := httpClient.calls["/trishuli/dynamic/"] + httpClient.calls["/trishuli/static"]; got != 0 { + t.Fatalf("extra venue page calls = %d, want 0", got) + } +} + +func TestAssortmentFallsBackToTheStaticPageZone(t *testing.T) { + client, httpClient := scheduleTestClient(t, map[string]string{ + "/trishuli/assortment": scheduleTestAssortment, + "/trishuli/dynamic/": `{"venue":{"open_status":null}}`, + "/trishuli/static": scheduleTestStatic, + }, "2026-10-22 15:51") + payload, err := client.AssortmentByVenueSlug(context.Background(), "trishuli") + if err != nil { + t.Fatalf("AssortmentByVenueSlug() error = %v", err) + } + if scheduleTestItems(t, payload)["lunch"]["disabled_info"] == nil { + t.Fatal("lunch was not disabled with the static page zone") + } + if httpClient.calls["/trishuli/dynamic/"] != 1 || httpClient.calls["/trishuli/static"] != 1 { + t.Fatalf("calls = %#v, want one dynamic and one static", httpClient.calls) + } +} + +func TestOffsetZoneExpires(t *testing.T) { + client, httpClient := scheduleTestClient(t, map[string]string{ + "/trishuli/dynamic/": scheduleTestDynamic, + }, "2026-10-22 15:51") + if _, ok := client.venueLocation(context.Background(), "trishuli"); !ok { + t.Fatal("venueLocation() found no zone") + } + later := client.clock().Add(offsetZoneTTL + time.Minute) + client.now = func() time.Time { return later } + if _, ok := client.venueLocation(context.Background(), "trishuli"); !ok { + t.Fatal("venueLocation() found no zone after expiry") + } + if got := httpClient.calls["/trishuli/dynamic/"]; got != 2 { + t.Fatalf("dynamic calls = %d, want 2 (the offset is re-read after it expires)", got) } }