@decocms/apps-vtex 8.1.0-next.0 → 8.1.0-next.1

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 (139) hide show
  1. package/package.json +7 -52
  2. package/src/index.ts +10 -28
  3. package/src/utils/constants.ts +2 -56
  4. package/src/utils/cookieSanitizer.ts +59 -59
  5. package/src/utils/operationRouter.ts +75 -65
  6. package/src/utils/types.ts +709 -1557
  7. package/src/vtexClient.ts +354 -343
  8. package/src/README.md +0 -6
  9. package/src/__conformance__/upstream.test.ts +0 -158
  10. package/src/__tests__/client-segment-cookie.test.ts +0 -255
  11. package/src/__tests__/client-set-cookie-forward.test.ts +0 -257
  12. package/src/__tests__/schemas.test.ts +0 -71
  13. package/src/actions/address.ts +0 -250
  14. package/src/actions/analytics/sendEvent.ts +0 -86
  15. package/src/actions/auth.ts +0 -333
  16. package/src/actions/checkout.ts +0 -659
  17. package/src/actions/index.ts +0 -11
  18. package/src/actions/masterData.ts +0 -168
  19. package/src/actions/misc.ts +0 -188
  20. package/src/actions/newsletter.ts +0 -105
  21. package/src/actions/orders.ts +0 -34
  22. package/src/actions/profile.ts +0 -201
  23. package/src/actions/session.ts +0 -85
  24. package/src/actions/trigger.ts +0 -43
  25. package/src/actions/wishlist.ts +0 -114
  26. package/src/client.ts +0 -724
  27. package/src/commerceLoaders.ts +0 -267
  28. package/src/hooks/__tests__/createCart.render.test.ts +0 -114
  29. package/src/hooks/__tests__/createCart.test.ts +0 -92
  30. package/src/hooks/__tests__/createUseCart.test.ts +0 -184
  31. package/src/hooks/__tests__/createUseUser.test.ts +0 -48
  32. package/src/hooks/__tests__/createUseWishlist.test.ts +0 -87
  33. package/src/hooks/cartQuery.ts +0 -204
  34. package/src/hooks/createCart.ts +0 -427
  35. package/src/hooks/createUseCart.ts +0 -360
  36. package/src/hooks/createUseUser.ts +0 -153
  37. package/src/hooks/createUseWishlist.ts +0 -242
  38. package/src/hooks/index.ts +0 -25
  39. package/src/hooks/useAutocomplete.ts +0 -83
  40. package/src/hooks/useCart.ts +0 -243
  41. package/src/hooks/useUser.ts +0 -78
  42. package/src/hooks/useWishlist.ts +0 -119
  43. package/src/invoke.ts +0 -235
  44. package/src/loaders/ProductDetailsPage.ts +0 -1
  45. package/src/loaders/ProductList.ts +0 -1
  46. package/src/loaders/ProductListingPage.ts +0 -1
  47. package/src/loaders/__tests__/legacyProductList.test.ts +0 -157
  48. package/src/loaders/address.ts +0 -115
  49. package/src/loaders/autocomplete.ts +0 -56
  50. package/src/loaders/brands.ts +0 -44
  51. package/src/loaders/cart/attachments.ts +0 -53
  52. package/src/loaders/cart/full.ts +0 -67
  53. package/src/loaders/cart/gifts.ts +0 -43
  54. package/src/loaders/cart/orderFormId.ts +0 -31
  55. package/src/loaders/cart/shipping.test.ts +0 -125
  56. package/src/loaders/cart/shipping.ts +0 -136
  57. package/src/loaders/cart/summary.ts +0 -36
  58. package/src/loaders/cart.ts +0 -52
  59. package/src/loaders/catalog.ts +0 -163
  60. package/src/loaders/collections.ts +0 -55
  61. package/src/loaders/index.ts +0 -20
  62. package/src/loaders/intelligentSearch/__tests__/productListingPage.test.ts +0 -51
  63. package/src/loaders/intelligentSearch/productDetailsPage.ts +0 -111
  64. package/src/loaders/intelligentSearch/productList.ts +0 -170
  65. package/src/loaders/intelligentSearch/productListingPage.ts +0 -525
  66. package/src/loaders/intelligentSearch/suggestions.ts +0 -44
  67. package/src/loaders/legacy/productDetailsPage.ts +0 -1
  68. package/src/loaders/legacy/productList.ts +0 -1
  69. package/src/loaders/legacy/relatedProductsLoader.ts +0 -79
  70. package/src/loaders/legacy.ts +0 -710
  71. package/src/loaders/logistics.ts +0 -111
  72. package/src/loaders/minicart.ts +0 -78
  73. package/src/loaders/navbar.ts +0 -26
  74. package/src/loaders/orders.ts +0 -92
  75. package/src/loaders/pageType.ts +0 -68
  76. package/src/loaders/paths/PDPDefaultPath.ts +0 -51
  77. package/src/loaders/payment.ts +0 -97
  78. package/src/loaders/productListFull.ts +0 -157
  79. package/src/loaders/profile.ts +0 -138
  80. package/src/loaders/promotion.ts +0 -30
  81. package/src/loaders/search.ts +0 -124
  82. package/src/loaders/session.ts +0 -83
  83. package/src/loaders/user.ts +0 -87
  84. package/src/loaders/wishlist.ts +0 -89
  85. package/src/loaders/wishlistProducts.ts +0 -69
  86. package/src/loaders/workflow/products.ts +0 -62
  87. package/src/loaders/workflow.ts +0 -316
  88. package/src/logo.png +0 -0
  89. package/src/manifest.gen.ts +0 -89
  90. package/src/middleware.cacheHeaders.test.ts +0 -44
  91. package/src/middleware.ts +0 -240
  92. package/src/mod.ts +0 -173
  93. package/src/registry.ts +0 -9
  94. package/src/schemas.gen.ts +0 -3191
  95. package/src/schemas.ts +0 -30
  96. package/src/types.ts +0 -248
  97. package/src/utils/__tests__/cartProjection.test.ts +0 -118
  98. package/src/utils/__tests__/cookieSanitizer.test.ts +0 -162
  99. package/src/utils/__tests__/fetch.test.ts +0 -80
  100. package/src/utils/__tests__/fetchCache.test.ts +0 -155
  101. package/src/utils/__tests__/instrumentedFetch.test.ts +0 -158
  102. package/src/utils/__tests__/intelligentSearch.test.ts +0 -88
  103. package/src/utils/__tests__/minicart.test.ts +0 -184
  104. package/src/utils/__tests__/operationRouter.test.ts +0 -227
  105. package/src/utils/__tests__/resilience.test.ts +0 -205
  106. package/src/utils/__tests__/resourceRange.test.ts +0 -32
  107. package/src/utils/__tests__/simulationCache.test.ts +0 -40
  108. package/src/utils/__tests__/sitemap.test.ts +0 -185
  109. package/src/utils/__tests__/slugify.test.ts +0 -31
  110. package/src/utils/__tests__/storefrontBaseUrl.test.ts +0 -61
  111. package/src/utils/__tests__/transform.test.ts +0 -1111
  112. package/src/utils/accountLoaders.ts +0 -203
  113. package/src/utils/authHelpers.ts +0 -85
  114. package/src/utils/batch.ts +0 -18
  115. package/src/utils/buildOfferShelf.test.ts +0 -59
  116. package/src/utils/cartProjection.ts +0 -96
  117. package/src/utils/cookies.ts +0 -167
  118. package/src/utils/enrichment.ts +0 -556
  119. package/src/utils/fetch.ts +0 -111
  120. package/src/utils/fetchCache.ts +0 -55
  121. package/src/utils/index.ts +0 -19
  122. package/src/utils/instrumentedFetch.ts +0 -98
  123. package/src/utils/intelligentSearch.ts +0 -96
  124. package/src/utils/legacy.ts +0 -139
  125. package/src/utils/minicart.ts +0 -139
  126. package/src/utils/pickAndOmit.ts +0 -25
  127. package/src/utils/proxy.ts +0 -495
  128. package/src/utils/resilience.ts +0 -352
  129. package/src/utils/resourceRange.ts +0 -10
  130. package/src/utils/segment.ts +0 -152
  131. package/src/utils/similars.ts +0 -35
  132. package/src/utils/simulationCache.ts +0 -73
  133. package/src/utils/sitemap.ts +0 -283
  134. package/src/utils/slugCache.ts +0 -28
  135. package/src/utils/slugify.ts +0 -13
  136. package/src/utils/transform.ts +0 -1691
  137. package/src/utils/vtexId.ts +0 -151
  138. package/src/vtexClient.test.ts +0 -204
  139. package/tsconfig.json +0 -7
