Skip to content

Commit b01acb1

Browse files
authored
fix: harden Walmart detail fetching (#71)
Pin walmart-client-go v2.2.1, preserve group IDs, skip active orders, cap detail calls before -max, and stop on bot challenges.
1 parent 8b06a92 commit b01acb1

6 files changed

Lines changed: 195 additions & 50 deletions

File tree

docs/bug-fixes.md

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,36 @@ Each bug fix entry should include:
1212

1313
## Bug Fixes
1414

15+
### 2026-07-15: Walmart detail requests were rejected with HTTP 456
16+
17+
**Description:**
18+
Walmart purchase history succeeded, but itemize then sent an obsolete request profile for every order-detail lookup. Walmart rejected those calls with HTTP 456, and itemize continued through the remaining history, repeating a session-level failure for orders it would later discard because of `-max`.
19+
20+
**Test Case:**
21+
```go
22+
// internal/adapters/providers/walmart/provider_test.go:
23+
// TestProvider_FetchOrders_WithMaxOrders
24+
// Expected: -max truncates summaries before any detail requests.
25+
26+
// TestProvider_FetchOrders_PassesPurchaseHistoryGroupID
27+
// Expected: the summary groupId is forwarded to getOrder.
28+
29+
// TestProvider_FetchOrders_StopsOnBotChallenge
30+
// Expected: ErrBotChallenge stops the detail loop after one request.
31+
```
32+
33+
**Root Cause:**
34+
Itemize used `walmart-client-go/v2 v2.1.0`, whose `getOrder` hash, GraphQL variables, user agent, Walmart platform version, and tracing headers no longer matched the web client. Cookie refresh merged snapshots, so expired WAF cookies could also survive. The itemize provider ignored each HTTP 456 as an order-specific failure, did not pass the purchase-history `groupId`, and applied `MaxOrders` only after fetching every detail response.
35+
36+
**Fix Applied:**
37+
Upgraded to `walmart-client-go/v2 v2.2.1`, which captures the live browser request profile, replaces stale cookie snapshots, preserves response-cookie path scope, forwards group IDs, and exposes `ErrBotChallenge`. Itemize now removes active/unfinalized summaries and truncates the remaining deduplicated summaries before detail calls, calls `GetOrderWithGroup`, and returns immediately when Walmart challenges the browser session.
38+
39+
**Verification:**
40+
- The four regression tests failed before their fixes and pass after them.
41+
- The walmart client performed a live read-only `getOrder` with the captured order/group ID and returned 17 items.
42+
- A second live client sequence fetched purchase history and then a completed in-store order with 4 items, confirming path-scoped history cookies no longer poison detail requests.
43+
- `./itemize walmart -dry-run -days 14 -max 1 -verbose` skipped the active `PLACED` summary, issued exactly one completed-order detail request, exited successfully, and logged no HTTP 456 response.
44+
1545
### 2026-07-10: Walmart refunds lacked item-level categorization
1646

1747
**Description:**

go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ require (
66
github.com/eshaffer321/amazon-go v0.3.0
77
github.com/eshaffer321/costco-go v0.3.11
88
github.com/eshaffer321/monarch-go/v2 v2.0.0
9-
github.com/eshaffer321/walmart-client-go/v2 v2.1.0
9+
github.com/eshaffer321/walmart-client-go/v2 v2.2.1
1010
github.com/getsentry/sentry-go v0.36.0
1111
github.com/go-chi/chi/v5 v5.3.0
1212
github.com/pressly/goose/v3 v3.27.2

go.sum

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,8 @@ github.com/eshaffer321/costco-go v0.3.11 h1:vOEXcSj/vGlwE/4AbXK3uUdXG7XYg1qfUPVi
1212
github.com/eshaffer321/costco-go v0.3.11/go.mod h1:ANJTHfRSPyot9cWKNlfs5qDKRKkA4tYORrorvWx/vbM=
1313
github.com/eshaffer321/monarch-go/v2 v2.0.0 h1:3reQ15D0K/Btc3mu+sfWdKJnU0hmNb0/ZwsbVUjN4l8=
1414
github.com/eshaffer321/monarch-go/v2 v2.0.0/go.mod h1:0rdAjSetvYQ8wGkUdg4BsqN8jTMhJ4TbHAr6qhAzSk4=
15-
github.com/eshaffer321/walmart-client-go/v2 v2.1.0 h1:FbOL/CuTXaTVDLgcBtE5C0SvjkwO6d/cIqNAVAF1OvU=
16-
github.com/eshaffer321/walmart-client-go/v2 v2.1.0/go.mod h1:4PVK9TsqFscTZypC67dgCt/vnPxXdtaheJVM7HOnod0=
15+
github.com/eshaffer321/walmart-client-go/v2 v2.2.1 h1:FT6VAzEC6SNdQm7r2Mr26Q8/cREDj5vlTGBHVp2W1lw=
16+
github.com/eshaffer321/walmart-client-go/v2 v2.2.1/go.mod h1:4PVK9TsqFscTZypC67dgCt/vnPxXdtaheJVM7HOnod0=
1717
github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM=
1818
github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE=
1919
github.com/getsentry/sentry-go v0.36.0 h1:UkCk0zV28PiGf+2YIONSSYiYhxwlERE5Li3JPpZqEns=

internal/adapters/providers/walmart/order.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import (
1313
// Order wraps a Walmart order and implements providers.Order interface
1414
type Order struct {
1515
walmartOrder *walmartclient.Order
16-
client *walmartclient.WalmartClient
16+
client walmartLedgerClient
1717
logger *slog.Logger
1818
ctx context.Context
1919

internal/adapters/providers/walmart/provider.go

Lines changed: 48 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package walmart
22

33
import (
44
"context"
5+
"errors"
56
"fmt"
67
"log/slog"
78
"time"
@@ -10,16 +11,33 @@ import (
1011
walmartclient "github.com/eshaffer321/walmart-client-go/v2"
1112
)
1213

14+
type walmartLedgerClient interface {
15+
GetOrderLedger(context.Context, string) (*walmartclient.OrderLedger, error)
16+
}
17+
18+
type walmartAPI interface {
19+
walmartLedgerClient
20+
GetPurchaseHistory(context.Context, walmartclient.PurchaseHistoryRequest) (*walmartclient.PurchaseHistoryResponse, error)
21+
GetOrderWithGroup(context.Context, string, string, bool) (*walmartclient.Order, error)
22+
}
23+
1324
// Provider implements the OrderProvider interface for Walmart
1425
type Provider struct {
15-
client *walmartclient.WalmartClient
26+
client walmartAPI
1627
logger *slog.Logger
1728
rateLimit time.Duration
1829
}
1930

2031
// NewProvider creates a new Walmart provider
2132
// Note: Caller is responsible for adding any scoping attributes (e.g., system="walmart")
2233
func NewProvider(client *walmartclient.WalmartClient, logger *slog.Logger) *Provider {
34+
if client == nil {
35+
return newProvider(nil, logger)
36+
}
37+
return newProvider(client, logger)
38+
}
39+
40+
func newProvider(client walmartAPI, logger *slog.Logger) *Provider {
2341
if logger == nil {
2442
logger = slog.Default()
2543
}
@@ -69,6 +87,19 @@ func (p *Provider) FetchOrders(ctx context.Context, opts providers.FetchOptions)
6987
p.logger.Warn("duplicate order summary returned by Walmart; using first occurrence",
7088
slog.String("order_id", orderID))
7189
}
90+
completedSummaries := make([]walmartclient.OrderSummary, 0, len(orderSummaries))
91+
for _, summary := range orderSummaries {
92+
if summary.IsActive {
93+
p.logger.Debug("skipping active Walmart order before detail fetch",
94+
slog.String("order_id", summary.OrderID))
95+
continue
96+
}
97+
completedSummaries = append(completedSummaries, summary)
98+
}
99+
orderSummaries = completedSummaries
100+
if opts.MaxOrders > 0 && len(orderSummaries) > opts.MaxOrders {
101+
orderSummaries = orderSummaries[:opts.MaxOrders]
102+
}
72103

73104
// Convert OrderSummary to full Orders
74105
var providerOrders []providers.Order
@@ -78,47 +109,25 @@ func (p *Provider) FetchOrders(ctx context.Context, opts providers.FetchOptions)
78109
return providerOrders, fmt.Errorf("cancelled during order fetch: %w", err)
79110
}
80111

81-
// Fetch full order details if requested
82-
if opts.IncludeDetails {
83-
isInStore := summary.FulfillmentType == "IN_STORE"
84-
fullOrder, err := p.client.GetOrder(ctx, summary.OrderID, isInStore)
85-
if err != nil {
86-
p.logger.Warn("failed to fetch order details, skipping",
87-
slog.String("order_id", summary.OrderID),
88-
slog.String("error", err.Error()))
89-
continue
90-
}
91-
92-
providerOrders = append(providerOrders, &Order{
93-
walmartOrder: fullOrder,
94-
client: p.client,
95-
logger: p.logger,
96-
ctx: ctx,
97-
})
98-
} else {
99-
// For basic listing, we'd need to create a minimal Order
100-
// For now, always fetch details
101-
isInStore := summary.FulfillmentType == "IN_STORE"
102-
fullOrder, err := p.client.GetOrder(ctx, summary.OrderID, isInStore)
103-
if err != nil {
104-
p.logger.Warn("failed to fetch order details, skipping",
105-
slog.String("order_id", summary.OrderID),
106-
slog.String("error", err.Error()))
107-
continue
112+
// The domain order requires details even when IncludeDetails is false.
113+
isInStore := summary.FulfillmentType == "IN_STORE"
114+
fullOrder, err := p.client.GetOrderWithGroup(ctx, summary.OrderID, summary.GroupID, isInStore)
115+
if err != nil {
116+
if errors.Is(err, walmartclient.ErrBotChallenge) {
117+
return providerOrders, fmt.Errorf("walmart session rejected while fetching order details: %w", err)
108118
}
109-
110-
providerOrders = append(providerOrders, &Order{
111-
walmartOrder: fullOrder,
112-
client: p.client,
113-
logger: p.logger,
114-
ctx: ctx,
115-
})
119+
p.logger.Warn("failed to fetch order details, skipping",
120+
slog.String("order_id", summary.OrderID),
121+
slog.String("error", err.Error()))
122+
continue
116123
}
117-
}
118124

119-
// Apply max orders limit if specified
120-
if opts.MaxOrders > 0 && len(providerOrders) > opts.MaxOrders {
121-
providerOrders = providerOrders[:opts.MaxOrders]
125+
providerOrders = append(providerOrders, &Order{
126+
walmartOrder: fullOrder,
127+
client: p.client,
128+
logger: p.logger,
129+
ctx: ctx,
130+
})
122131
}
123132

124133
p.logger.Info("fetched orders", slog.Int("total", len(providerOrders)))

internal/adapters/providers/walmart/provider_test.go

Lines changed: 113 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package walmart
22

33
import (
44
"context"
5+
"fmt"
56
"log/slog"
67
"os"
78
"testing"
@@ -13,6 +14,40 @@ import (
1314
"github.com/stretchr/testify/require"
1415
)
1516

17+
type detailCall struct {
18+
orderID string
19+
groupID string
20+
isInStore bool
21+
}
22+
23+
type fakeWalmartClient struct {
24+
summaries []walmartclient.OrderSummary
25+
historyErr error
26+
detailErr error
27+
detailCalls []detailCall
28+
}
29+
30+
func (f *fakeWalmartClient) GetPurchaseHistory(_ context.Context, _ walmartclient.PurchaseHistoryRequest) (*walmartclient.PurchaseHistoryResponse, error) {
31+
if f.historyErr != nil {
32+
return nil, f.historyErr
33+
}
34+
resp := &walmartclient.PurchaseHistoryResponse{}
35+
resp.Data.OrderHistoryV2.OrderGroups = f.summaries
36+
return resp, nil
37+
}
38+
39+
func (f *fakeWalmartClient) GetOrderWithGroup(_ context.Context, orderID, groupID string, isInStore bool) (*walmartclient.Order, error) {
40+
f.detailCalls = append(f.detailCalls, detailCall{orderID: orderID, groupID: groupID, isInStore: isInStore})
41+
if f.detailErr != nil {
42+
return nil, f.detailErr
43+
}
44+
return &walmartclient.Order{ID: orderID}, nil
45+
}
46+
47+
func (f *fakeWalmartClient) GetOrderLedger(_ context.Context, orderID string) (*walmartclient.OrderLedger, error) {
48+
return &walmartclient.OrderLedger{OrderID: orderID}, nil
49+
}
50+
1651
// TestProvider_ImplementsInterface verifies Provider implements OrderProvider
1752
func TestProvider_ImplementsInterface(t *testing.T) {
1853
var _ providers.OrderProvider = (*Provider)(nil)
@@ -41,11 +76,8 @@ func TestProvider_SupportsFeatures(t *testing.T) {
4176

4277
// TestProvider_FetchOrders_EmptyResult tests fetching with no orders
4378
func TestProvider_FetchOrders_EmptyResult(t *testing.T) {
44-
// This test will fail until we implement the provider
45-
t.Skip("Skipping until we have a mock walmart client")
46-
4779
logger := slog.New(slog.NewTextHandler(os.Stderr, nil))
48-
provider := NewProvider(nil, logger)
80+
provider := newProvider(&fakeWalmartClient{}, logger)
4981

5082
ctx := context.Background()
5183
opts := providers.FetchOptions{
@@ -63,10 +95,84 @@ func TestProvider_FetchOrders_EmptyResult(t *testing.T) {
6395

6496
// TestProvider_FetchOrders_WithMaxOrders tests max orders limit
6597
func TestProvider_FetchOrders_WithMaxOrders(t *testing.T) {
66-
t.Skip("Skipping until we have a mock walmart client")
98+
client := &fakeWalmartClient{summaries: []walmartclient.OrderSummary{
99+
{OrderID: "order-1", GroupID: "group-1", FulfillmentType: "IN_STORE"},
100+
{OrderID: "order-2", GroupID: "group-2", FulfillmentType: "DFS"},
101+
{OrderID: "order-3", GroupID: "group-3", FulfillmentType: "IN_STORE"},
102+
}}
103+
provider := newProvider(client, nil)
104+
105+
orders, err := provider.FetchOrders(context.Background(), providers.FetchOptions{
106+
StartDate: time.Now().AddDate(0, 0, -7),
107+
EndDate: time.Now(),
108+
MaxOrders: 2,
109+
IncludeDetails: true,
110+
})
67111

68-
// TODO: Create mock client that returns multiple orders
69-
// Then verify MaxOrders limits the result
112+
require.NoError(t, err)
113+
assert.Len(t, orders, 2)
114+
assert.Equal(t, []detailCall{
115+
{orderID: "order-1", groupID: "group-1", isInStore: true},
116+
{orderID: "order-2", groupID: "group-2", isInStore: false},
117+
}, client.detailCalls, "max orders must be applied before detail requests")
118+
}
119+
120+
func TestProvider_FetchOrders_SkipsActiveOrdersBeforeMax(t *testing.T) {
121+
client := &fakeWalmartClient{summaries: []walmartclient.OrderSummary{
122+
{OrderID: "active-order", GroupID: "active-group", IsActive: true},
123+
{OrderID: "completed-order", GroupID: "completed-group", IsActive: false},
124+
}}
125+
provider := newProvider(client, nil)
126+
127+
orders, err := provider.FetchOrders(context.Background(), providers.FetchOptions{
128+
StartDate: time.Now().AddDate(0, 0, -7),
129+
EndDate: time.Now(),
130+
MaxOrders: 1,
131+
})
132+
133+
require.NoError(t, err)
134+
assert.Len(t, orders, 1)
135+
assert.Equal(t, []detailCall{
136+
{orderID: "completed-order", groupID: "completed-group", isInStore: false},
137+
}, client.detailCalls, "active orders must not consume detail requests or the max-orders budget")
138+
}
139+
140+
func TestProvider_FetchOrders_PassesPurchaseHistoryGroupID(t *testing.T) {
141+
client := &fakeWalmartClient{summaries: []walmartclient.OrderSummary{
142+
{OrderID: "order-1", GroupID: "captured-group", FulfillmentType: "IN_STORE"},
143+
}}
144+
provider := newProvider(client, nil)
145+
146+
_, err := provider.FetchOrders(context.Background(), providers.FetchOptions{
147+
StartDate: time.Now().AddDate(0, 0, -7),
148+
EndDate: time.Now(),
149+
})
150+
151+
require.NoError(t, err)
152+
require.Len(t, client.detailCalls, 1)
153+
assert.Equal(t, "captured-group", client.detailCalls[0].groupID)
154+
}
155+
156+
func TestProvider_FetchOrders_StopsOnBotChallenge(t *testing.T) {
157+
challenge := fmt.Errorf("wrapped challenge: %w", walmartclient.ErrBotChallenge)
158+
client := &fakeWalmartClient{
159+
summaries: []walmartclient.OrderSummary{
160+
{OrderID: "order-1", GroupID: "group-1"},
161+
{OrderID: "order-2", GroupID: "group-2"},
162+
},
163+
detailErr: challenge,
164+
}
165+
provider := newProvider(client, nil)
166+
167+
orders, err := provider.FetchOrders(context.Background(), providers.FetchOptions{
168+
StartDate: time.Now().AddDate(0, 0, -7),
169+
EndDate: time.Now(),
170+
})
171+
172+
require.Error(t, err)
173+
assert.ErrorIs(t, err, walmartclient.ErrBotChallenge)
174+
assert.Empty(t, orders)
175+
assert.Len(t, client.detailCalls, 1, "bot challenge must stop further detail requests")
70176
}
71177

72178
func TestDedupeOrderSummariesByID(t *testing.T) {

0 commit comments

Comments
 (0)