@decocms/apps-vtex 7.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (109) hide show
  1. package/package.json +67 -0
  2. package/src/README.md +6 -0
  3. package/src/__tests__/client-segment-cookie.test.ts +255 -0
  4. package/src/__tests__/client-set-cookie-forward.test.ts +257 -0
  5. package/src/actions/address.ts +250 -0
  6. package/src/actions/analytics/sendEvent.ts +86 -0
  7. package/src/actions/auth.ts +333 -0
  8. package/src/actions/checkout.ts +522 -0
  9. package/src/actions/index.ts +11 -0
  10. package/src/actions/masterData.ts +168 -0
  11. package/src/actions/misc.ts +188 -0
  12. package/src/actions/newsletter.ts +105 -0
  13. package/src/actions/orders.ts +34 -0
  14. package/src/actions/profile.ts +201 -0
  15. package/src/actions/session.ts +85 -0
  16. package/src/actions/trigger.ts +43 -0
  17. package/src/actions/wishlist.ts +114 -0
  18. package/src/client.ts +667 -0
  19. package/src/commerceLoaders.ts +261 -0
  20. package/src/hooks/__tests__/createUseCart.test.ts +184 -0
  21. package/src/hooks/__tests__/createUseUser.test.ts +48 -0
  22. package/src/hooks/__tests__/createUseWishlist.test.ts +87 -0
  23. package/src/hooks/createUseCart.ts +360 -0
  24. package/src/hooks/createUseUser.ts +153 -0
  25. package/src/hooks/createUseWishlist.ts +242 -0
  26. package/src/hooks/index.ts +19 -0
  27. package/src/hooks/useAutocomplete.ts +83 -0
  28. package/src/hooks/useCart.ts +243 -0
  29. package/src/hooks/useUser.ts +78 -0
  30. package/src/hooks/useWishlist.ts +119 -0
  31. package/src/index.ts +17 -0
  32. package/src/invoke.ts +181 -0
  33. package/src/loaders/ProductDetailsPage.ts +1 -0
  34. package/src/loaders/ProductList.ts +1 -0
  35. package/src/loaders/ProductListingPage.ts +1 -0
  36. package/src/loaders/address.ts +115 -0
  37. package/src/loaders/autocomplete.ts +58 -0
  38. package/src/loaders/brands.ts +44 -0
  39. package/src/loaders/cart.ts +52 -0
  40. package/src/loaders/catalog.ts +163 -0
  41. package/src/loaders/collections.ts +55 -0
  42. package/src/loaders/index.ts +19 -0
  43. package/src/loaders/intelligentSearch/__tests__/productListingPage.test.ts +20 -0
  44. package/src/loaders/intelligentSearch/productDetailsPage.ts +95 -0
  45. package/src/loaders/intelligentSearch/productList.ts +154 -0
  46. package/src/loaders/intelligentSearch/productListingPage.ts +506 -0
  47. package/src/loaders/intelligentSearch/suggestions.ts +46 -0
  48. package/src/loaders/legacy/productDetailsPage.ts +1 -0
  49. package/src/loaders/legacy/productList.ts +1 -0
  50. package/src/loaders/legacy/relatedProductsLoader.ts +81 -0
  51. package/src/loaders/legacy.ts +635 -0
  52. package/src/loaders/logistics.ts +111 -0
  53. package/src/loaders/minicart.ts +78 -0
  54. package/src/loaders/navbar.ts +26 -0
  55. package/src/loaders/orders.ts +92 -0
  56. package/src/loaders/pageType.ts +68 -0
  57. package/src/loaders/payment.ts +97 -0
  58. package/src/loaders/productListFull.ts +159 -0
  59. package/src/loaders/profile.ts +138 -0
  60. package/src/loaders/promotion.ts +30 -0
  61. package/src/loaders/search.ts +124 -0
  62. package/src/loaders/session.ts +83 -0
  63. package/src/loaders/user.ts +87 -0
  64. package/src/loaders/wishlist.ts +89 -0
  65. package/src/loaders/wishlistProducts.ts +69 -0
  66. package/src/loaders/workflow/products.ts +64 -0
  67. package/src/loaders/workflow.ts +316 -0
  68. package/src/logo.png +0 -0
  69. package/src/middleware.ts +240 -0
  70. package/src/mod.ts +152 -0
  71. package/src/registry.ts +9 -0
  72. package/src/types.ts +248 -0
  73. package/src/utils/__tests__/cookieSanitizer.test.ts +162 -0
  74. package/src/utils/__tests__/fetch.test.ts +80 -0
  75. package/src/utils/__tests__/fetchCache.test.ts +112 -0
  76. package/src/utils/__tests__/instrumentedFetch.test.ts +158 -0
  77. package/src/utils/__tests__/intelligentSearch.test.ts +88 -0
  78. package/src/utils/__tests__/minicart.test.ts +184 -0
  79. package/src/utils/__tests__/operationRouter.test.ts +227 -0
  80. package/src/utils/__tests__/resourceRange.test.ts +32 -0
  81. package/src/utils/__tests__/sitemap.test.ts +185 -0
  82. package/src/utils/__tests__/slugify.test.ts +31 -0
  83. package/src/utils/__tests__/transform.test.ts +698 -0
  84. package/src/utils/accountLoaders.ts +203 -0
  85. package/src/utils/authHelpers.ts +85 -0
  86. package/src/utils/batch.ts +18 -0
  87. package/src/utils/cookieSanitizer.ts +173 -0
  88. package/src/utils/cookies.ts +167 -0
  89. package/src/utils/enrichment.ts +560 -0
  90. package/src/utils/fetch.ts +107 -0
  91. package/src/utils/fetchCache.ts +222 -0
  92. package/src/utils/index.ts +19 -0
  93. package/src/utils/instrumentedFetch.ts +78 -0
  94. package/src/utils/intelligentSearch.ts +96 -0
  95. package/src/utils/legacy.ts +139 -0
  96. package/src/utils/minicart.ts +139 -0
  97. package/src/utils/operationRouter.ts +139 -0
  98. package/src/utils/pickAndOmit.ts +25 -0
  99. package/src/utils/proxy.ts +435 -0
  100. package/src/utils/resourceRange.ts +10 -0
  101. package/src/utils/segment.ts +152 -0
  102. package/src/utils/similars.ts +37 -0
  103. package/src/utils/sitemap.ts +282 -0
  104. package/src/utils/slugCache.ts +28 -0
  105. package/src/utils/slugify.ts +13 -0
  106. package/src/utils/transform.ts +1509 -0
  107. package/src/utils/types.ts +1884 -0
  108. package/src/utils/vtexId.ts +138 -0
  109. package/tsconfig.json +7 -0