package/src/README.md DELETED
@@ -1,6 +0,0 @@
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
@@ -1,158 +0,0 @@
1
- // @vitest-environment node
2
- /**
3
- * Conformance: upstream-clients.mdx (Calling APIs) against the next-major
4
- * client packages. Only the v8 client modules are checked; the v7 app
5
- * surfaces that still ship beside them are out of scope until v7 is removed.
6
- */
7
- import fs from "node:fs";
8
- import path from "node:path";
9
- import { fileURLToPath } from "node:url";
10
- import { createAlgoliaClient } from "@decocms/apps-algolia";
11
- import { createVtexClient } from "@decocms/apps-vtex";
12
- import { describe, expect, it, vi } from "vitest";
13
-
14
- const PACKAGES = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../..");
15
-
16
- /** The next-major client module of each platform package. */
17
- const V8_CLIENTS: Record<string, string[]> = {
18
- "apps-vtex": ["src/vtexClient.ts"],
19
- "apps-shopify": ["src/v8/client.ts", "src/v8/graphqlOperationName.ts"],
20
- "apps-wake": ["src/wakeClient.ts"],
21
- "apps-magento": ["src/magentoClient.ts"],
22
- "apps-algolia": ["src/index.ts"],
23
- "apps-resend": ["src/emails.ts"],
24
- "apps-sfmc-personalization": ["src/index.ts"],
25
- };
26
-
27
- /** The source without comments, so doc examples (`process.env.X!` in a JSDoc) don't count. */
28
- function code(pkg: string, file: string): string {
29
- return fs
30
- .readFileSync(path.join(PACKAGES, pkg, file), "utf8")
31
- .replace(/\/\*[\s\S]*?\*\//g, "")
32
- .replace(/(^|[^:"'`])\/\/.*$/gm, "$1");
33
- }
34
-
35
- describe("what a client is (upstream-clients.mdx)", () => {
36
- it("up-01: VTEX, Shopify, Wake, Magento, Algolia, Resend (and others) ship a client built on createInstrumentedFetch", () => {
37
- for (const [pkg, files] of Object.entries(V8_CLIENTS)) {
38
- expect(fs.existsSync(path.join(PACKAGES, pkg, "package.json"))).toBe(true);
39
- expect([pkg, code(pkg, files[0]!)]).toEqual([
40
- pkg,
41
- expect.stringMatching(/import \{[^}]*createInstrumentedFetch[^}]*\} from "@decocms\/blocks\/fetch"/),
42
- ]);
43
- }
44
- });
45
-
46
- it("up-02: clients take settings as arguments: no environment reads inside a client", () => {
47
- for (const [pkg, files] of Object.entries(V8_CLIENTS)) {
48
- for (const file of files) {
49
- expect([pkg, file, /process\.env|import\.meta\.env|Deno\.env/.test(code(pkg, file))]).toEqual(
50
- [pkg, file, false],
51
- );
52
- }
53
- }
54
- });
55
-
56
- it("up-03: no React hooks, client components or shared-commerce converters in a client", () => {
57
- for (const [pkg, files] of Object.entries(V8_CLIENTS)) {
58
- for (const file of files) {
59
- const text = code(pkg, file);
60
- const found = [
61
- /["']use client["']/,
62
- /from ["']react["']/,
63
- /\buse(Cart|User|Wishlist)\b/,
64
- /@decocms\/apps-commerce/,
65
- /from ["']\.\/(loaders|actions|hooks)\//,
66
- ].filter((pattern) => pattern.test(text));
67
- expect([pkg, file, found]).toEqual([pkg, file, []]);
68
- }
69
- }
70
- });
71
-
72
- it("up-05: the Salesforce client is @decocms/apps-sfmc-personalization; @decocms/apps-salesforce is gone", () => {
73
- const pkg = JSON.parse(
74
- fs.readFileSync(path.join(PACKAGES, "apps-sfmc-personalization/package.json"), "utf8"),
75
- );
76
- expect(pkg.name).toBe("@decocms/apps-sfmc-personalization");
77
- const names = fs
78
- .readdirSync(PACKAGES)
79
- .filter((dir) => fs.existsSync(path.join(PACKAGES, dir, "package.json")))
80
- .map((dir) => JSON.parse(fs.readFileSync(path.join(PACKAGES, dir, "package.json"), "utf8")).name);
81
- expect(names).not.toContain("@decocms/apps-salesforce");
82
- });
83
-
84
- it("up-07: clients don't cache upstream responses", () => {
85
- for (const [pkg, files] of Object.entries(V8_CLIENTS)) {
86
- for (const file of files) {
87
- expect([
88
- pkg,
89
- file,
90
- /createFetchCache|fetchWithCache|fetchCache|caches\.default|caches\.open|cachedLoader/.test(
91
- code(pkg, file),
92
- ),
93
- ]).toEqual([pkg, file, false]);
94
- }
95
- }
96
- });
97
- });
98
-
99
- describe("call a client (upstream-clients.mdx)", () => {
100
- it("up-06: createVtexClient({ account, appKey, appToken }) and vtex.search.products({ query, count })", async () => {
101
- const fetch = vi.fn(
102
- async (_input: string | URL | Request, _init?: RequestInit) =>
103
- new Response(JSON.stringify({ products: [] }), { status: 200 }),
104
- );
105
- const vtex = createVtexClient({
106
- account: "mystore",
107
- appKey: "key",
108
- appToken: "token",
109
- fetch: fetch as unknown as typeof globalThis.fetch,
110
- });
111
- const products = await vtex.search.products({ query: "linen shirt", count: 12 });
112
- expect(products).toEqual({ products: [] });
113
- const url = new URL(String(fetch.mock.calls[0]![0]));
114
- expect(url.host).toBe("mystore.vtexcommercestable.com.br");
115
- expect(url.pathname).toContain("product_search");
116
- expect(url.searchParams.get("query")).toBe("linen shirt");
117
- expect(url.searchParams.get("count")).toBe("12");
118
- });
119
- });
120
-
121
- describe("retries and failures (upstream-clients.mdx)", () => {
122
- it("up-08: the VTEX client retries by default", async () => {
123
- const statuses = [503, 200];
124
- const fetch = vi.fn(
125
- async () => new Response("{}", { status: statuses.shift() ?? 200 }),
126
- );
127
- const vtex = createVtexClient({
128
- account: "mystore",
129
- fetch: fetch as unknown as typeof globalThis.fetch,
130
- });
131
- await vtex.search.products({ query: "shirt" });
132
- expect(fetch).toHaveBeenCalledTimes(2);
133
- });
134
-
135
- it("up-08: the VTEX client's circuit breaker fails fast after repeated failures", async () => {
136
- const fetch = vi.fn(async () => new Response("{}", { status: 503 }));
137
- const vtex = createVtexClient({
138
- account: "mystore",
139
- fetch: fetch as unknown as typeof globalThis.fetch,
140
- });
141
- for (let i = 0; i < 5; i++) await expect(vtex.search.products({ query: "shirt" })).rejects.toThrow();
142
- const before = fetch.mock.calls.length;
143
- await expect(vtex.search.products({ query: "shirt" })).rejects.toThrow();
144
- expect(fetch.mock.calls.length).toBe(before);
145
- }, 20_000);
146
-
147
- it("up-08: other clients leave retries off (one attempt on a 503)", async () => {
148
- const fetch = vi.fn(async () => new Response("{}", { status: 503 }));
149
- const algolia = createAlgoliaClient(
150
- { applicationId: "APP123", apiKey: "k" },
151
- { fetch: fetch as unknown as typeof globalThis.fetch },
152
- );
153
- await expect(algolia.search([{ indexName: "products", query: "shirt" }] as never)).rejects.toThrow(
154
- "algolia search failed with HTTP 503",
155
- );
156
- expect(fetch).toHaveBeenCalledTimes(1);
157
- });
158
- });
@@ -1,255 +0,0 @@
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
- });
@@ -1,257 +0,0 @@
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.acme.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\.acme\.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.acme.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\.acme\.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
- });