@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.
@@ -0,0 +1,183 @@
1
+ /**
2
+ * Tests for the GraphQL helpers used by product loaders.
3
+ *
4
+ * Parity with deco-cx/apps/magento/utils/graphql.ts — pure functions,
5
+ * so behavior is exhaustively pinnable. Each test mirrors a real call
6
+ * site in the (still-unported) product loaders.
7
+ */
8
+ import { describe, expect, it } from "vitest";
9
+ import {
10
+ filtersFromLoaderGraphQL,
11
+ filtersFromUrlGraphQL,
12
+ formatUrlSuffix,
13
+ getCustomFields,
14
+ transformFilterGraphQL,
15
+ transformFilterValueGraphQL,
16
+ transformSortGraphQL,
17
+ } from "../utils/graphql";
18
+
19
+ describe("transformSortGraphQL", () => {
20
+ it("returns undefined when sortBy is absent", () => {
21
+ expect(transformSortGraphQL({})).toBeUndefined();
22
+ expect(transformSortGraphQL({ order: "DESC" })).toBeUndefined();
23
+ });
24
+
25
+ it("defaults order to ASC when not provided", () => {
26
+ expect(transformSortGraphQL({ sortBy: { value: "price" } })).toEqual({
27
+ price: "ASC",
28
+ });
29
+ });
30
+
31
+ it("respects DESC order", () => {
32
+ expect(transformSortGraphQL({ sortBy: { value: "name" }, order: "DESC" })).toEqual({
33
+ name: "DESC",
34
+ });
35
+ });
36
+
37
+ it("supports custom sort options (any string value)", () => {
38
+ expect(transformSortGraphQL({ sortBy: { value: "best_seller_rank" } as any })).toEqual({
39
+ best_seller_rank: "ASC",
40
+ });
41
+ });
42
+ });
43
+
44
+ describe("transformFilterValueGraphQL", () => {
45
+ it("EQUAL → { eq: value }", () => {
46
+ expect(transformFilterValueGraphQL("ABC", "EQUAL")).toEqual({ eq: "ABC" });
47
+ });
48
+
49
+ it("MATCH → { match: value }", () => {
50
+ expect(transformFilterValueGraphQL("foo bar", "MATCH")).toEqual({
51
+ match: "foo bar",
52
+ });
53
+ });
54
+
55
+ it("RANGE → { from, to } split on the FIRST underscore", () => {
56
+ expect(transformFilterValueGraphQL("10_50", "RANGE")).toEqual({
57
+ from: "10",
58
+ to: "50",
59
+ });
60
+ });
61
+
62
+ it("RANGE with multi-underscore value: only first underscore splits", () => {
63
+ // Magento's URL param `price=10_50_extra` is unusual but pinned
64
+ // to substring(0, idx) + substring(idx+1) which keeps the trailing
65
+ // underscore on the `to` side.
66
+ expect(transformFilterValueGraphQL("10_50_extra", "RANGE")).toEqual({
67
+ from: "10",
68
+ to: "50_extra",
69
+ });
70
+ });
71
+ });
72
+
73
+ describe("filtersFromUrlGraphQL", () => {
74
+ it("picks up known filter keys from URL searchParams", () => {
75
+ const url = new URL("https://x.test/?sku=ABC&color=red&unknown=zzz");
76
+ expect(filtersFromUrlGraphQL(url)).toEqual({
77
+ sku: { eq: "ABC" },
78
+ color: { eq: "red" },
79
+ });
80
+ });
81
+
82
+ it("handles RANGE filters (price)", () => {
83
+ const url = new URL("https://x.test/?price=10_50");
84
+ expect(filtersFromUrlGraphQL(url)).toEqual({
85
+ price: { from: "10", to: "50" },
86
+ });
87
+ });
88
+
89
+ it("handles MATCH filters (name, description)", () => {
90
+ const url = new URL("https://x.test/?name=foo+bar");
91
+ expect(filtersFromUrlGraphQL(url)).toEqual({
92
+ name: { match: "foo bar" },
93
+ });
94
+ });
95
+
96
+ it("layers customFilters on top of defaults", () => {
97
+ const url = new URL("https://x.test/?tag__phebo=fragrancia&sku=X");
98
+ expect(filtersFromUrlGraphQL(url, [{ value: "tag__phebo", type: "EQUAL" }])).toEqual({
99
+ tag__phebo: { eq: "fragrancia" },
100
+ sku: { eq: "X" },
101
+ });
102
+ });
103
+
104
+ it("returns empty object when no filterable param is set", () => {
105
+ expect(filtersFromUrlGraphQL(new URL("https://x.test/?totally=other"))).toEqual({});
106
+ });
107
+ });
108
+
109
+ describe("filtersFromLoaderGraphQL", () => {
110
+ it("returns empty object when undefined", () => {
111
+ expect(filtersFromLoaderGraphQL(undefined)).toEqual({});
112
+ });
113
+
114
+ it("collapses array of FilterProps into a keyed object", () => {
115
+ expect(
116
+ filtersFromLoaderGraphQL([
117
+ { name: "sku", type: { eq: "A" } },
118
+ { name: "color", type: { in: ["red", "blue"] } },
119
+ ]),
120
+ ).toEqual({
121
+ sku: { eq: "A" },
122
+ color: { in: ["red", "blue"] },
123
+ });
124
+ });
125
+ });
126
+
127
+ describe("transformFilterGraphQL — merge order", () => {
128
+ it("loader-derived filters override URL-derived ones on key collisions", () => {
129
+ // A section hard-coding `sale=true` should ignore any conflicting URL hint.
130
+ const url = new URL("https://x.test/?sale=false");
131
+ expect(
132
+ transformFilterGraphQL(url, undefined, [{ name: "sale", type: { eq: "true" } }]),
133
+ ).toEqual({
134
+ sale: { eq: "true" },
135
+ });
136
+ });
137
+
138
+ it("non-colliding sources merge cleanly", () => {
139
+ const url = new URL("https://x.test/?sku=ABC");
140
+ expect(
141
+ transformFilterGraphQL(url, undefined, [{ name: "category_id", type: { eq: "42" } }]),
142
+ ).toEqual({
143
+ sku: { eq: "ABC" },
144
+ category_id: { eq: "42" },
145
+ });
146
+ });
147
+ });
148
+
149
+ describe("formatUrlSuffix", () => {
150
+ it("strips a single leading slash", () => {
151
+ expect(formatUrlSuffix("/granado/")).toBe("granado/");
152
+ });
153
+
154
+ it("appends trailing slash when missing", () => {
155
+ expect(formatUrlSuffix("granado")).toBe("granado/");
156
+ });
157
+
158
+ it("leaves trailing slash alone", () => {
159
+ expect(formatUrlSuffix("granado/")).toBe("granado/");
160
+ });
161
+ });
162
+
163
+ describe("getCustomFields", () => {
164
+ it("returns undefined when active=false", () => {
165
+ expect(getCustomFields({ active: false }, ["a", "b"])).toBeUndefined();
166
+ });
167
+
168
+ it("returns overrideList when present (ignores fallback)", () => {
169
+ expect(getCustomFields({ active: true, overrideList: ["x", "y"] }, ["a", "b"])).toEqual([
170
+ "x",
171
+ "y",
172
+ ]);
173
+ });
174
+
175
+ it("falls back to provided customFields when overrideList is empty/absent", () => {
176
+ expect(getCustomFields({ active: true, overrideList: [] }, ["a", "b"])).toEqual(["a", "b"]);
177
+ expect(getCustomFields({ active: true }, ["a", "b"])).toEqual(["a", "b"]);
178
+ });
179
+
180
+ it("defaults config to {active:false} when omitted", () => {
181
+ expect(getCustomFields(undefined, ["a"])).toBeUndefined();
182
+ });
183
+ });
@@ -0,0 +1,86 @@
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
+ });
@@ -0,0 +1,80 @@
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
+ });
@@ -0,0 +1,56 @@
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
+ });