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..8360ca4 100644 --- a/docs/output-contract.md +++ b/docs/output-contract.md @@ -400,6 +400,14 @@ 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 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 @@ -470,7 +478,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 +490,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..131c302 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 + venueZones 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) @@ -618,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. @@ -637,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/", @@ -645,11 +659,16 @@ 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. 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 +686,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 +709,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 +717,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 +738,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 +746,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..d9f5230 --- /dev/null +++ b/internal/gateway/wolt/item_schedule.go @@ -0,0 +1,290 @@ +package wolt + +import ( + "context" + "fmt" + "strings" + "time" + + // Static venue pages name an IANA zone; 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 +} + +// 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, + 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 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 { + 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 + } + 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) + " venue time", + "source": itemScheduleSource, + } + } +} + +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) { + 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 + } + cached, ok := c.venueZones.Load(key) + if !ok { + return nil, false + } + zone := cached.(venueZone) + if !zone.expires.IsZero() && c.clock().After(zone.expires) { + c.venueZones.Delete(key) + return nil, false + } + 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) + location, err := time.LoadLocation(strings.TrimSpace(name)) + if strings.TrimSpace(name) == "" || err != nil { + return + } + 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) + } + } +} + +func venueZoneKey(reference string) string { + return strings.ToLower(strings.TrimSpace(reference)) +} + +// 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..15c6694 --- /dev/null +++ b/internal/gateway/wolt/item_schedule_test.go @@ -0,0 +1,263 @@ +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":{"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). +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/", + 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), + ) + 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/dynamic/": scheduleTestDynamic, + "/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 venue time"; 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/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) + } +} + +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"] + 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) + } +} + +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) + } +}