@decocms/apps-magento 8.1.0-next.0 → 8.1.0-next.2
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/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -0
- package/dist/magentoClient.d.ts +62 -0
- package/dist/magentoClient.d.ts.map +1 -0
- package/dist/magentoClient.js +96 -0
- package/dist/magentoClient.js.map +1 -0
- package/package.json +19 -27
- package/src/index.ts +2 -29
- package/src/README.md +0 -65
- package/src/__tests__/cart.test.ts +0 -139
- package/src/__tests__/client.test.ts +0 -252
- package/src/__tests__/features.test.ts +0 -51
- package/src/__tests__/graphql.test.ts +0 -183
- package/src/__tests__/magentoClient.test.ts +0 -166
- package/src/__tests__/newsletter-subscribe.test.ts +0 -86
- package/src/__tests__/product-stockAlert.test.ts +0 -80
- package/src/__tests__/stringifySearchCriteria.test.ts +0 -56
- package/src/__tests__/transform.test.ts +0 -346
- package/src/__tests__/user-loader.test.ts +0 -134
- package/src/__tests__/wishlist-actions.test.ts +0 -140
- package/src/__tests__/wishlist-loader.test.ts +0 -97
- package/src/actions/newsletter/subscribe.ts +0 -40
- package/src/actions/product/stockAlert.ts +0 -67
- package/src/actions/wishlist/addItem.ts +0 -57
- package/src/actions/wishlist/removeItem.ts +0 -59
- package/src/client.ts +0 -252
- package/src/loaders/cart.ts +0 -64
- package/src/loaders/features.ts +0 -16
- package/src/loaders/user.ts +0 -60
- package/src/loaders/wishlist.ts +0 -29
- package/src/middleware.ts +0 -19
- package/src/types.ts +0 -51
- package/src/utils/__tests__/instrumentation.test.ts +0 -59
- package/src/utils/cacheTimeControl.ts +0 -69
- package/src/utils/client/types.ts +0 -270
- package/src/utils/constants.ts +0 -126
- package/src/utils/fetchCache.ts +0 -55
- package/src/utils/graphql-types.ts +0 -99
- package/src/utils/graphql.ts +0 -155
- package/src/utils/instrumentedFetch.ts +0 -57
- package/src/utils/operationRouter.ts +0 -43
- package/src/utils/stringifySearchCriteria.ts +0 -62
- package/src/utils/transform.ts +0 -283
- package/src/utils/user.ts +0 -16
- package/tsconfig.json +0 -7
|
@@ -1,86 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Tests for the newsletter/subscribe action.
|
|
3
|
-
*
|
|
4
|
-
* Parity with deco-cx/apps/magento/actions/newsletter/subscribe.ts:
|
|
5
|
-
* - POSTs to /rest/:site/V1/newsletter/subscribed
|
|
6
|
-
* - Body shape: { email, store_id: number }
|
|
7
|
-
* - storeId is coerced to number even if the CMS block holds it as string
|
|
8
|
-
* - Returns null on non-2xx or when `success === false`, the payload otherwise
|
|
9
|
-
*/
|
|
10
|
-
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
11
|
-
import subscribe from "../actions/newsletter/subscribe";
|
|
12
|
-
import { configureMagento } from "../client";
|
|
13
|
-
|
|
14
|
-
function mockResponse(body: unknown, status = 200): Response {
|
|
15
|
-
return new Response(JSON.stringify(body), {
|
|
16
|
-
status,
|
|
17
|
-
headers: { "content-type": "application/json" },
|
|
18
|
-
});
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
describe("newsletter/subscribe", () => {
|
|
22
|
-
let fetchSpy: ReturnType<typeof vi.spyOn>;
|
|
23
|
-
|
|
24
|
-
beforeEach(() => {
|
|
25
|
-
configureMagento({
|
|
26
|
-
baseUrl: "https://loja.example.com/",
|
|
27
|
-
apiKey: "secret",
|
|
28
|
-
storeId: 21,
|
|
29
|
-
site: "example",
|
|
30
|
-
});
|
|
31
|
-
fetchSpy = vi.spyOn(globalThis, "fetch");
|
|
32
|
-
});
|
|
33
|
-
|
|
34
|
-
afterEach(() => {
|
|
35
|
-
vi.restoreAllMocks();
|
|
36
|
-
});
|
|
37
|
-
|
|
38
|
-
it("POSTs to the correct REST path with the encoded site", async () => {
|
|
39
|
-
fetchSpy.mockResolvedValue(mockResponse({ success: true, message: "ok" }));
|
|
40
|
-
await subscribe({ email: "a@b.com" });
|
|
41
|
-
const [target, init] = fetchSpy.mock.calls[0] as [URL, RequestInit];
|
|
42
|
-
expect(target.toString()).toBe(
|
|
43
|
-
"https://loja.example.com/rest/example/V1/newsletter/subscribed",
|
|
44
|
-
);
|
|
45
|
-
expect(init.method).toBe("POST");
|
|
46
|
-
});
|
|
47
|
-
|
|
48
|
-
it("sends { email, store_id (number) } as JSON body", async () => {
|
|
49
|
-
fetchSpy.mockResolvedValue(mockResponse({ success: true, message: "ok" }));
|
|
50
|
-
await subscribe({ email: "a@b.com" });
|
|
51
|
-
const [, init] = fetchSpy.mock.calls[0] as [URL, RequestInit];
|
|
52
|
-
expect(JSON.parse(init.body as string)).toEqual({ email: "a@b.com", store_id: 21 });
|
|
53
|
-
});
|
|
54
|
-
|
|
55
|
-
it("coerces storeId to number even if config holds a string-shaped value", async () => {
|
|
56
|
-
// Some CMS blocks store storeId as a string. The prod loader did
|
|
57
|
-
// `Number(storeId)` defensively; we keep that behavior so the
|
|
58
|
-
// Magento backend never receives a string for an int field.
|
|
59
|
-
configureMagento({
|
|
60
|
-
baseUrl: "https://loja.example.com/",
|
|
61
|
-
apiKey: "secret",
|
|
62
|
-
storeId: "42" as unknown as number,
|
|
63
|
-
site: "example",
|
|
64
|
-
});
|
|
65
|
-
fetchSpy.mockResolvedValue(mockResponse({ success: true, message: "ok" }));
|
|
66
|
-
await subscribe({ email: "a@b.com" });
|
|
67
|
-
const [, init] = fetchSpy.mock.calls[0] as [URL, RequestInit];
|
|
68
|
-
expect(JSON.parse(init.body as string).store_id).toBe(42);
|
|
69
|
-
});
|
|
70
|
-
|
|
71
|
-
it("returns the payload on success", async () => {
|
|
72
|
-
const payload = { success: true, message: "ok" };
|
|
73
|
-
fetchSpy.mockResolvedValue(mockResponse(payload));
|
|
74
|
-
expect(await subscribe({ email: "a@b.com" })).toEqual(payload);
|
|
75
|
-
});
|
|
76
|
-
|
|
77
|
-
it("returns null on success:false (Magento failure shape)", async () => {
|
|
78
|
-
fetchSpy.mockResolvedValue(mockResponse({ success: false, message: "no" }));
|
|
79
|
-
expect(await subscribe({ email: "a@b.com" })).toBeNull();
|
|
80
|
-
});
|
|
81
|
-
|
|
82
|
-
it("returns null on non-2xx HTTP status", async () => {
|
|
83
|
-
fetchSpy.mockResolvedValue(mockResponse(null, 500));
|
|
84
|
-
expect(await subscribe({ email: "a@b.com" })).toBeNull();
|
|
85
|
-
});
|
|
86
|
-
});
|
|
@@ -1,80 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Tests for the product/stockAlert action.
|
|
3
|
-
*
|
|
4
|
-
* Parity with deco-cx/apps/magento/actions/product/stockAlert.ts:
|
|
5
|
-
* - Calls Magento's GraphQL endpoint at <baseUrl>/graphql.
|
|
6
|
-
* - Sends operationName ProductStockAlert + variables {product_id, name, email}.
|
|
7
|
-
* - Returns { data: { productStockAlert } } on success.
|
|
8
|
-
* - Returns { error } on thrown exceptions / missing payload.
|
|
9
|
-
*
|
|
10
|
-
* The legacy code passed a STALE cache hint to clientGraphql.query but
|
|
11
|
-
* mutations are never cached server-side, so the TanStack port omits it
|
|
12
|
-
* — behavior is observationally identical from a consumer perspective.
|
|
13
|
-
*/
|
|
14
|
-
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
15
|
-
import stockAlert from "../actions/product/stockAlert";
|
|
16
|
-
import { configureMagento } from "../client";
|
|
17
|
-
|
|
18
|
-
function mockResponse(body: unknown, status = 200): Response {
|
|
19
|
-
return new Response(JSON.stringify(body), {
|
|
20
|
-
status,
|
|
21
|
-
headers: { "content-type": "application/json" },
|
|
22
|
-
});
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
describe("product/stockAlert", () => {
|
|
26
|
-
let fetchSpy: ReturnType<typeof vi.spyOn>;
|
|
27
|
-
|
|
28
|
-
beforeEach(() => {
|
|
29
|
-
configureMagento({
|
|
30
|
-
baseUrl: "https://loja.example.com/",
|
|
31
|
-
apiKey: "secret",
|
|
32
|
-
storeId: 1,
|
|
33
|
-
site: "example",
|
|
34
|
-
});
|
|
35
|
-
fetchSpy = vi.spyOn(globalThis, "fetch");
|
|
36
|
-
});
|
|
37
|
-
|
|
38
|
-
afterEach(() => {
|
|
39
|
-
vi.restoreAllMocks();
|
|
40
|
-
});
|
|
41
|
-
|
|
42
|
-
it("POSTs to <baseUrl>/graphql with the ProductStockAlert mutation", async () => {
|
|
43
|
-
fetchSpy.mockResolvedValue(
|
|
44
|
-
mockResponse({
|
|
45
|
-
data: { productStockAlert: { message: "ok", status: true } },
|
|
46
|
-
}),
|
|
47
|
-
);
|
|
48
|
-
await stockAlert({ product_id: 42, name: "Alice", email: "a@b.com" });
|
|
49
|
-
const [target, init] = fetchSpy.mock.calls[0] as [URL, RequestInit];
|
|
50
|
-
expect(target.toString()).toBe("https://loja.example.com/graphql");
|
|
51
|
-
expect(init.method).toBe("POST");
|
|
52
|
-
|
|
53
|
-
const body = JSON.parse(init.body as string);
|
|
54
|
-
expect(body.operationName).toBe("ProductStockAlert");
|
|
55
|
-
expect(body.variables).toEqual({ product_id: 42, name: "Alice", email: "a@b.com" });
|
|
56
|
-
expect(body.query).toMatch(/mutation ProductStockAlert/);
|
|
57
|
-
});
|
|
58
|
-
|
|
59
|
-
it("returns { data: { productStockAlert } } on success", async () => {
|
|
60
|
-
fetchSpy.mockResolvedValue(
|
|
61
|
-
mockResponse({
|
|
62
|
-
data: { productStockAlert: { message: "added", status: true } },
|
|
63
|
-
}),
|
|
64
|
-
);
|
|
65
|
-
const out = await stockAlert({ product_id: 1, name: "n", email: "e@e.com" });
|
|
66
|
-
expect(out).toEqual({ data: { productStockAlert: { message: "added", status: true } } });
|
|
67
|
-
});
|
|
68
|
-
|
|
69
|
-
it("returns { error } when the GraphQL response lacks productStockAlert", async () => {
|
|
70
|
-
fetchSpy.mockResolvedValue(mockResponse({ data: {} }));
|
|
71
|
-
const out = await stockAlert({ product_id: 1, name: "n", email: "e@e.com" });
|
|
72
|
-
expect(out).toHaveProperty("error");
|
|
73
|
-
});
|
|
74
|
-
|
|
75
|
-
it("returns { error } when fetch throws (network error)", async () => {
|
|
76
|
-
fetchSpy.mockRejectedValue(new Error("ECONNREFUSED"));
|
|
77
|
-
const out = await stockAlert({ product_id: 1, name: "n", email: "e@e.com" });
|
|
78
|
-
expect(out).toEqual({ error: "ECONNREFUSED" });
|
|
79
|
-
});
|
|
80
|
-
});
|
|
@@ -1,56 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Tests for stringifySearchCriteria.
|
|
3
|
-
*
|
|
4
|
-
* Parity goal with deco-cx/apps/magento/utils/stringifySearchCriteria.ts
|
|
5
|
-
* — the Fresh implementation produces query-string keys that Magento's
|
|
6
|
-
* REST API depends on byte-for-byte. These tests pin the exact bracket
|
|
7
|
-
* layout so the port can't silently drift from prod.
|
|
8
|
-
*/
|
|
9
|
-
import { describe, expect, it } from "vitest";
|
|
10
|
-
import stringifySearchCriteria from "../utils/stringifySearchCriteria";
|
|
11
|
-
|
|
12
|
-
describe("stringifySearchCriteria", () => {
|
|
13
|
-
it("flattens a top-level scalar field into a bracketed key", () => {
|
|
14
|
-
expect(
|
|
15
|
-
stringifySearchCriteria({
|
|
16
|
-
pageSize: 20,
|
|
17
|
-
}),
|
|
18
|
-
).toEqual({
|
|
19
|
-
"searchCriteria[pageSize]": 20,
|
|
20
|
-
});
|
|
21
|
-
});
|
|
22
|
-
|
|
23
|
-
it("flattens nested objects with the [key] path style", () => {
|
|
24
|
-
expect(
|
|
25
|
-
stringifySearchCriteria({
|
|
26
|
-
filterGroups: [
|
|
27
|
-
{
|
|
28
|
-
filters: [{ field: "sku", value: "ABC" }],
|
|
29
|
-
},
|
|
30
|
-
],
|
|
31
|
-
}),
|
|
32
|
-
).toEqual({
|
|
33
|
-
"searchCriteria[filterGroups][0][filters][0][field]": "sku",
|
|
34
|
-
"searchCriteria[filterGroups][0][filters][0][value]": "ABC",
|
|
35
|
-
});
|
|
36
|
-
});
|
|
37
|
-
|
|
38
|
-
it("handles multiple filterGroups (OR semantics in Magento)", () => {
|
|
39
|
-
const out = stringifySearchCriteria({
|
|
40
|
-
filterGroups: [
|
|
41
|
-
{ filters: [{ field: "sku", value: "A" }] },
|
|
42
|
-
{ filters: [{ field: "sku", value: "B" }] },
|
|
43
|
-
],
|
|
44
|
-
});
|
|
45
|
-
expect(out).toEqual({
|
|
46
|
-
"searchCriteria[filterGroups][0][filters][0][field]": "sku",
|
|
47
|
-
"searchCriteria[filterGroups][0][filters][0][value]": "A",
|
|
48
|
-
"searchCriteria[filterGroups][1][filters][0][field]": "sku",
|
|
49
|
-
"searchCriteria[filterGroups][1][filters][0][value]": "B",
|
|
50
|
-
});
|
|
51
|
-
});
|
|
52
|
-
|
|
53
|
-
it("returns an empty object for empty input", () => {
|
|
54
|
-
expect(stringifySearchCriteria({})).toEqual({});
|
|
55
|
-
});
|
|
56
|
-
});
|
|
@@ -1,346 +0,0 @@
|
|
|
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
|
-
});
|