package/package.json ADDED
@@ -0,0 +1,67 @@
1
+ {
2
+ "name": "@decocms/apps-vtex",
3
+ "version": "7.2.0",
4
+ "type": "module",
5
+ "description": "Deco commerce app: VTEX integration",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "https://github.com/decocms/blocks.git",
9
+ "directory": "packages/apps-vtex"
10
+ },
11
+ "main": "./src/index.ts",
12
+ "exports": {
13
+ ".": "./src/index.ts",
14
+ "./commerceLoaders": "./src/commerceLoaders.ts",
15
+ "./mod": "./src/mod.ts",
16
+ "./client": "./src/client.ts",
17
+ "./types": "./src/types.ts",
18
+ "./actions": "./src/actions/index.ts",
19
+ "./actions/*": "./src/actions/*.ts",
20
+ "./actions/analytics/*": "./src/actions/analytics/*.ts",
21
+ "./loaders": "./src/loaders/index.ts",
22
+ "./loaders/*": "./src/loaders/*.ts",
23
+ "./utils": "./src/utils/index.ts",
24
+ "./utils/*": "./src/utils/*.ts",
25
+ "./loaders/intelligentSearch/*": "./src/loaders/intelligentSearch/*.ts",
26
+ "./loaders/legacy/*": "./src/loaders/legacy/*.ts",
27
+ "./loaders/workflow/*": "./src/loaders/workflow/*.ts",
28
+ "./inline-loaders/productDetailsPage": "./src/loaders/intelligentSearch/productDetailsPage.ts",
29
+ "./inline-loaders/productListingPage": "./src/loaders/intelligentSearch/productListingPage.ts",
30
+ "./inline-loaders/productList": "./src/loaders/productListFull.ts",
31
+ "./inline-loaders/productListShelf": "./src/loaders/intelligentSearch/productList.ts",
32
+ "./inline-loaders/relatedProducts": "./src/loaders/legacy/relatedProductsLoader.ts",
33
+ "./inline-loaders/suggestions": "./src/loaders/intelligentSearch/suggestions.ts",
34
+ "./inline-loaders/minicart": "./src/loaders/minicart.ts",
35
+ "./inline-loaders/workflowProducts": "./src/loaders/workflow/products.ts",
36
+ "./hooks": "./src/hooks/index.ts",
37
+ "./hooks/*": "./src/hooks/*.ts",
38
+ "./middleware": "./src/middleware.ts",
39
+ "./registry": "./src/registry.ts"
40
+ },
41
+ "scripts": {
42
+ "build": "tsc",
43
+ "test": "vitest run --root ../.. packages/apps-vtex/",
44
+ "typecheck": "tsc --noEmit",
45
+ "lint:unused": "knip"
46
+ },
47
+ "dependencies": {
48
+ "@decocms/blocks": "7.2.0",
49
+ "@decocms/apps-commerce": "7.2.0",
50
+ "@decocms/apps-website": "7.2.0",
51
+ "@decocms/tanstack": "7.2.0"
52
+ },
53
+ "peerDependencies": {
54
+ "react": "^19.0.0",
55
+ "react-dom": "^19.0.0"
56
+ },
57
+ "devDependencies": {
58
+ "@types/react": "^19.0.0",
59
+ "@types/react-dom": "^19.0.0",
60
+ "knip": "^5.86.0",
61
+ "typescript": "^5.9.0"
62
+ },
63
+ "publishConfig": {
64
+ "registry": "https://registry.npmjs.org",
65
+ "access": "public"
66
+ }
67
+ }
package/src/README.md ADDED
@@ -0,0 +1,6 @@
1
+ VTEX is a cloud-based e-commerce platform and digital commerce company that provides a comprehensive set of tools and services for businesses looking to establish and manage their online retail operations.
2
+
3
+ This app wrapps VTEX API into a comprehensive set of loaders/actions/workflows
4
+ empowering non technical users to interact and act upon their headless commerce.
5
+
6
+ If you want to use a custom search engine (Algolia, Typesense etc), you will need to fill the App Key & App Token properties. For these, follow this guide
@@ -0,0 +1,255 @@
1
+ /**
2
+ * Regression tests for the auto-forwarding of `vtex_segment` on outgoing
3
+ * VTEX API calls. Without this, Legacy Catalog endpoints don't see the
4
+ * region cookie and return OutOfStock for products only available
5
+ * through regional sellers — see vtex/client.ts:vtexFetchResponse.
6
+ */
7
+
8
+ import { RequestContext } from "@decocms/blocks/sdk/requestContext";
9
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
10
+ import {
11
+ configureVtex,
12
+ intelligentSearch,
13
+ setVtexFetch,
14
+ vtexCachedFetch,
15
+ vtexFetchResponse,
16
+ } from "../client";
17
+ import { clearFetchCache } from "../utils/fetchCache";
18
+
19
+ function mockResponse(body: unknown = {}, status = 200): Response {
20
+ return {
21
+ ok: status >= 200 && status < 300,
22
+ status,
23
+ statusText: status === 200 ? "OK" : "Error",
24
+ json: () => Promise.resolve(body),
25
+ } as Response;
26
+ }
27
+
28
+ /**
29
+ * Run `fn` inside a fake request context with the given cookie header.
30
+ *
31
+ * Two problems prevented a naive `RequestContext.run(new Request(...))`
32
+ * approach from working:
33
+ *
34
+ * 1. Under the Fetch spec a Request's headers are in the "request"
35
+ * guard mode, which silently drops forbidden request headers —
36
+ * including `cookie` — at construction time. Node 22 / undici
37
+ * enforces this strictly, so the cookie never reaches
38
+ * `request.headers.get("cookie")`.
39
+ * 2. `@decocms/start`'s `RequestContext` is backed by a
40
+ * `RequestStore` that defaults to a NOOP implementation. The
41
+ * ALS-backed store is installed by site code at worker boot, not
42
+ * in unit tests. So `RequestContext.run(req, fn)` calls
43
+ * `fn()` without any propagation, and `RequestContext.current`
44
+ * inside `fn` still returns `null` — production code under test
45
+ * never sees the test's cookie.
46
+ *
47
+ * Fix: build a fresh `Headers` object (which uses the "none" guard,
48
+ * so `set("cookie", ...)` works), wrap it in a minimal `Ctx`-shaped
49
+ * object, and override the `RequestContext.current` getter via
50
+ * `vi.spyOn`. The spy is restored after `fn` resolves to keep tests
51
+ * isolated. Nothing here depends on undici or ALS internals.
52
+ */
53
+ /**
54
+ * Read a header from an init in a shape-agnostic way. After the
55
+ * `mergeHeaders` refactor, `init.headers` is always a `Headers`
56
+ * instance — but the helper handles legacy shapes too so the tests
57
+ * stay robust if someone changes the merge implementation again.
58
+ */
59
+ function headerValue(init: RequestInit | undefined, name: string): string | undefined {
60
+ const headers = init?.headers;
61
+ if (!headers) return undefined;
62
+ if (headers instanceof Headers) return headers.get(name) ?? undefined;
63
+ if (Array.isArray(headers)) {
64
+ const found = headers.find(([k]) => k.toLowerCase() === name.toLowerCase());
65
+ return found?.[1];
66
+ }
67
+ const rec = headers as Record<string, string>;
68
+ const key = Object.keys(rec).find((k) => k.toLowerCase() === name.toLowerCase());
69
+ return key ? rec[key] : undefined;
70
+ }
71
+
72
+ function withRequest<T>(cookieHeader: string | null, fn: () => Promise<T>): Promise<T> {
73
+ const headers = new Headers();
74
+ if (cookieHeader) headers.set("cookie", cookieHeader);
75
+ const fakeCtx = {
76
+ request: { headers } as unknown as Request,
77
+ signal: new AbortController().signal,
78
+ responseHeaders: new Headers(),
79
+ bag: new Map(),
80
+ startedAt: Date.now(),
81
+ };
82
+ const spy = vi
83
+ .spyOn(RequestContext, "current", "get")
84
+ .mockReturnValue(fakeCtx as unknown as ReturnType<typeof Reflect.get>);
85
+ return fn().finally(() => spy.mockRestore());
86
+ }
87
+
88
+ describe("vtexFetchResponse — vtex_segment cookie forwarding", () => {
89
+ let lastInit: RequestInit | undefined;
90
+
91
+ beforeEach(() => {
92
+ configureVtex({ account: "testaccount" });
93
+ lastInit = undefined;
94
+ setVtexFetch(((_url: string, init?: RequestInit) => {
95
+ lastInit = init;
96
+ return Promise.resolve(mockResponse());
97
+ }) as typeof fetch);
98
+ });
99
+
100
+ afterEach(() => {
101
+ vi.restoreAllMocks();
102
+ });
103
+
104
+ it("forwards vtex_segment cookie when present and caller didn't set one", async () => {
105
+ await withRequest("vtex_segment=abc123; other=foo", async () => {
106
+ await vtexFetchResponse("/api/catalog_system/pub/products/x");
107
+ });
108
+ expect(headerValue(lastInit, "cookie")).toBe("vtex_segment=abc123");
109
+ });
110
+
111
+ it("does not overwrite a caller-supplied cookie header", async () => {
112
+ await withRequest("vtex_segment=abc123", async () => {
113
+ await vtexFetchResponse("/api/x", {
114
+ headers: { cookie: "custom=zzz" },
115
+ });
116
+ });
117
+ expect(headerValue(lastInit, "cookie")).toBe("custom=zzz");
118
+ });
119
+
120
+ it("does not overwrite a caller-supplied Cookie header (case-insensitive)", async () => {
121
+ await withRequest("vtex_segment=abc123", async () => {
122
+ await vtexFetchResponse("/api/x", {
123
+ headers: { Cookie: "custom=zzz" },
124
+ });
125
+ });
126
+ expect(headerValue(lastInit, "cookie")).toBe("custom=zzz");
127
+ });
128
+
129
+ it("is a no-op when there is no incoming cookie header", async () => {
130
+ await withRequest(null, async () => {
131
+ await vtexFetchResponse("/api/x");
132
+ });
133
+ expect(headerValue(lastInit, "cookie")).toBeUndefined();
134
+ });
135
+
136
+ it("is a no-op when there is a cookie header but no vtex_segment", async () => {
137
+ await withRequest("other=foo; another=bar", async () => {
138
+ await vtexFetchResponse("/api/x");
139
+ });
140
+ expect(headerValue(lastInit, "cookie")).toBeUndefined();
141
+ });
142
+
143
+ it("does not crash when called outside a RequestContext", async () => {
144
+ await vtexFetchResponse("/api/x");
145
+ expect(headerValue(lastInit, "cookie")).toBeUndefined();
146
+ });
147
+
148
+ it("preserves auth headers alongside the forwarded cookie", async () => {
149
+ configureVtex({ account: "testaccount", appKey: "k", appToken: "t" });
150
+ await withRequest("vtex_segment=abc123", async () => {
151
+ await vtexFetchResponse("/api/x");
152
+ });
153
+ expect(headerValue(lastInit, "X-VTEX-API-AppKey")).toBe("k");
154
+ expect(headerValue(lastInit, "X-VTEX-API-AppToken")).toBe("t");
155
+ expect(headerValue(lastInit, "cookie")).toBe("vtex_segment=abc123");
156
+ });
157
+
158
+ // Regression: when init.headers is a Headers object (as
159
+ // `createVtexCheckoutProxy` passes through `getVtexFetch()`), the
160
+ // existing cookie must survive verbatim. The naive
161
+ // `{ ...authHeaders, ...init?.headers }` spread collapses a Headers
162
+ // instance to `{}` (Headers has no own enumerable entries), which
163
+ // silently wipes the browser's full Cookie header — including the
164
+ // orderForm cookie any checkout flow depends on.
165
+ it("preserves an existing Cookie header when init.headers is a Headers instance", async () => {
166
+ await withRequest("vtex_segment=abc123", async () => {
167
+ const proxyInit: RequestInit = {
168
+ headers: new Headers({
169
+ cookie: "checkout.vtex.com=__ofid=xyz; vtex_segment=originalseg; foo=bar",
170
+ }),
171
+ };
172
+ await vtexFetchResponse("/api/checkout/pub/orderForm", proxyInit);
173
+ });
174
+ expect(headerValue(lastInit, "cookie")).toContain("checkout.vtex.com=__ofid=xyz");
175
+ });
176
+ });
177
+
178
+ // Module-level counter for cache-busting test URLs. `Date.now()` collides
179
+ // when two tests run within the same millisecond and the SWR cache in
180
+ // fetchWithCache short-circuits the second one — `_fetch` never runs and
181
+ // `lastInit` stays `undefined`. Per-test ids are deterministic and
182
+ // collision-free.
183
+ let testUrlCounter = 0;
184
+ const uniqPath = (prefix: string) => `${prefix}/${++testUrlCounter}`;
185
+
186
+ describe("vtexCachedFetch — vtex_segment cookie forwarding", () => {
187
+ let lastInit: RequestInit | undefined;
188
+
189
+ beforeEach(() => {
190
+ clearFetchCache();
191
+ configureVtex({ account: "testaccount" });
192
+ lastInit = undefined;
193
+ setVtexFetch(((_url: string, init?: RequestInit) => {
194
+ lastInit = init;
195
+ return Promise.resolve(mockResponse({ ok: true }));
196
+ }) as typeof fetch);
197
+ });
198
+
199
+ afterEach(() => {
200
+ vi.restoreAllMocks();
201
+ });
202
+
203
+ it("forwards vtex_segment on cached GETs", async () => {
204
+ await withRequest("vtex_segment=abc123", async () => {
205
+ await vtexCachedFetch(uniqPath("/api/catalog_system/pub/products"));
206
+ });
207
+ expect(headerValue(lastInit, "cookie")).toBe("vtex_segment=abc123");
208
+ });
209
+
210
+ it("does not overwrite a caller-supplied cookie header", async () => {
211
+ await withRequest("vtex_segment=abc123", async () => {
212
+ await vtexCachedFetch(uniqPath("/api/x"), {
213
+ headers: { cookie: "custom=zzz" },
214
+ });
215
+ });
216
+ expect(headerValue(lastInit, "cookie")).toBe("custom=zzz");
217
+ });
218
+ });
219
+
220
+ describe("intelligentSearch — vtex_segment cookie forwarding", () => {
221
+ let lastInit: RequestInit | undefined;
222
+
223
+ beforeEach(() => {
224
+ // Reset the SWR cache: otherwise the second test in this block
225
+ // can serve the first test's cached body without invoking the
226
+ // stub _fetch, leaving lastInit undefined.
227
+ clearFetchCache();
228
+ configureVtex({ account: "testaccount" });
229
+ lastInit = undefined;
230
+ setVtexFetch(((_url: string, init?: RequestInit) => {
231
+ lastInit = init;
232
+ return Promise.resolve(mockResponse({ products: [] }));
233
+ }) as typeof fetch);
234
+ });
235
+
236
+ afterEach(() => {
237
+ vi.restoreAllMocks();
238
+ });
239
+
240
+ it("forwards vtex_segment when caller didn't pass cookieHeader", async () => {
241
+ await withRequest("vtex_segment=abc123; other=foo", async () => {
242
+ await intelligentSearch(uniqPath("/product_search"));
243
+ });
244
+ expect(headerValue(lastInit, "cookie")).toBe("vtex_segment=abc123");
245
+ });
246
+
247
+ it("respects an explicit cookieHeader override", async () => {
248
+ await withRequest("vtex_segment=abc123", async () => {
249
+ await intelligentSearch(uniqPath("/product_search"), undefined, {
250
+ cookieHeader: "custom=zzz",
251
+ });
252
+ });
253
+ expect(headerValue(lastInit, "cookie")).toBe("custom=zzz");
254
+ });
255
+ });
@@ -0,0 +1,257 @@
1
+ /**
2
+ * Regression tests for the Set-Cookie propagation chain through
3
+ * `vtexFetchWithCookies`. Without this chain, VTEX's `checkout.vtex.com`
4
+ * and `CheckoutOrderFormOwnership` cookies never reach the browser via
5
+ * `createServerFn` actions, the storefront's local `__orderFormId`
6
+ * drifts away from VTEX's server-side orderForm, and the user lands
7
+ * on `/checkout` with an empty cart.
8
+ *
9
+ * Two failure modes covered here:
10
+ *
11
+ * (1) Inbound capture — VTEX `Set-Cookie` headers must be appended
12
+ * to `RequestContext.responseHeaders`, skipping the two IS
13
+ * cookies that the middleware owns (`vtex_is_session`,
14
+ * `vtex_is_anonymous`), and the `domain=` attribute must be
15
+ * stripped so the browser scopes the cookie to the storefront.
16
+ *
17
+ * (2) Outbound merge — when the caller passes `init.headers` as a
18
+ * `Headers` instance (the `createVtexCheckoutProxy` factory does
19
+ * this through `getVtexFetch()`), spreading it as a plain object
20
+ * collapses to `{}` and silently wipes every other header the
21
+ * caller set. The Headers-aware merge in `vtexFetchWithCookies`
22
+ * keeps the bug from sneaking back in.
23
+ */
24
+
25
+ import { RequestContext } from "@decocms/blocks/sdk/requestContext";
26
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
27
+ import { configureVtex, setVtexFetch, vtexFetchWithCookies } from "../client";
28
+
29
+ function mockResponse(opts?: { body?: unknown; status?: number; setCookies?: string[] }): Response {
30
+ const status = opts?.status ?? 200;
31
+ const headers = new Headers();
32
+ for (const c of opts?.setCookies ?? []) headers.append("set-cookie", c);
33
+ return {
34
+ ok: status >= 200 && status < 300,
35
+ status,
36
+ statusText: status === 200 ? "OK" : "Error",
37
+ headers,
38
+ json: () => Promise.resolve(opts?.body ?? {}),
39
+ } as Response;
40
+ }
41
+
42
+ function headerValue(init: RequestInit | undefined, name: string): string | undefined {
43
+ const headers = init?.headers;
44
+ if (!headers) return undefined;
45
+ if (headers instanceof Headers) return headers.get(name) ?? undefined;
46
+ if (Array.isArray(headers)) {
47
+ const found = headers.find(([k]) => k.toLowerCase() === name.toLowerCase());
48
+ return found?.[1];
49
+ }
50
+ const rec = headers as Record<string, string>;
51
+ const key = Object.keys(rec).find((k) => k.toLowerCase() === name.toLowerCase());
52
+ return key ? rec[key] : undefined;
53
+ }
54
+
55
+ function withRequest<T>(
56
+ cookieHeader: string | null,
57
+ fn: (ctx: { responseHeaders: Headers }) => Promise<T>,
58
+ requestUrl = "https://store.example.com/api/checkout/pub/orderForm",
59
+ ): Promise<T> {
60
+ const reqHeaders = new Headers();
61
+ if (cookieHeader) reqHeaders.set("cookie", cookieHeader);
62
+ const responseHeaders = new Headers();
63
+ const fakeCtx = {
64
+ request: { headers: reqHeaders, url: requestUrl } as unknown as Request,
65
+ signal: new AbortController().signal,
66
+ responseHeaders,
67
+ bag: new Map(),
68
+ startedAt: Date.now(),
69
+ };
70
+ const spy = vi
71
+ .spyOn(RequestContext, "current", "get")
72
+ .mockReturnValue(fakeCtx as unknown as ReturnType<typeof Reflect.get>);
73
+ return fn({ responseHeaders }).finally(() => spy.mockRestore());
74
+ }
75
+
76
+ describe("vtexFetchWithCookies — inbound Set-Cookie capture", () => {
77
+ let lastInit: RequestInit | undefined;
78
+
79
+ beforeEach(() => {
80
+ configureVtex({ account: "testaccount" });
81
+ lastInit = undefined;
82
+ });
83
+
84
+ afterEach(() => {
85
+ vi.restoreAllMocks();
86
+ });
87
+
88
+ it("captures upstream Set-Cookie into RequestContext.responseHeaders", async () => {
89
+ setVtexFetch(((_url: string, init?: RequestInit) => {
90
+ lastInit = init;
91
+ return Promise.resolve(
92
+ mockResponse({
93
+ setCookies: [
94
+ "checkout.vtex.com=__ofid=abc123; Path=/; HttpOnly; Secure",
95
+ "CheckoutOrderFormOwnership=def456; Path=/; HttpOnly",
96
+ ],
97
+ }),
98
+ );
99
+ }) as typeof fetch);
100
+
101
+ const captured = await withRequest("vtex_segment=seg1", async ({ responseHeaders }) => {
102
+ await vtexFetchWithCookies("/api/checkout/pub/orderForm");
103
+ return responseHeaders.getSetCookie();
104
+ });
105
+
106
+ expect(captured).toHaveLength(2);
107
+ expect(captured.some((c) => c.startsWith("checkout.vtex.com="))).toBe(true);
108
+ expect(captured.some((c) => c.startsWith("CheckoutOrderFormOwnership="))).toBe(true);
109
+ });
110
+
111
+ // The cart server-fn cookie MUST land at the same scope as the checkout
112
+ // proxy (domain-scoped to the storefront host) and native VTEX
113
+ // (`domain=<host>`). Stripping the Domain (host-only) creates a second,
114
+ // distinct cookie that drifts from the proxy's and causes the
115
+ // nondeterministic empty-cart bug. So we rewrite Domain, not strip it.
116
+ it("rewrites the Domain= attribute to the storefront host (matches the proxy)", async () => {
117
+ setVtexFetch((() =>
118
+ Promise.resolve(
119
+ mockResponse({
120
+ setCookies: [
121
+ "checkout.vtex.com=__ofid=abc; Domain=.vtexcommercestable.com.br; Path=/; HttpOnly",
122
+ ],
123
+ }),
124
+ )) as typeof fetch);
125
+
126
+ const captured = await withRequest(
127
+ "foo=bar",
128
+ async ({ responseHeaders }) => {
129
+ await vtexFetchWithCookies("/api/checkout/pub/orderForm");
130
+ return responseHeaders.getSetCookie();
131
+ },
132
+ "https://www.casaevideo.com.br/api/checkout/pub/orderForm",
133
+ );
134
+
135
+ // domain-scoped to the storefront host, NOT the original VTEX domain
136
+ expect(captured[0]).toMatch(/Domain=www\.casaevideo\.com\.br/);
137
+ expect(captured[0]).not.toMatch(/vtexcommercestable/i);
138
+ expect(captured[0]).toContain("checkout.vtex.com=__ofid=abc");
139
+ });
140
+
141
+ it("rewrites only the Domain attribute, not a domain= substring in the cookie value", async () => {
142
+ setVtexFetch((() =>
143
+ Promise.resolve(
144
+ mockResponse({
145
+ // Pathological value embedding `domain=` before the first `;`.
146
+ setCookies: [
147
+ "checkout.vtex.com=__ofid=domain=keep; Domain=.vtexcommercestable.com.br; Path=/",
148
+ ],
149
+ }),
150
+ )) as typeof fetch);
151
+
152
+ const captured = await withRequest(
153
+ "foo=bar",
154
+ async ({ responseHeaders }) => {
155
+ await vtexFetchWithCookies("/api/checkout/pub/orderForm");
156
+ return responseHeaders.getSetCookie();
157
+ },
158
+ "https://www.casaevideo.com.br/api/checkout/pub/orderForm",
159
+ );
160
+
161
+ // the value's `domain=keep` is untouched; the attribute is rewritten
162
+ expect(captured[0]).toContain("__ofid=domain=keep");
163
+ expect(captured[0]).toMatch(/;\s*Domain=www\.casaevideo\.com\.br/);
164
+ expect(captured[0]).not.toMatch(/vtexcommercestable/i);
165
+ });
166
+
167
+ it("falls back to stripping Domain when there is no request scope (module init)", async () => {
168
+ setVtexFetch((() =>
169
+ Promise.resolve(
170
+ mockResponse({
171
+ setCookies: ["checkout.vtex.com=__ofid=abc; Domain=.vtexcommercestable.com.br; Path=/"],
172
+ }),
173
+ )) as typeof fetch);
174
+ // No withRequest wrapper → RequestContext.current is null.
175
+ await expect(vtexFetchWithCookies("/api/checkout/pub/orderForm")).resolves.toBeDefined();
176
+ });
177
+
178
+ it("skips Intelligent Search cookies (managed by middleware, not actions)", async () => {
179
+ setVtexFetch((() =>
180
+ Promise.resolve(
181
+ mockResponse({
182
+ setCookies: [
183
+ "checkout.vtex.com=__ofid=abc; Path=/",
184
+ "vtex_is_session=ignore-me; Path=/",
185
+ "vtex_is_anonymous=ignore-me-too; Path=/",
186
+ "CheckoutOrderFormOwnership=def; Path=/",
187
+ ],
188
+ }),
189
+ )) as typeof fetch);
190
+
191
+ const captured = await withRequest("foo=bar", async ({ responseHeaders }) => {
192
+ await vtexFetchWithCookies("/api/checkout/pub/orderForm");
193
+ return responseHeaders.getSetCookie();
194
+ });
195
+
196
+ expect(captured.some((c) => c.startsWith("checkout.vtex.com="))).toBe(true);
197
+ expect(captured.some((c) => c.startsWith("CheckoutOrderFormOwnership="))).toBe(true);
198
+ expect(captured.some((c) => c.startsWith("vtex_is_session="))).toBe(false);
199
+ expect(captured.some((c) => c.startsWith("vtex_is_anonymous="))).toBe(false);
200
+ });
201
+
202
+ it("does not crash when called outside a RequestContext", async () => {
203
+ setVtexFetch((() =>
204
+ Promise.resolve(
205
+ mockResponse({
206
+ setCookies: ["checkout.vtex.com=__ofid=abc; Path=/"],
207
+ }),
208
+ )) as typeof fetch);
209
+ await expect(vtexFetchWithCookies("/api/checkout/pub/orderForm")).resolves.toBeDefined();
210
+ });
211
+
212
+ // Regression for Hole B: when init.headers is a Headers instance,
213
+ // the previous Record-cast + spread collapsed it to `{}`, wiping
214
+ // every other header (auth, content-type) the caller set. After
215
+ // the fix, the Headers-aware merge preserves all of them.
216
+ it("preserves other caller headers when init.headers is a Headers instance", async () => {
217
+ setVtexFetch(((_url: string, init?: RequestInit) => {
218
+ lastInit = init;
219
+ return Promise.resolve(mockResponse());
220
+ }) as typeof fetch);
221
+
222
+ await withRequest("vtex_segment=abc; foo=bar", async () => {
223
+ await vtexFetchWithCookies("/api/checkout/pub/orderForm", {
224
+ headers: new Headers({
225
+ "X-Custom-Trace": "trace-id",
226
+ "X-VTEX-Operation": "test-op",
227
+ }),
228
+ });
229
+ });
230
+
231
+ expect(headerValue(lastInit, "x-custom-trace")).toBe("trace-id");
232
+ expect(headerValue(lastInit, "x-vtex-operation")).toBe("test-op");
233
+ // Caller didn't pass a Cookie — auto-injection picks up the
234
+ // request's cookie and forwards it without dropping the
235
+ // other headers.
236
+ expect(headerValue(lastInit, "cookie")).toBeDefined();
237
+ });
238
+
239
+ it("preserves an existing Cookie header passed via Headers and sanitises it in place", async () => {
240
+ setVtexFetch(((_url: string, init?: RequestInit) => {
241
+ lastInit = init;
242
+ return Promise.resolve(mockResponse());
243
+ }) as typeof fetch);
244
+
245
+ await withRequest("vtex_segment=abc", async () => {
246
+ await vtexFetchWithCookies("/api/checkout/pub/orderForm", {
247
+ headers: new Headers({
248
+ "X-Custom": "keep-me",
249
+ cookie: "checkout.vtex.com=__ofid=xyz; vtex_segment=mine",
250
+ }),
251
+ });
252
+ });
253
+
254
+ expect(headerValue(lastInit, "x-custom")).toBe("keep-me");
255
+ expect(headerValue(lastInit, "cookie")).toContain("checkout.vtex.com=__ofid=xyz");
256
+ });
257
+ });