@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.
- package/package.json +67 -0
- package/src/README.md +6 -0
- package/src/__tests__/client-segment-cookie.test.ts +255 -0
- package/src/__tests__/client-set-cookie-forward.test.ts +257 -0
- package/src/actions/address.ts +250 -0
- package/src/actions/analytics/sendEvent.ts +86 -0
- package/src/actions/auth.ts +333 -0
- package/src/actions/checkout.ts +522 -0
- package/src/actions/index.ts +11 -0
- package/src/actions/masterData.ts +168 -0
- package/src/actions/misc.ts +188 -0
- package/src/actions/newsletter.ts +105 -0
- package/src/actions/orders.ts +34 -0
- package/src/actions/profile.ts +201 -0
- package/src/actions/session.ts +85 -0
- package/src/actions/trigger.ts +43 -0
- package/src/actions/wishlist.ts +114 -0
- package/src/client.ts +667 -0
- package/src/commerceLoaders.ts +261 -0
- package/src/hooks/__tests__/createUseCart.test.ts +184 -0
- package/src/hooks/__tests__/createUseUser.test.ts +48 -0
- package/src/hooks/__tests__/createUseWishlist.test.ts +87 -0
- package/src/hooks/createUseCart.ts +360 -0
- package/src/hooks/createUseUser.ts +153 -0
- package/src/hooks/createUseWishlist.ts +242 -0
- package/src/hooks/index.ts +19 -0
- package/src/hooks/useAutocomplete.ts +83 -0
- package/src/hooks/useCart.ts +243 -0
- package/src/hooks/useUser.ts +78 -0
- package/src/hooks/useWishlist.ts +119 -0
- package/src/index.ts +17 -0
- package/src/invoke.ts +181 -0
- package/src/loaders/ProductDetailsPage.ts +1 -0
- package/src/loaders/ProductList.ts +1 -0
- package/src/loaders/ProductListingPage.ts +1 -0
- package/src/loaders/address.ts +115 -0
- package/src/loaders/autocomplete.ts +58 -0
- package/src/loaders/brands.ts +44 -0
- package/src/loaders/cart.ts +52 -0
- package/src/loaders/catalog.ts +163 -0
- package/src/loaders/collections.ts +55 -0
- package/src/loaders/index.ts +19 -0
- package/src/loaders/intelligentSearch/__tests__/productListingPage.test.ts +20 -0
- package/src/loaders/intelligentSearch/productDetailsPage.ts +95 -0
- package/src/loaders/intelligentSearch/productList.ts +154 -0
- package/src/loaders/intelligentSearch/productListingPage.ts +506 -0
- package/src/loaders/intelligentSearch/suggestions.ts +46 -0
- package/src/loaders/legacy/productDetailsPage.ts +1 -0
- package/src/loaders/legacy/productList.ts +1 -0
- package/src/loaders/legacy/relatedProductsLoader.ts +81 -0
- package/src/loaders/legacy.ts +635 -0
- package/src/loaders/logistics.ts +111 -0
- package/src/loaders/minicart.ts +78 -0
- package/src/loaders/navbar.ts +26 -0
- package/src/loaders/orders.ts +92 -0
- package/src/loaders/pageType.ts +68 -0
- package/src/loaders/payment.ts +97 -0
- package/src/loaders/productListFull.ts +159 -0
- package/src/loaders/profile.ts +138 -0
- package/src/loaders/promotion.ts +30 -0
- package/src/loaders/search.ts +124 -0
- package/src/loaders/session.ts +83 -0
- package/src/loaders/user.ts +87 -0
- package/src/loaders/wishlist.ts +89 -0
- package/src/loaders/wishlistProducts.ts +69 -0
- package/src/loaders/workflow/products.ts +64 -0
- package/src/loaders/workflow.ts +316 -0
- package/src/logo.png +0 -0
- package/src/middleware.ts +240 -0
- package/src/mod.ts +152 -0
- package/src/registry.ts +9 -0
- package/src/types.ts +248 -0
- package/src/utils/__tests__/cookieSanitizer.test.ts +162 -0
- package/src/utils/__tests__/fetch.test.ts +80 -0
- package/src/utils/__tests__/fetchCache.test.ts +112 -0
- package/src/utils/__tests__/instrumentedFetch.test.ts +158 -0
- package/src/utils/__tests__/intelligentSearch.test.ts +88 -0
- package/src/utils/__tests__/minicart.test.ts +184 -0
- package/src/utils/__tests__/operationRouter.test.ts +227 -0
- package/src/utils/__tests__/resourceRange.test.ts +32 -0
- package/src/utils/__tests__/sitemap.test.ts +185 -0
- package/src/utils/__tests__/slugify.test.ts +31 -0
- package/src/utils/__tests__/transform.test.ts +698 -0
- package/src/utils/accountLoaders.ts +203 -0
- package/src/utils/authHelpers.ts +85 -0
- package/src/utils/batch.ts +18 -0
- package/src/utils/cookieSanitizer.ts +173 -0
- package/src/utils/cookies.ts +167 -0
- package/src/utils/enrichment.ts +560 -0
- package/src/utils/fetch.ts +107 -0
- package/src/utils/fetchCache.ts +222 -0
- package/src/utils/index.ts +19 -0
- package/src/utils/instrumentedFetch.ts +78 -0
- package/src/utils/intelligentSearch.ts +96 -0
- package/src/utils/legacy.ts +139 -0
- package/src/utils/minicart.ts +139 -0
- package/src/utils/operationRouter.ts +139 -0
- package/src/utils/pickAndOmit.ts +25 -0
- package/src/utils/proxy.ts +435 -0
- package/src/utils/resourceRange.ts +10 -0
- package/src/utils/segment.ts +152 -0
- package/src/utils/similars.ts +37 -0
- package/src/utils/sitemap.ts +282 -0
- package/src/utils/slugCache.ts +28 -0
- package/src/utils/slugify.ts +13 -0
- package/src/utils/transform.ts +1509 -0
- package/src/utils/types.ts +1884 -0
- package/src/utils/vtexId.ts +138 -0
- package/tsconfig.json +7 -0
package/src/registry.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { AppRegistryEntry } from "@decocms/apps-commerce/registry";
|
|
2
|
+
|
|
3
|
+
export const VTEX_REGISTRY_ENTRY: AppRegistryEntry = {
|
|
4
|
+
blockKey: "deco-vtex",
|
|
5
|
+
module: () => import("./mod"),
|
|
6
|
+
displayName: "VTEX",
|
|
7
|
+
category: "commerce",
|
|
8
|
+
description: "VTEX IO commerce integration",
|
|
9
|
+
};
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* VTEX Checkout / OrderForm types.
|
|
3
|
+
* These mirror the VTEX Checkout API response shapes.
|
|
4
|
+
* Aligned with deco-cx/apps vtex/utils/types.ts
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export interface OrderFormItem {
|
|
8
|
+
id: string;
|
|
9
|
+
productId: string;
|
|
10
|
+
productRefId?: string;
|
|
11
|
+
refId?: string;
|
|
12
|
+
ean?: string | null;
|
|
13
|
+
name: string;
|
|
14
|
+
skuName: string;
|
|
15
|
+
imageUrl: string;
|
|
16
|
+
detailUrl: string;
|
|
17
|
+
price: number;
|
|
18
|
+
listPrice: number;
|
|
19
|
+
manualPrice?: number | null;
|
|
20
|
+
quantity: number;
|
|
21
|
+
sellingPrice: number;
|
|
22
|
+
rewardValue?: number;
|
|
23
|
+
isGift?: boolean;
|
|
24
|
+
tax?: number;
|
|
25
|
+
seller: string;
|
|
26
|
+
sellerChain?: string[];
|
|
27
|
+
uniqueId: string;
|
|
28
|
+
parentItemIndex?: number | null;
|
|
29
|
+
parentAssemblyBinding?: string | null;
|
|
30
|
+
availability?: string;
|
|
31
|
+
measurementUnit?: string;
|
|
32
|
+
unitMultiplier?: number;
|
|
33
|
+
productCategoryIds?: string;
|
|
34
|
+
productCategories?: Record<string, string>;
|
|
35
|
+
additionalInfo?: {
|
|
36
|
+
brandName?: string;
|
|
37
|
+
brandId?: string;
|
|
38
|
+
dimension?: Record<string, string> | null;
|
|
39
|
+
offeringInfo?: unknown | null;
|
|
40
|
+
offeringType?: unknown | null;
|
|
41
|
+
offeringTypeId?: unknown | null;
|
|
42
|
+
};
|
|
43
|
+
attachments?: unknown[];
|
|
44
|
+
attachmentOfferings?: Array<{
|
|
45
|
+
name: string;
|
|
46
|
+
required: boolean;
|
|
47
|
+
schema: Record<string, unknown>;
|
|
48
|
+
}>;
|
|
49
|
+
offerings?: Array<{
|
|
50
|
+
type: string;
|
|
51
|
+
id: string;
|
|
52
|
+
name: string;
|
|
53
|
+
price: number;
|
|
54
|
+
}>;
|
|
55
|
+
priceTags?: Array<{
|
|
56
|
+
name: string;
|
|
57
|
+
value: number;
|
|
58
|
+
rawValue: number;
|
|
59
|
+
isPercentual: boolean;
|
|
60
|
+
identifier: string | null;
|
|
61
|
+
}>;
|
|
62
|
+
components?: unknown[];
|
|
63
|
+
bundleItems?: unknown[];
|
|
64
|
+
priceDefinition?: {
|
|
65
|
+
calculatedSellingPrice: number;
|
|
66
|
+
total: number;
|
|
67
|
+
sellingPrices: Array<{
|
|
68
|
+
value: number;
|
|
69
|
+
quantity: number;
|
|
70
|
+
}>;
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export interface Totalizer {
|
|
75
|
+
id: string;
|
|
76
|
+
name: string;
|
|
77
|
+
value: number;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export interface Message {
|
|
81
|
+
code: string;
|
|
82
|
+
text: string;
|
|
83
|
+
status: string;
|
|
84
|
+
fields?: Record<string, string>;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export interface MarketingData {
|
|
88
|
+
utmSource?: string;
|
|
89
|
+
utmMedium?: string;
|
|
90
|
+
utmCampaign?: string;
|
|
91
|
+
utmiPage?: string;
|
|
92
|
+
utmiPart?: string;
|
|
93
|
+
utmiCampaign?: string;
|
|
94
|
+
coupon?: string;
|
|
95
|
+
marketingTags?: string[];
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export interface ClientProfileData {
|
|
99
|
+
email: string;
|
|
100
|
+
firstName?: string | null;
|
|
101
|
+
lastName?: string | null;
|
|
102
|
+
document?: string | null;
|
|
103
|
+
phone?: string | null;
|
|
104
|
+
corporateName?: string | null;
|
|
105
|
+
isCorporate?: boolean;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export interface StorePreferencesData {
|
|
109
|
+
countryCode: string;
|
|
110
|
+
saveUserData?: boolean;
|
|
111
|
+
timeZone?: string;
|
|
112
|
+
currencyCode: string;
|
|
113
|
+
currencyLocale?: number;
|
|
114
|
+
currencySymbol: string;
|
|
115
|
+
currencyFormatInfo?: {
|
|
116
|
+
currencyDecimalDigits: number;
|
|
117
|
+
currencyDecimalSeparator: string;
|
|
118
|
+
currencyGroupSeparator: string;
|
|
119
|
+
currencyGroupSize: number;
|
|
120
|
+
startsWithCurrencySymbol: boolean;
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export interface ClientPreferencesData {
|
|
125
|
+
locale: string;
|
|
126
|
+
optinNewsLetter?: boolean;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export interface ShippingData {
|
|
130
|
+
address?: {
|
|
131
|
+
postalCode?: string;
|
|
132
|
+
city?: string;
|
|
133
|
+
state?: string;
|
|
134
|
+
country?: string;
|
|
135
|
+
street?: string;
|
|
136
|
+
number?: string;
|
|
137
|
+
neighborhood?: string;
|
|
138
|
+
complement?: string;
|
|
139
|
+
reference?: string;
|
|
140
|
+
} | null;
|
|
141
|
+
selectedAddresses?: Array<{
|
|
142
|
+
postalCode?: string;
|
|
143
|
+
city?: string;
|
|
144
|
+
state?: string;
|
|
145
|
+
country?: string;
|
|
146
|
+
}>;
|
|
147
|
+
logisticsInfo?: Array<{
|
|
148
|
+
itemIndex: number;
|
|
149
|
+
selectedSla?: string;
|
|
150
|
+
selectedDeliveryChannel?: string;
|
|
151
|
+
slas?: Sla[];
|
|
152
|
+
}>;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export interface PaymentData {
|
|
156
|
+
updateStatus?: string;
|
|
157
|
+
installmentOptions?: unknown[];
|
|
158
|
+
paymentSystems?: unknown[];
|
|
159
|
+
payments?: unknown[];
|
|
160
|
+
giftCards?: unknown[];
|
|
161
|
+
availableAccounts?: unknown[];
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export interface OrderForm {
|
|
165
|
+
orderFormId: string;
|
|
166
|
+
salesChannel: string;
|
|
167
|
+
loggedIn: boolean;
|
|
168
|
+
isCheckedIn: boolean;
|
|
169
|
+
storeId?: unknown | null;
|
|
170
|
+
checkedInPickupPointId?: unknown | null;
|
|
171
|
+
allowManualPrice: boolean;
|
|
172
|
+
canEditData: boolean;
|
|
173
|
+
userProfileId?: unknown | null;
|
|
174
|
+
userType?: unknown | null;
|
|
175
|
+
ignoreProfileData: boolean;
|
|
176
|
+
value: number;
|
|
177
|
+
messages: Message[];
|
|
178
|
+
items: OrderFormItem[];
|
|
179
|
+
selectableGifts?: unknown[];
|
|
180
|
+
totalizers: Totalizer[];
|
|
181
|
+
shippingData: ShippingData | null;
|
|
182
|
+
clientProfileData: ClientProfileData | null;
|
|
183
|
+
paymentData: PaymentData | null;
|
|
184
|
+
marketingData: MarketingData | null;
|
|
185
|
+
sellers?: Array<{ id: string; name: string; logo?: string }>;
|
|
186
|
+
clientPreferencesData?: ClientPreferencesData | null;
|
|
187
|
+
commercialConditionData?: unknown | null;
|
|
188
|
+
storePreferencesData?: StorePreferencesData | null;
|
|
189
|
+
giftRegistryData?: unknown | null;
|
|
190
|
+
openTextField?: unknown | null;
|
|
191
|
+
invoiceData?: unknown | null;
|
|
192
|
+
customData?: unknown | null;
|
|
193
|
+
itemMetadata?: unknown | null;
|
|
194
|
+
hooksData?: unknown | null;
|
|
195
|
+
ratesAndBenefitsData?: {
|
|
196
|
+
rateAndBenefitsIdentifiers?: unknown[];
|
|
197
|
+
teaser?: unknown[];
|
|
198
|
+
} | null;
|
|
199
|
+
subscriptionData?: unknown | null;
|
|
200
|
+
merchantContextData?: unknown | null;
|
|
201
|
+
itemsOrdination?: unknown | null;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export interface SimulationOrderForm {
|
|
205
|
+
items: Array<{
|
|
206
|
+
id: string;
|
|
207
|
+
quantity: number;
|
|
208
|
+
seller: string;
|
|
209
|
+
price?: number;
|
|
210
|
+
listPrice?: number;
|
|
211
|
+
offerings?: any[];
|
|
212
|
+
priceTags?: any[];
|
|
213
|
+
availability?: string;
|
|
214
|
+
}>;
|
|
215
|
+
logisticsInfo?: Array<{
|
|
216
|
+
itemIndex: number;
|
|
217
|
+
slas: Sla[];
|
|
218
|
+
selectedSla?: string;
|
|
219
|
+
selectedDeliveryChannel?: string;
|
|
220
|
+
}>;
|
|
221
|
+
paymentData?: {
|
|
222
|
+
installmentOptions?: any[];
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
export interface Sla {
|
|
227
|
+
id: string;
|
|
228
|
+
name: string;
|
|
229
|
+
price: number;
|
|
230
|
+
shippingEstimate: string;
|
|
231
|
+
deliveryChannel?: string;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
export interface SKU {
|
|
235
|
+
id: string;
|
|
236
|
+
seller: string;
|
|
237
|
+
quantity: number;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
export interface VtexProduct {
|
|
241
|
+
productId: string;
|
|
242
|
+
productName: string;
|
|
243
|
+
brand: string;
|
|
244
|
+
categoryId: string;
|
|
245
|
+
categories: string[];
|
|
246
|
+
items: any[];
|
|
247
|
+
[key: string]: any;
|
|
248
|
+
}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
_resetCookieWarnDedupForTests,
|
|
4
|
+
extractVtexCookies,
|
|
5
|
+
sanitizeOutboundCookieHeader,
|
|
6
|
+
VTEX_COOKIE_PREFIXES,
|
|
7
|
+
warnDroppedCookies,
|
|
8
|
+
} from "../cookieSanitizer";
|
|
9
|
+
|
|
10
|
+
describe("sanitizeOutboundCookieHeader", () => {
|
|
11
|
+
it("returns empty result for empty input", () => {
|
|
12
|
+
expect(sanitizeOutboundCookieHeader("")).toEqual({ cookies: "", dropped: [] });
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it("preserves a clean ASCII cookie header unchanged in content", () => {
|
|
16
|
+
const raw = "checkout.vtex.com=__ofid=abc; vtex_segment=eyJ0b2tlbiI6IjEyMyJ9";
|
|
17
|
+
const { cookies, dropped } = sanitizeOutboundCookieHeader(raw);
|
|
18
|
+
expect(dropped).toEqual([]);
|
|
19
|
+
expect(cookies).toBe("checkout.vtex.com=__ofid=abc; vtex_segment=eyJ0b2tlbiI6IjEyMyJ9");
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("drops a cookie whose value contains non-ASCII bytes — the casaevideo repro", () => {
|
|
23
|
+
// `á` is the UTF-8 encoding of `á` interpreted as Latin-1 — bytes 0xC3 0xA1.
|
|
24
|
+
// VTEX's janus gateway returns 503 deterministically when this reaches it.
|
|
25
|
+
const raw = "checkout.vtex.com=__ofid=abc; category_click=Eletroportáteis; vtex_segment=ok";
|
|
26
|
+
const { cookies, dropped } = sanitizeOutboundCookieHeader(raw);
|
|
27
|
+
expect(cookies).toBe("checkout.vtex.com=__ofid=abc; vtex_segment=ok");
|
|
28
|
+
expect(dropped).toEqual([{ name: "category_click", reason: "non_ascii" }]);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("drops cookies with raw multi-byte UTF-8 characters", () => {
|
|
32
|
+
const { cookies, dropped } = sanitizeOutboundCookieHeader(
|
|
33
|
+
"checkout.vtex.com=ok; pref=日本語; analytics=tracked",
|
|
34
|
+
);
|
|
35
|
+
expect(cookies).toBe("checkout.vtex.com=ok; analytics=tracked");
|
|
36
|
+
expect(dropped).toEqual([{ name: "pref", reason: "non_ascii" }]);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("treats malformed pairs (no `=`, blank name) as dropped", () => {
|
|
40
|
+
const { cookies, dropped } = sanitizeOutboundCookieHeader("justaname; =headless; ok=1; ;");
|
|
41
|
+
expect(cookies).toBe("ok=1");
|
|
42
|
+
expect(dropped.map((d) => d.reason)).toEqual(["malformed", "malformed"]);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("preserves URL-encoded values containing `=` signs (JWT-shaped tokens)", () => {
|
|
46
|
+
const raw =
|
|
47
|
+
"VtexIdclientAutCookie=eyJhbGciOiJSUzI1NiIs.eyJzdWIiOiJxIn0.signature; CheckoutOrderFormOwnership=Vk1B%3D%3D";
|
|
48
|
+
const { cookies, dropped } = sanitizeOutboundCookieHeader(raw);
|
|
49
|
+
expect(dropped).toEqual([]);
|
|
50
|
+
expect(cookies).toBe(raw);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("rejects cookie names with separators (e.g. spaces) as malformed", () => {
|
|
54
|
+
const { dropped } = sanitizeOutboundCookieHeader("bad name=v; ok=1");
|
|
55
|
+
expect(dropped[0]).toEqual({ name: "bad name", reason: "malformed" });
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("trims surrounding whitespace between pairs", () => {
|
|
59
|
+
const { cookies } = sanitizeOutboundCookieHeader(" a=1 ; b=2 ;c=3 ");
|
|
60
|
+
expect(cookies).toBe("a=1; b=2; c=3");
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("preserves cookie order across the input", () => {
|
|
64
|
+
const { cookies } = sanitizeOutboundCookieHeader("a=1; b=2; c=3; d=4");
|
|
65
|
+
expect(cookies).toBe("a=1; b=2; c=3; d=4");
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("with allowlist=true keeps only VTEX-prefixed cookies and reports the rest", () => {
|
|
69
|
+
const raw =
|
|
70
|
+
"FPID=abc; checkout.vtex.com=__ofid=xyz; _ga=tracking; vtex_segment=eyJ9; CheckoutOrderFormOwnership=token; __cf_bm=cf";
|
|
71
|
+
const { cookies, dropped } = sanitizeOutboundCookieHeader(raw, { allowlist: true });
|
|
72
|
+
expect(cookies).toBe(
|
|
73
|
+
"checkout.vtex.com=__ofid=xyz; vtex_segment=eyJ9; CheckoutOrderFormOwnership=token",
|
|
74
|
+
);
|
|
75
|
+
expect(dropped.map((d) => d.name).sort()).toEqual(["FPID", "__cf_bm", "_ga"]);
|
|
76
|
+
expect(dropped.every((d) => d.reason === "not_in_allowlist")).toBe(true);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("allowlist mode still drops a VTEX-prefixed cookie that has a non-ASCII value", () => {
|
|
80
|
+
const raw = "vtex_segment=okãvalue; checkout.vtex.com=ok";
|
|
81
|
+
const { cookies, dropped } = sanitizeOutboundCookieHeader(raw, { allowlist: true });
|
|
82
|
+
expect(cookies).toBe("checkout.vtex.com=ok");
|
|
83
|
+
expect(dropped).toEqual([{ name: "vtex_segment", reason: "non_ascii" }]);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("VTEX_COOKIE_PREFIXES covers the cookies VTEX actions actually depend on", () => {
|
|
87
|
+
// Sanity check — if anyone removes one of these, every action that
|
|
88
|
+
// depends on it will silently 401/redirect on production.
|
|
89
|
+
for (const required of [
|
|
90
|
+
"VtexIdclientAutCookie",
|
|
91
|
+
"checkout.vtex.com",
|
|
92
|
+
"CheckoutOrderFormOwnership",
|
|
93
|
+
"vtex_session",
|
|
94
|
+
"vtex_segment",
|
|
95
|
+
]) {
|
|
96
|
+
expect(VTEX_COOKIE_PREFIXES).toContain(required);
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
describe("extractVtexCookies (allowlist convenience wrapper)", () => {
|
|
102
|
+
it("matches sanitizeOutboundCookieHeader(_, { allowlist: true })", () => {
|
|
103
|
+
const raw =
|
|
104
|
+
"FPID=abc; checkout.vtex.com=__ofid=xyz; category_click=Eletroportáteis; vtex_segment=ok";
|
|
105
|
+
expect(extractVtexCookies(raw)).toBe(
|
|
106
|
+
sanitizeOutboundCookieHeader(raw, { allowlist: true }).cookies,
|
|
107
|
+
);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("returns empty string for empty input", () => {
|
|
111
|
+
expect(extractVtexCookies("")).toBe("");
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
describe("warnDroppedCookies", () => {
|
|
116
|
+
beforeEach(() => {
|
|
117
|
+
_resetCookieWarnDedupForTests();
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
afterEach(() => {
|
|
121
|
+
vi.restoreAllMocks();
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("emits one console.warn per dropped cookie with structured fields", () => {
|
|
125
|
+
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
126
|
+
warnDroppedCookies(
|
|
127
|
+
[
|
|
128
|
+
{ name: "category_click", reason: "non_ascii" },
|
|
129
|
+
{ name: "_ga", reason: "not_in_allowlist" },
|
|
130
|
+
],
|
|
131
|
+
"acct.vtexcommercestable.com.br",
|
|
132
|
+
);
|
|
133
|
+
expect(warn).toHaveBeenCalledTimes(2);
|
|
134
|
+
expect(warn.mock.calls[0]?.[0]).toMatch(
|
|
135
|
+
/host=acct\.vtexcommercestable\.com\.br name=category_click reason=non_ascii/,
|
|
136
|
+
);
|
|
137
|
+
expect(warn.mock.calls[1]?.[0]).toMatch(/name=_ga reason=not_in_allowlist/);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it("dedupes repeated drops of the same name+reason+host within the isolate lifetime", () => {
|
|
141
|
+
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
142
|
+
const drop = [{ name: "category_click", reason: "non_ascii" as const }];
|
|
143
|
+
warnDroppedCookies(drop, "acct.vtexcommercestable.com.br");
|
|
144
|
+
warnDroppedCookies(drop, "acct.vtexcommercestable.com.br");
|
|
145
|
+
warnDroppedCookies(drop, "acct.vtexcommercestable.com.br");
|
|
146
|
+
expect(warn).toHaveBeenCalledTimes(1);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it("does not dedupe across different hosts", () => {
|
|
150
|
+
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
151
|
+
const drop = [{ name: "category_click", reason: "non_ascii" as const }];
|
|
152
|
+
warnDroppedCookies(drop, "acct.vtexcommercestable.com.br");
|
|
153
|
+
warnDroppedCookies(drop, "secure.example.com.br");
|
|
154
|
+
expect(warn).toHaveBeenCalledTimes(2);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it("is a no-op when nothing was dropped", () => {
|
|
158
|
+
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
159
|
+
warnDroppedCookies([], "any.host");
|
|
160
|
+
expect(warn).not.toHaveBeenCalled();
|
|
161
|
+
});
|
|
162
|
+
});
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { fetchAPI, fetchSafe, HttpError } from "../fetch";
|
|
3
|
+
|
|
4
|
+
const realFetch = globalThis.fetch;
|
|
5
|
+
|
|
6
|
+
function mockResponse(body: unknown, status = 200): Response {
|
|
7
|
+
return {
|
|
8
|
+
ok: status >= 200 && status < 300,
|
|
9
|
+
status,
|
|
10
|
+
statusText: status === 200 ? "OK" : "Error",
|
|
11
|
+
url: "https://example.com",
|
|
12
|
+
json: () => Promise.resolve(body),
|
|
13
|
+
} as Response;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
describe("fetchSafe", () => {
|
|
17
|
+
beforeEach(() => {
|
|
18
|
+
globalThis.fetch = vi.fn() as unknown as typeof fetch;
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
afterEach(() => {
|
|
22
|
+
globalThis.fetch = realFetch;
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("returns the response on 2xx", async () => {
|
|
26
|
+
(globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValue(mockResponse({ ok: true }));
|
|
27
|
+
const res = await fetchSafe("https://example.com/api");
|
|
28
|
+
expect(res.status).toBe(200);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("throws HttpError on non-2xx", async () => {
|
|
32
|
+
(globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValue(mockResponse({}, 500));
|
|
33
|
+
await expect(fetchSafe("https://example.com/api")).rejects.toBeInstanceOf(HttpError);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("sanitizes utm_* and map params (drops <, > and non-Latin1)", async () => {
|
|
37
|
+
const fetchMock = globalThis.fetch as ReturnType<typeof vi.fn>;
|
|
38
|
+
fetchMock.mockResolvedValue(mockResponse({}));
|
|
39
|
+
|
|
40
|
+
await fetchSafe("https://example.com/api?utm_source=<script>café</script>&keep=ok");
|
|
41
|
+
|
|
42
|
+
const callArg = fetchMock.mock.calls[0]?.[0] as string;
|
|
43
|
+
expect(callArg).toContain("utm_source=script");
|
|
44
|
+
expect(callArg).not.toContain("<");
|
|
45
|
+
expect(callArg).not.toContain("café");
|
|
46
|
+
expect(callArg).toContain("keep=ok");
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("forwards init options to fetch", async () => {
|
|
50
|
+
const fetchMock = globalThis.fetch as ReturnType<typeof vi.fn>;
|
|
51
|
+
fetchMock.mockResolvedValue(mockResponse({}));
|
|
52
|
+
|
|
53
|
+
await fetchSafe("https://example.com/api", {
|
|
54
|
+
method: "POST",
|
|
55
|
+
headers: { "x-test": "1" },
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
const init = fetchMock.mock.calls[0]?.[1] as RequestInit;
|
|
59
|
+
expect(init.method).toBe("POST");
|
|
60
|
+
expect((init.headers as Record<string, string>)["x-test"]).toBe("1");
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
describe("fetchAPI", () => {
|
|
65
|
+
beforeEach(() => {
|
|
66
|
+
globalThis.fetch = vi.fn() as unknown as typeof fetch;
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
afterEach(() => {
|
|
70
|
+
globalThis.fetch = realFetch;
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("parses JSON on success", async () => {
|
|
74
|
+
(globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValue(
|
|
75
|
+
mockResponse({ hello: "world" }),
|
|
76
|
+
);
|
|
77
|
+
const data = await fetchAPI<{ hello: string }>("https://example.com/api");
|
|
78
|
+
expect(data).toEqual({ hello: "world" });
|
|
79
|
+
});
|
|
80
|
+
});
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { clearFetchCache, fetchWithCache, getFetchCacheStats } from "../fetchCache";
|
|
3
|
+
|
|
4
|
+
function mockResponse(body: unknown, status = 200): Response {
|
|
5
|
+
return {
|
|
6
|
+
ok: status >= 200 && status < 300,
|
|
7
|
+
status,
|
|
8
|
+
statusText: status === 200 ? "OK" : "Error",
|
|
9
|
+
json: () => Promise.resolve(body),
|
|
10
|
+
} as Response;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
describe("fetchWithCache", () => {
|
|
14
|
+
beforeEach(() => {
|
|
15
|
+
clearFetchCache();
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("returns fetched data on cache miss", async () => {
|
|
19
|
+
const data = { id: 1, name: "product" };
|
|
20
|
+
const doFetch = vi.fn(() => Promise.resolve(mockResponse(data)));
|
|
21
|
+
|
|
22
|
+
const result = await fetchWithCache("key1", doFetch);
|
|
23
|
+
expect(result).toEqual(data);
|
|
24
|
+
expect(doFetch).toHaveBeenCalledOnce();
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("returns cached data on subsequent calls", async () => {
|
|
28
|
+
const data = { id: 1 };
|
|
29
|
+
const doFetch = vi.fn(() => Promise.resolve(mockResponse(data)));
|
|
30
|
+
|
|
31
|
+
await fetchWithCache("key2", doFetch);
|
|
32
|
+
const result = await fetchWithCache("key2", doFetch);
|
|
33
|
+
|
|
34
|
+
expect(result).toEqual(data);
|
|
35
|
+
expect(doFetch).toHaveBeenCalledOnce();
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("deduplicates in-flight requests", async () => {
|
|
39
|
+
const data = { id: 1 };
|
|
40
|
+
const doFetch = vi.fn(
|
|
41
|
+
() => new Promise<Response>((resolve) => setTimeout(() => resolve(mockResponse(data)), 10)),
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
const [r1, r2] = await Promise.all([
|
|
45
|
+
fetchWithCache("key3", doFetch),
|
|
46
|
+
fetchWithCache("key3", doFetch),
|
|
47
|
+
]);
|
|
48
|
+
|
|
49
|
+
expect(r1).toEqual(data);
|
|
50
|
+
expect(r2).toEqual(data);
|
|
51
|
+
expect(doFetch).toHaveBeenCalledOnce();
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("throws on 5xx responses", async () => {
|
|
55
|
+
const doFetch = vi.fn(() => Promise.resolve(mockResponse(null, 500)));
|
|
56
|
+
|
|
57
|
+
await expect(fetchWithCache("key4", doFetch)).rejects.toThrow("500");
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("returns null for 404 responses", async () => {
|
|
61
|
+
const doFetch = vi.fn(() => Promise.resolve(mockResponse(null, 404)));
|
|
62
|
+
|
|
63
|
+
const result = await fetchWithCache("key5", doFetch);
|
|
64
|
+
expect(result).toBeNull();
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("tracks cache stats", async () => {
|
|
68
|
+
const doFetch = () => Promise.resolve(mockResponse({ ok: true }));
|
|
69
|
+
|
|
70
|
+
expect(getFetchCacheStats()).toEqual({ entries: 0, inflight: 0 });
|
|
71
|
+
|
|
72
|
+
await fetchWithCache("stats1", doFetch);
|
|
73
|
+
await fetchWithCache("stats2", doFetch);
|
|
74
|
+
|
|
75
|
+
expect(getFetchCacheStats()).toEqual({ entries: 2, inflight: 0 });
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("clears cache", async () => {
|
|
79
|
+
const doFetch = () => Promise.resolve(mockResponse({ ok: true }));
|
|
80
|
+
await fetchWithCache("clear1", doFetch);
|
|
81
|
+
|
|
82
|
+
clearFetchCache();
|
|
83
|
+
expect(getFetchCacheStats()).toEqual({ entries: 0, inflight: 0 });
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("evicts the inflight slot when the fetch never settles", async () => {
|
|
87
|
+
vi.useFakeTimers();
|
|
88
|
+
try {
|
|
89
|
+
// `doFetch` returns a Promise that never resolves — simulates a hung
|
|
90
|
+
// VTEX subrequest (TCP open, no FIN, no response). Without the
|
|
91
|
+
// timeout guard, the inflight Map entry would leak forever and
|
|
92
|
+
// subsequent callers would `await` a zombie Promise — the prod
|
|
93
|
+
// memory-leak this fix addresses.
|
|
94
|
+
const doFetch = vi.fn(() => new Promise<Response>(() => {}));
|
|
95
|
+
|
|
96
|
+
const pending = fetchWithCache("hung-key", doFetch);
|
|
97
|
+
// Swallow the eventual rejection so the unhandled rejection doesn't
|
|
98
|
+
// fail the test runner.
|
|
99
|
+
pending.catch(() => {});
|
|
100
|
+
|
|
101
|
+
expect(getFetchCacheStats().inflight).toBe(1);
|
|
102
|
+
|
|
103
|
+
// Fast-forward past the 10s fetch timeout.
|
|
104
|
+
await vi.advanceTimersByTimeAsync(11_000);
|
|
105
|
+
|
|
106
|
+
await expect(pending).rejects.toThrow(/timed out/);
|
|
107
|
+
expect(getFetchCacheStats().inflight).toBe(0);
|
|
108
|
+
} finally {
|
|
109
|
+
vi.useRealTimers();
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
});
|