@decocms/apps-magento 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 +47 -0
- package/src/README.md +65 -0
- package/src/__tests__/cart.test.ts +139 -0
- package/src/__tests__/client.test.ts +252 -0
- package/src/__tests__/features.test.ts +51 -0
- package/src/__tests__/graphql.test.ts +183 -0
- package/src/__tests__/newsletter-subscribe.test.ts +86 -0
- package/src/__tests__/product-stockAlert.test.ts +80 -0
- package/src/__tests__/stringifySearchCriteria.test.ts +56 -0
- package/src/__tests__/transform.test.ts +346 -0
- package/src/__tests__/user-loader.test.ts +134 -0
- package/src/__tests__/wishlist-actions.test.ts +140 -0
- package/src/__tests__/wishlist-loader.test.ts +97 -0
- package/src/actions/newsletter/subscribe.ts +40 -0
- package/src/actions/product/stockAlert.ts +67 -0
- package/src/actions/wishlist/addItem.ts +57 -0
- package/src/actions/wishlist/removeItem.ts +59 -0
- package/src/client.ts +228 -0
- package/src/index.ts +11 -0
- package/src/loaders/cart.ts +64 -0
- package/src/loaders/features.ts +16 -0
- package/src/loaders/user.ts +60 -0
- package/src/loaders/wishlist.ts +29 -0
- package/src/middleware.ts +19 -0
- package/src/types.ts +51 -0
- package/src/utils/cacheTimeControl.ts +69 -0
- package/src/utils/client/types.ts +270 -0
- package/src/utils/constants.ts +101 -0
- package/src/utils/graphql-types.ts +99 -0
- package/src/utils/graphql.ts +155 -0
- package/src/utils/stringifySearchCriteria.ts +62 -0
- package/src/utils/transform.ts +283 -0
- package/src/utils/user.ts +16 -0
- package/tsconfig.json +7 -0
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for utils/transform.ts — the schema.org mapping layer.
|
|
3
|
+
*
|
|
4
|
+
* Parity goals against deco-cx/apps/magento/utils/transform.ts:
|
|
5
|
+
* each helper is exercised against the same input shapes the legacy
|
|
6
|
+
* code handled and we assert on the same output shapes the storefront
|
|
7
|
+
* sections render against. The goal is to keep this file as a
|
|
8
|
+
* regression dam so future refactors don't silently shift output
|
|
9
|
+
* structure on real consumer sites.
|
|
10
|
+
*/
|
|
11
|
+
import { describe, expect, it } from "vitest";
|
|
12
|
+
import type { MagentoProduct } from "../utils/client/types";
|
|
13
|
+
import { toBreadcrumbList, toImages, toOffer, toProduct, toSeo, toURL } from "../utils/transform";
|
|
14
|
+
|
|
15
|
+
const baseProduct = (): MagentoProduct => ({
|
|
16
|
+
id: 42,
|
|
17
|
+
sku: "SKU-42",
|
|
18
|
+
name: "Test Product",
|
|
19
|
+
price: 100,
|
|
20
|
+
status: 1,
|
|
21
|
+
visibility: 4,
|
|
22
|
+
type_id: "simple",
|
|
23
|
+
created_at: "",
|
|
24
|
+
updated_at: "",
|
|
25
|
+
weight: 0,
|
|
26
|
+
url: "https://loja.example.com/p/test",
|
|
27
|
+
extension_attributes: {
|
|
28
|
+
category_links: [],
|
|
29
|
+
stock_item: {
|
|
30
|
+
item_id: 1,
|
|
31
|
+
product_id: 42,
|
|
32
|
+
stock_id: 1,
|
|
33
|
+
is_in_stock: true,
|
|
34
|
+
qty: 7,
|
|
35
|
+
},
|
|
36
|
+
},
|
|
37
|
+
custom_attributes: [
|
|
38
|
+
{ attribute_code: "title", value: "Test Title" },
|
|
39
|
+
{ attribute_code: "meta_title", value: "Test Meta Title" },
|
|
40
|
+
{ attribute_code: "meta_description", value: "Test Meta Description" },
|
|
41
|
+
],
|
|
42
|
+
currency_code: "BRL",
|
|
43
|
+
price_info: {
|
|
44
|
+
final_price: 80,
|
|
45
|
+
max_price: 100,
|
|
46
|
+
max_regular_price: 100,
|
|
47
|
+
minimal_regular_price: 100,
|
|
48
|
+
special_price: null,
|
|
49
|
+
minimal_price: 80,
|
|
50
|
+
regular_price: 100,
|
|
51
|
+
formatted_prices: {
|
|
52
|
+
final_price: "R$ 80,00",
|
|
53
|
+
max_price: "R$ 100,00",
|
|
54
|
+
minimal_price: "R$ 80,00",
|
|
55
|
+
max_regular_price: "R$ 100,00",
|
|
56
|
+
minimal_regular_price: null,
|
|
57
|
+
special_price: null,
|
|
58
|
+
regular_price: "R$ 100,00",
|
|
59
|
+
},
|
|
60
|
+
extension_attributes: {
|
|
61
|
+
msrp: {
|
|
62
|
+
msrp_price: "",
|
|
63
|
+
is_applicable: "",
|
|
64
|
+
is_shown_price_on_gesture: "",
|
|
65
|
+
msrp_message: "",
|
|
66
|
+
explanation_message: "",
|
|
67
|
+
},
|
|
68
|
+
tax_adjustments: {
|
|
69
|
+
final_price: 80,
|
|
70
|
+
max_price: 100,
|
|
71
|
+
max_regular_price: 100,
|
|
72
|
+
minimal_regular_price: 100,
|
|
73
|
+
special_price: 80,
|
|
74
|
+
minimal_price: 80,
|
|
75
|
+
regular_price: 100,
|
|
76
|
+
formatted_prices: {
|
|
77
|
+
final_price: "R$ 80,00",
|
|
78
|
+
max_price: "R$ 100,00",
|
|
79
|
+
minimal_price: "R$ 80,00",
|
|
80
|
+
max_regular_price: "R$ 100,00",
|
|
81
|
+
minimal_regular_price: null,
|
|
82
|
+
special_price: "R$ 80,00",
|
|
83
|
+
regular_price: "R$ 100,00",
|
|
84
|
+
},
|
|
85
|
+
},
|
|
86
|
+
weee_attributes: [],
|
|
87
|
+
weee_adjustment: "",
|
|
88
|
+
},
|
|
89
|
+
},
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
describe("toURL", () => {
|
|
93
|
+
it("promotes protocol-relative URLs to https", () => {
|
|
94
|
+
expect(toURL("//cdn.example.com/img.jpg")).toBe("https://cdn.example.com/img.jpg");
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("leaves https URLs untouched", () => {
|
|
98
|
+
expect(toURL("https://x.com/i.jpg")).toBe("https://x.com/i.jpg");
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("leaves http URLs untouched (no upgrade)", () => {
|
|
102
|
+
// Prod only promotes the // form; http:// is passed through to match.
|
|
103
|
+
expect(toURL("http://x.com/i.jpg")).toBe("http://x.com/i.jpg");
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
describe("toSeo", () => {
|
|
108
|
+
it("prefers meta_title over title", () => {
|
|
109
|
+
const seo = toSeo(
|
|
110
|
+
[
|
|
111
|
+
{ attribute_code: "title", value: "A" },
|
|
112
|
+
{ attribute_code: "meta_title", value: "B" },
|
|
113
|
+
{ attribute_code: "meta_description", value: "D" },
|
|
114
|
+
],
|
|
115
|
+
"https://x.test/p",
|
|
116
|
+
);
|
|
117
|
+
expect(seo).toEqual({ title: "B", description: "D", canonical: "https://x.test/p" });
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("falls back to title when meta_title is absent", () => {
|
|
121
|
+
const seo = toSeo([{ attribute_code: "title", value: "A" }], "https://x.test/p");
|
|
122
|
+
expect(seo.title).toBe("A");
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("returns empty title/description when neither attr is present", () => {
|
|
126
|
+
const seo = toSeo([], "https://x.test/p");
|
|
127
|
+
expect(seo).toEqual({ title: "", description: "", canonical: "https://x.test/p" });
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("joins string-array attribute values with comma+space", () => {
|
|
131
|
+
const seo = toSeo(
|
|
132
|
+
[{ attribute_code: "meta_description", value: ["a", "b", "c"] }],
|
|
133
|
+
"https://x.test/p",
|
|
134
|
+
);
|
|
135
|
+
expect(seo.description).toBe("a, b, c");
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
describe("toOffer", () => {
|
|
140
|
+
it("returns [] when price_info is absent", () => {
|
|
141
|
+
const p = baseProduct();
|
|
142
|
+
p.price_info = undefined;
|
|
143
|
+
expect(toOffer(p, 30, 10)).toEqual([]);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("maps InStock availability when stock_item.is_in_stock=true", () => {
|
|
147
|
+
const offers = toOffer(baseProduct(), 30, 10);
|
|
148
|
+
expect(offers[0].availability).toBe("https://schema.org/InStock");
|
|
149
|
+
expect(offers[0].inventoryLevel).toEqual({ value: 7 });
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it("maps OutOfStock availability when stock_item.is_in_stock=false", () => {
|
|
153
|
+
const p = baseProduct();
|
|
154
|
+
p.extension_attributes.stock_item!.is_in_stock = false;
|
|
155
|
+
const offers = toOffer(p, 30, 10);
|
|
156
|
+
expect(offers[0].availability).toBe("https://schema.org/OutOfStock");
|
|
157
|
+
expect(offers[0].inventoryLevel).toEqual({ value: 0 });
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it("emits ListPrice + SalePrice in priceSpecification", () => {
|
|
161
|
+
const offers = toOffer(baseProduct(), 30, 10);
|
|
162
|
+
const types = (offers[0].priceSpecification ?? []).map((s: any) => s.priceType);
|
|
163
|
+
expect(types).toContain("https://schema.org/ListPrice");
|
|
164
|
+
expect(types).toContain("https://schema.org/SalePrice");
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it("calculates installments capped by maxInstallments AND minInstallmentValue", () => {
|
|
168
|
+
// finalPrice 80, minInstallmentValue 30 → floor(80/30) = 2 possible
|
|
169
|
+
// maxInstallments 10 → bounded by 2
|
|
170
|
+
const offers = toOffer(baseProduct(), 30, 10);
|
|
171
|
+
const installments = (offers[0].priceSpecification ?? []).filter(
|
|
172
|
+
(s: any) => s.priceComponentType === "https://schema.org/Installment",
|
|
173
|
+
);
|
|
174
|
+
expect(installments.length).toBe(2);
|
|
175
|
+
expect((installments[0] as any).description).toBe("À vista");
|
|
176
|
+
expect((installments[1] as any).description).toBe("2x sem juros");
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it("always emits at least 1 installment (À vista) when finalPrice < minInstallmentValue", () => {
|
|
180
|
+
// finalPrice 80, minInstallmentValue 200 → floor = 0 → || 1
|
|
181
|
+
const offers = toOffer(baseProduct(), 200, 10);
|
|
182
|
+
const installments = (offers[0].priceSpecification ?? []).filter(
|
|
183
|
+
(s: any) => s.priceComponentType === "https://schema.org/Installment",
|
|
184
|
+
);
|
|
185
|
+
expect(installments.length).toBe(1);
|
|
186
|
+
expect((installments[0] as any).description).toBe("À vista");
|
|
187
|
+
});
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
describe("toImages", () => {
|
|
191
|
+
it("maps media_gallery_entries when imagesUrl is provided", () => {
|
|
192
|
+
const p = baseProduct();
|
|
193
|
+
p.media_gallery_entries = [
|
|
194
|
+
{
|
|
195
|
+
id: 1,
|
|
196
|
+
media_type: "image",
|
|
197
|
+
label: null,
|
|
198
|
+
position: 1,
|
|
199
|
+
disabled: false,
|
|
200
|
+
types: [],
|
|
201
|
+
file: "/x/y.jpg",
|
|
202
|
+
},
|
|
203
|
+
];
|
|
204
|
+
const imgs = toImages(p, "https://cdn.example/media");
|
|
205
|
+
expect(imgs).toEqual([
|
|
206
|
+
{
|
|
207
|
+
"@type": "ImageObject",
|
|
208
|
+
encodingFormat: "image",
|
|
209
|
+
alternateName: "/x/y.jpg",
|
|
210
|
+
url: "https://cdn.example/media/x/y.jpg",
|
|
211
|
+
disabled: false,
|
|
212
|
+
},
|
|
213
|
+
]);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it("falls back to `images` when imagesUrl is empty", () => {
|
|
217
|
+
const p = baseProduct();
|
|
218
|
+
p.images = [
|
|
219
|
+
{
|
|
220
|
+
url: "https://x.test/i.jpg",
|
|
221
|
+
code: "image",
|
|
222
|
+
height: 1,
|
|
223
|
+
width: 1,
|
|
224
|
+
label: "x",
|
|
225
|
+
resized_width: 1,
|
|
226
|
+
resized_height: 1,
|
|
227
|
+
disabled: false,
|
|
228
|
+
},
|
|
229
|
+
];
|
|
230
|
+
const imgs = toImages(p, "");
|
|
231
|
+
expect(imgs?.[0]).toMatchObject({ url: "https://x.test/i.jpg", alternateName: "x" });
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it("promotes // URLs to https via toURL inside the prefix", () => {
|
|
235
|
+
const p = baseProduct();
|
|
236
|
+
p.media_gallery_entries = [
|
|
237
|
+
{
|
|
238
|
+
id: 1,
|
|
239
|
+
media_type: "image",
|
|
240
|
+
label: null,
|
|
241
|
+
position: 1,
|
|
242
|
+
disabled: false,
|
|
243
|
+
types: [],
|
|
244
|
+
file: "/x.jpg",
|
|
245
|
+
},
|
|
246
|
+
];
|
|
247
|
+
const imgs = toImages(p, "//cdn.example/m");
|
|
248
|
+
expect(imgs?.[0].url).toBe("https://cdn.example/m/x.jpg");
|
|
249
|
+
});
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
describe("toBreadcrumbList", () => {
|
|
253
|
+
const PRODUCT_URL = new URL("https://x.test/produto/abc");
|
|
254
|
+
|
|
255
|
+
it("returns a single ListItem with the product name when categories=[] and flag=true", () => {
|
|
256
|
+
const out = toBreadcrumbList(
|
|
257
|
+
[],
|
|
258
|
+
true,
|
|
259
|
+
{ "@type": "Product", productID: "1", sku: "1", name: "Foo" } as any,
|
|
260
|
+
PRODUCT_URL,
|
|
261
|
+
);
|
|
262
|
+
expect(out).toEqual([
|
|
263
|
+
{
|
|
264
|
+
"@type": "ListItem",
|
|
265
|
+
name: "Foo",
|
|
266
|
+
position: 1,
|
|
267
|
+
item: "https://x.test/Foo",
|
|
268
|
+
},
|
|
269
|
+
]);
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
it("maps each valid category to a ListItem ordered by position", () => {
|
|
273
|
+
const out = toBreadcrumbList(
|
|
274
|
+
[{ id: 1, name: "A", position: 1 } as any, { id: 2, name: "B", position: 2 } as any],
|
|
275
|
+
false,
|
|
276
|
+
{ name: "Foo" } as any,
|
|
277
|
+
PRODUCT_URL,
|
|
278
|
+
);
|
|
279
|
+
expect(out).toEqual([
|
|
280
|
+
{ "@type": "ListItem", name: "A", position: 1, item: "https://x.test/A" },
|
|
281
|
+
{ "@type": "ListItem", name: "B", position: 2, item: "https://x.test/B" },
|
|
282
|
+
]);
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
it("filters out null/empty-name/zero-position categories", () => {
|
|
286
|
+
const out = toBreadcrumbList(
|
|
287
|
+
[
|
|
288
|
+
null,
|
|
289
|
+
{ id: 1, name: "", position: 1 } as any,
|
|
290
|
+
{ id: 2, name: "B", position: 0 } as any,
|
|
291
|
+
{ id: 3, name: "C", position: 3 } as any,
|
|
292
|
+
],
|
|
293
|
+
false,
|
|
294
|
+
{ name: "Foo" } as any,
|
|
295
|
+
PRODUCT_URL,
|
|
296
|
+
);
|
|
297
|
+
expect(out).toEqual([
|
|
298
|
+
{ "@type": "ListItem", name: "C", position: 3, item: "https://x.test/C" },
|
|
299
|
+
]);
|
|
300
|
+
});
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
describe("toProduct", () => {
|
|
304
|
+
it("maps a base product into a schema.org Product with offers, image, additionalProperty", () => {
|
|
305
|
+
const out = toProduct({
|
|
306
|
+
product: baseProduct(),
|
|
307
|
+
options: {
|
|
308
|
+
currencyCode: "BRL",
|
|
309
|
+
imagesUrl: "",
|
|
310
|
+
maxInstallments: 10,
|
|
311
|
+
minInstallmentValue: 30,
|
|
312
|
+
},
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
expect(out["@type"]).toBe("Product");
|
|
316
|
+
expect(out.productID).toBe("42");
|
|
317
|
+
expect(out.sku).toBe("SKU-42");
|
|
318
|
+
expect(out.name).toBe("Test Product");
|
|
319
|
+
expect(out.url).toBe("https://loja.example.com/p/test");
|
|
320
|
+
expect((out.offers as any).highPrice).toBe(100);
|
|
321
|
+
expect((out.offers as any).lowPrice).toBe(80);
|
|
322
|
+
expect(out.additionalProperty?.length).toBe(3);
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
it("wraps the product in isVariantOf with the same productID", () => {
|
|
326
|
+
const out = toProduct({
|
|
327
|
+
product: baseProduct(),
|
|
328
|
+
options: { maxInstallments: 10, minInstallmentValue: 30 },
|
|
329
|
+
});
|
|
330
|
+
expect(out.isVariantOf?.productGroupID).toBe("42");
|
|
331
|
+
expect(out.isVariantOf?.hasVariant?.[0].productID).toBe("42");
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
it("falls back to product.price when price_info is absent for highPrice/lowPrice", () => {
|
|
335
|
+
const p = baseProduct();
|
|
336
|
+
p.price_info = undefined;
|
|
337
|
+
const out = toProduct({
|
|
338
|
+
product: p,
|
|
339
|
+
options: { maxInstallments: 10, minInstallmentValue: 30 },
|
|
340
|
+
});
|
|
341
|
+
expect((out.offers as any).highPrice).toBe(100);
|
|
342
|
+
expect((out.offers as any).lowPrice).toBe(100);
|
|
343
|
+
// And offers array is empty when price_info is absent (toOffer returns [])
|
|
344
|
+
expect((out.offers as any).offerCount).toBe(0);
|
|
345
|
+
});
|
|
346
|
+
});
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the user loader.
|
|
3
|
+
*
|
|
4
|
+
* Parity goals against deco-cx/apps/magento/loaders/user.ts:
|
|
5
|
+
* - Returns null when no PHPSESSID cookie is present (anonymous).
|
|
6
|
+
* - Calls /customer/section/load?sections=customer,carbono-customer
|
|
7
|
+
* with the Cookie header set from PHPSESSID.
|
|
8
|
+
* - Maps the bundle into schema.org Person { @id, email, givenName,
|
|
9
|
+
* familyName? }.
|
|
10
|
+
* - Derives familyName by stripping firstname from fullname (matches
|
|
11
|
+
* prod's `fullname.replace(firstname, "").trim()`).
|
|
12
|
+
* - Returns null when carbono-customer.data_id is missing OR when
|
|
13
|
+
* customer slice is absent.
|
|
14
|
+
* - Swallows fetch errors and returns null (the storefront re-renders
|
|
15
|
+
* the logged-out UI rather than crashing).
|
|
16
|
+
*/
|
|
17
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
18
|
+
import { configureMagento } from "../client";
|
|
19
|
+
import user from "../loaders/user";
|
|
20
|
+
|
|
21
|
+
function mockResponse(body: unknown, status = 200): Response {
|
|
22
|
+
return new Response(JSON.stringify(body), {
|
|
23
|
+
status,
|
|
24
|
+
headers: { "content-type": "application/json" },
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function requestWithCookie(cookie: string): Request {
|
|
29
|
+
const r = new Request("http://localhost/");
|
|
30
|
+
const headers = new Headers();
|
|
31
|
+
headers.set("cookie", cookie);
|
|
32
|
+
Object.defineProperty(r, "headers", { value: headers, configurable: true });
|
|
33
|
+
return r;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
describe("user loader", () => {
|
|
37
|
+
let fetchSpy: ReturnType<typeof vi.spyOn>;
|
|
38
|
+
|
|
39
|
+
beforeEach(() => {
|
|
40
|
+
configureMagento({
|
|
41
|
+
baseUrl: "https://loja.example.com/",
|
|
42
|
+
apiKey: "secret",
|
|
43
|
+
storeId: 1,
|
|
44
|
+
site: "example",
|
|
45
|
+
});
|
|
46
|
+
fetchSpy = vi.spyOn(globalThis, "fetch");
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
afterEach(() => {
|
|
50
|
+
vi.restoreAllMocks();
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("returns null when no PHPSESSID cookie is present", async () => {
|
|
54
|
+
const result = await user(null, new Request("http://localhost/"));
|
|
55
|
+
expect(result).toBeNull();
|
|
56
|
+
expect(fetchSpy).not.toHaveBeenCalled();
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("requests /customer/section/load with the Cookie header", async () => {
|
|
60
|
+
fetchSpy.mockResolvedValue(
|
|
61
|
+
mockResponse({
|
|
62
|
+
customer: { data_id: 1, fullname: "Alice Doe", firstname: "Alice" },
|
|
63
|
+
"carbono-customer": { data_id: 1, customerId: "c1", email: "a@b.com" },
|
|
64
|
+
}),
|
|
65
|
+
);
|
|
66
|
+
await user(null, requestWithCookie("PHPSESSID=abc"));
|
|
67
|
+
const [target, init] = fetchSpy.mock.calls[0] as [URL, RequestInit];
|
|
68
|
+
expect(target.toString()).toBe(
|
|
69
|
+
"https://loja.example.com/example/customer/section/load?sections=customer,carbono-customer",
|
|
70
|
+
);
|
|
71
|
+
const cookieHeader = new Headers(init.headers).get("cookie");
|
|
72
|
+
expect(cookieHeader).toBe("PHPSESSID=abc");
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("maps a happy-path bundle into a schema.org Person", async () => {
|
|
76
|
+
fetchSpy.mockResolvedValue(
|
|
77
|
+
mockResponse({
|
|
78
|
+
customer: { data_id: 1, fullname: "Alice Doe", firstname: "Alice" },
|
|
79
|
+
"carbono-customer": { data_id: 1, customerId: "c1", email: "a@b.com" },
|
|
80
|
+
}),
|
|
81
|
+
);
|
|
82
|
+
const out = await user(null, requestWithCookie("PHPSESSID=abc"));
|
|
83
|
+
expect(out).toEqual({
|
|
84
|
+
"@id": "c1",
|
|
85
|
+
email: "a@b.com",
|
|
86
|
+
givenName: "Alice",
|
|
87
|
+
familyName: "Doe",
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("omits familyName when fullname is just firstname (no surname)", async () => {
|
|
92
|
+
fetchSpy.mockResolvedValue(
|
|
93
|
+
mockResponse({
|
|
94
|
+
customer: { data_id: 1, fullname: "Alice", firstname: "Alice" },
|
|
95
|
+
"carbono-customer": { data_id: 1, customerId: "c1", email: "a@b.com" },
|
|
96
|
+
}),
|
|
97
|
+
);
|
|
98
|
+
const out = await user(null, requestWithCookie("PHPSESSID=abc"));
|
|
99
|
+
expect(out).toMatchObject({ givenName: "Alice" });
|
|
100
|
+
// Magento returns fullname=firstname; prod's `replace(firstname, "").trim()`
|
|
101
|
+
// yields "" — we include the key with an empty string. Test the
|
|
102
|
+
// surface, not the empty-vs-missing detail.
|
|
103
|
+
expect(out?.familyName ?? "").toBe("");
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("returns null when carbono-customer.data_id is missing", async () => {
|
|
107
|
+
fetchSpy.mockResolvedValue(
|
|
108
|
+
mockResponse({
|
|
109
|
+
customer: { data_id: 1, fullname: "Alice Doe", firstname: "Alice" },
|
|
110
|
+
"carbono-customer": { customerId: "c1", email: "a@b.com" }, // no data_id
|
|
111
|
+
}),
|
|
112
|
+
);
|
|
113
|
+
expect(await user(null, requestWithCookie("PHPSESSID=abc"))).toBeNull();
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("returns null when customer slice is absent", async () => {
|
|
117
|
+
fetchSpy.mockResolvedValue(
|
|
118
|
+
mockResponse({
|
|
119
|
+
"carbono-customer": { data_id: 1, customerId: "c1", email: "a@b.com" },
|
|
120
|
+
}),
|
|
121
|
+
);
|
|
122
|
+
expect(await user(null, requestWithCookie("PHPSESSID=abc"))).toBeNull();
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("returns null when fetch throws", async () => {
|
|
126
|
+
fetchSpy.mockRejectedValue(new Error("ECONNREFUSED"));
|
|
127
|
+
expect(await user(null, requestWithCookie("PHPSESSID=abc"))).toBeNull();
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("returns null on non-2xx response", async () => {
|
|
131
|
+
fetchSpy.mockResolvedValue(mockResponse(null, 500));
|
|
132
|
+
expect(await user(null, requestWithCookie("PHPSESSID=abc"))).toBeNull();
|
|
133
|
+
});
|
|
134
|
+
});
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the wishlist add/remove actions.
|
|
3
|
+
*
|
|
4
|
+
* Parity goals against deco-cx/apps/magento/actions/wishlist/{addItem,removeItem}:
|
|
5
|
+
* - Both require PHPSESSID + form_key cookies; return null when either absent.
|
|
6
|
+
* - addItem POSTs FormData {product, form_key} to /wishlist/index/add/.
|
|
7
|
+
* - removeItem POSTs FormData {item, uenc:"", form_key} to /wishlist/index/remove/.
|
|
8
|
+
* - On success ({success:true}) both delegate to the wishlist loader to
|
|
9
|
+
* fetch the refreshed list.
|
|
10
|
+
* - Failure / thrown / success:false → null.
|
|
11
|
+
*/
|
|
12
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
13
|
+
import addItem from "../actions/wishlist/addItem";
|
|
14
|
+
import removeItem from "../actions/wishlist/removeItem";
|
|
15
|
+
import { configureMagento } from "../client";
|
|
16
|
+
|
|
17
|
+
function mockResponse(body: unknown, status = 200): Response {
|
|
18
|
+
return new Response(JSON.stringify(body), {
|
|
19
|
+
status,
|
|
20
|
+
headers: { "content-type": "application/json" },
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function requestWithCookies(cookieHeader: string): Request {
|
|
25
|
+
const r = new Request("http://localhost/");
|
|
26
|
+
const headers = new Headers();
|
|
27
|
+
headers.set("cookie", cookieHeader);
|
|
28
|
+
Object.defineProperty(r, "headers", { value: headers, configurable: true });
|
|
29
|
+
return r;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const AUTHED = "PHPSESSID=abc; form_key=xyz";
|
|
33
|
+
|
|
34
|
+
describe("wishlist/addItem", () => {
|
|
35
|
+
let fetchSpy: ReturnType<typeof vi.spyOn>;
|
|
36
|
+
|
|
37
|
+
beforeEach(() => {
|
|
38
|
+
configureMagento({
|
|
39
|
+
baseUrl: "https://loja.example.com/",
|
|
40
|
+
apiKey: "secret",
|
|
41
|
+
storeId: 1,
|
|
42
|
+
site: "example",
|
|
43
|
+
});
|
|
44
|
+
fetchSpy = vi.spyOn(globalThis, "fetch");
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
afterEach(() => vi.restoreAllMocks());
|
|
48
|
+
|
|
49
|
+
it("returns null when PHPSESSID is missing", async () => {
|
|
50
|
+
expect(await addItem({ productId: "1" }, requestWithCookies("form_key=xyz"))).toBeNull();
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("returns null when form_key is missing", async () => {
|
|
54
|
+
expect(await addItem({ productId: "1" }, requestWithCookies("PHPSESSID=abc"))).toBeNull();
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("POSTs FormData {product, form_key} to /wishlist/index/add/", async () => {
|
|
58
|
+
fetchSpy
|
|
59
|
+
.mockResolvedValueOnce(mockResponse({ success: true })) // POST add
|
|
60
|
+
.mockResolvedValueOnce(
|
|
61
|
+
mockResponse({
|
|
62
|
+
wishlist: { counter: "1", items: [], counter_number: 1, data_id: 1 },
|
|
63
|
+
}),
|
|
64
|
+
); // wishlist loader fetch
|
|
65
|
+
|
|
66
|
+
await addItem({ productId: "p42" }, requestWithCookies(AUTHED));
|
|
67
|
+
|
|
68
|
+
const [target, init] = fetchSpy.mock.calls[0] as [URL, RequestInit];
|
|
69
|
+
expect(target.toString()).toBe("https://loja.example.com/example/wishlist/index/add/");
|
|
70
|
+
expect(init.method).toBe("POST");
|
|
71
|
+
|
|
72
|
+
const fd = init.body as FormData;
|
|
73
|
+
expect(fd.get("product")).toBe("p42");
|
|
74
|
+
expect(fd.get("form_key")).toBe("xyz");
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("delegates to the wishlist loader on success", async () => {
|
|
78
|
+
const wl = { counter: "1", items: [], counter_number: 1, data_id: 1 };
|
|
79
|
+
fetchSpy
|
|
80
|
+
.mockResolvedValueOnce(mockResponse({ success: true }))
|
|
81
|
+
.mockResolvedValueOnce(mockResponse({ wishlist: wl }));
|
|
82
|
+
|
|
83
|
+
expect(await addItem({ productId: "p42" }, requestWithCookies(AUTHED))).toEqual(wl);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("returns null when Magento responds with success:false", async () => {
|
|
87
|
+
fetchSpy.mockResolvedValueOnce(mockResponse({ success: false }));
|
|
88
|
+
expect(await addItem({ productId: "p42" }, requestWithCookies(AUTHED))).toBeNull();
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("returns null on a thrown fetch", async () => {
|
|
92
|
+
fetchSpy.mockRejectedValueOnce(new Error("boom"));
|
|
93
|
+
expect(await addItem({ productId: "p42" }, requestWithCookies(AUTHED))).toBeNull();
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
describe("wishlist/removeItem", () => {
|
|
98
|
+
let fetchSpy: ReturnType<typeof vi.spyOn>;
|
|
99
|
+
|
|
100
|
+
beforeEach(() => {
|
|
101
|
+
configureMagento({
|
|
102
|
+
baseUrl: "https://loja.example.com/",
|
|
103
|
+
apiKey: "secret",
|
|
104
|
+
storeId: 1,
|
|
105
|
+
site: "example",
|
|
106
|
+
});
|
|
107
|
+
fetchSpy = vi.spyOn(globalThis, "fetch");
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
afterEach(() => vi.restoreAllMocks());
|
|
111
|
+
|
|
112
|
+
it("POSTs FormData {item, uenc:'', form_key} to /wishlist/index/remove/", async () => {
|
|
113
|
+
fetchSpy.mockResolvedValueOnce(mockResponse({ success: true })).mockResolvedValueOnce(
|
|
114
|
+
mockResponse({
|
|
115
|
+
wishlist: { counter: "0", items: [], counter_number: 0, data_id: 1 },
|
|
116
|
+
}),
|
|
117
|
+
);
|
|
118
|
+
|
|
119
|
+
await removeItem({ productId: "row-42" }, requestWithCookies(AUTHED));
|
|
120
|
+
|
|
121
|
+
const [target, init] = fetchSpy.mock.calls[0] as [URL, RequestInit];
|
|
122
|
+
expect(target.toString()).toBe("https://loja.example.com/example/wishlist/index/remove/");
|
|
123
|
+
const fd = init.body as FormData;
|
|
124
|
+
expect(fd.get("item")).toBe("row-42");
|
|
125
|
+
expect(fd.get("uenc")).toBe("");
|
|
126
|
+
expect(fd.get("form_key")).toBe("xyz");
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("returns the refreshed wishlist on success", async () => {
|
|
130
|
+
const wl = { counter: "0", items: [], counter_number: 0, data_id: 1 };
|
|
131
|
+
fetchSpy
|
|
132
|
+
.mockResolvedValueOnce(mockResponse({ success: true }))
|
|
133
|
+
.mockResolvedValueOnce(mockResponse({ wishlist: wl }));
|
|
134
|
+
expect(await removeItem({ productId: "row-42" }, requestWithCookies(AUTHED))).toEqual(wl);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it("returns null when PHPSESSID is missing", async () => {
|
|
138
|
+
expect(await removeItem({ productId: "x" }, requestWithCookies("form_key=xyz"))).toBeNull();
|
|
139
|
+
});
|
|
140
|
+
});
|