@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 ADDED
@@ -0,0 +1,47 @@
1
+ {
2
+ "name": "@decocms/apps-magento",
3
+ "version": "7.2.0",
4
+ "type": "module",
5
+ "description": "Deco commerce app: Magento integration",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "https://github.com/decocms/blocks.git",
9
+ "directory": "packages/apps-magento"
10
+ },
11
+ "main": "./src/index.ts",
12
+ "exports": {
13
+ ".": "./src/index.ts",
14
+ "./client": "./src/client.ts",
15
+ "./types": "./src/types.ts",
16
+ "./middleware": "./src/middleware.ts",
17
+ "./loaders/*": "./src/loaders/*.ts",
18
+ "./actions/*": "./src/actions/*.ts",
19
+ "./utils/*": "./src/utils/*.ts",
20
+ "./hooks/*": "./src/hooks/*.ts"
21
+ },
22
+ "scripts": {
23
+ "build": "tsc",
24
+ "test": "vitest run --root ../.. packages/apps-magento/",
25
+ "typecheck": "tsc --noEmit",
26
+ "lint:unused": "knip"
27
+ },
28
+ "dependencies": {
29
+ "@decocms/blocks": "7.2.0",
30
+ "@decocms/apps-commerce": "7.2.0",
31
+ "@decocms/tanstack": "7.2.0"
32
+ },
33
+ "peerDependencies": {
34
+ "react": "^19.0.0",
35
+ "react-dom": "^19.0.0"
36
+ },
37
+ "devDependencies": {
38
+ "@types/react": "^19.0.0",
39
+ "@types/react-dom": "^19.0.0",
40
+ "knip": "^5.86.0",
41
+ "typescript": "^5.9.0"
42
+ },
43
+ "publishConfig": {
44
+ "registry": "https://registry.npmjs.org",
45
+ "access": "public"
46
+ }
47
+ }
package/src/README.md ADDED
@@ -0,0 +1,65 @@
1
+ # Magento app — initial scaffold
2
+
3
+ This folder ports the Magento integration from `deco-cx/apps/magento`
4
+ (Fresh/Deno) to `@decocms/apps/magento` (TanStack Start/Node), following
5
+ the same shape as the existing `vtex/` and `shopify/` packages.
6
+
7
+ ## Status
8
+
9
+ **Initial scaffold** — covers the configure/client surface and 2 reference
10
+ loaders (`features`, `cart`) so that downstream sites can wire magento at
11
+ all. The remaining 20+ loaders/actions exist as production-grade code in
12
+ the original deco-cx/apps repo and need adaptation passes (Deno → Node,
13
+ ctx-based to client-based state access, cookie helpers from
14
+ `@decocms/start/sdk/cookie`).
15
+
16
+ A real-world consumer (deco-sites/granadobr-tanstack) is wiring magento
17
+ in-site today using a thin adapter that wraps the legacy `magento/mod.ts`
18
+ shape. Their adapter is the migration target — once this package covers
19
+ the surface area they need, the in-site copy goes away.
20
+
21
+ ## What's here
22
+
23
+ - `client.ts` — `configureMagento({ baseUrl, apiKey, storeId, ... })` +
24
+ `getMagentoConfig()` global accessor. Mirrors the `configureVtex`
25
+ pattern.
26
+ - `types.ts` — request/response shapes shared between loaders.
27
+ - `loaders/features.ts` — returns the feature flags block. The simplest
28
+ loader, used as a smoke test.
29
+ - `loaders/cart.ts` — fetches the customer's active cart by cookie.
30
+ Pulls cookies via `@decocms/start/sdk/cookie`.
31
+ - `middleware.ts` — passthrough today; real-world cart-id reconciliation
32
+ lives in the consumer site for now.
33
+ - `index.ts` — re-export entry.
34
+
35
+ ## Pending port (PR follow-ups)
36
+
37
+ | Path | Original location |
38
+ |---|---|
39
+ | `loaders/product/{detailsPage,detailsPageGQL,listingPage,list,relatedProducts}.ts` | `deco-cx/apps/magento/loaders/product/*` |
40
+ | `loaders/{proxy,user,wishlist}.ts` | `deco-cx/apps/magento/loaders/*` |
41
+ | `loaders/routes/getRouteType.ts` | idem |
42
+ | `actions/cart/{addCoupon,addItem,removeCoupon,removeItem,setSimulation,simulation,updateItem}.ts` | `deco-cx/apps/magento/actions/cart/*` |
43
+ | `actions/newsletter/subscribe.ts` | idem |
44
+ | `actions/product/stockAlert.ts` | idem |
45
+ | `actions/wishlist/{addItem,removeItem}.ts` | idem |
46
+ | `utils/{clientGraphql,client,transform,cache,graphql,cart}.ts` | `deco-cx/apps/magento/utils/*` |
47
+ | `hooks/{useCart,useUser,useWishlist}.ts` | `deco-cx/apps/magento/hooks/*` (refactor to react-query, like `vtex/hooks/`) |
48
+ | `inline-loaders/*` | new, follow `vtex/inline-loaders/` shape |
49
+
50
+ **Site-specific extensions (Livelo, Amasty)** in the deco-cx/apps repo
51
+ should stay out of this package — they belong in consumer sites via the
52
+ `ExtensionOf` pattern. Preserving that pattern in the generic loaders
53
+ above is a hard requirement of this port.
54
+
55
+ ## Why a stub now
56
+
57
+ The deco-sites/granadobr-tanstack migration hit a HIGH parity finding:
58
+
59
+ ```
60
+ invoke(magento/loaders/features) failed: handler not found
61
+ ```
62
+
63
+ …because no `@decocms/apps/magento/*` resolver existed. The site is
64
+ working around it locally; this PR begins the upstream fix so future
65
+ magento sites don't have to repeat the in-site adapter.
@@ -0,0 +1,139 @@
1
+ /**
2
+ * Tests for the cart loader.
3
+ *
4
+ * Parity goals against deco-cx/apps/magento/loaders/cart.ts (Fresh/Deno,
5
+ * prod):
6
+ *
7
+ * - Reads `dataservices_cart_id` cookie when caller doesn't supply
8
+ * `cartId` in props (matches `getCartCookie(req.headers)` in prod).
9
+ * - Honors props.cartId override (matches `_cartId ?? getCartCookie()`).
10
+ * - Hits /rest/:site/V1/carts/:cartId — the /rest/ prefix is what the
11
+ * Magento REST API exposes; the legacy clientAdmin's typed key was
12
+ * "GET /rest/:site/V1/carts/:cartId".
13
+ * - Encodes site + cartId so a malicious cookie can't escape the path
14
+ * segment and reach another admin endpoint with the Bearer token.
15
+ * - Returns null when no cookie (anonymous visitor — matches prod's
16
+ * `if (!cartId) return null`).
17
+ * - Returns null on 404 (expired cookie — prod hits this via the same
18
+ * branch but throws on other non-2xx; we surface a plain Error).
19
+ *
20
+ * NOT covered by this initial port (and intentionally not tested here):
21
+ * - Pre-cart `GET /rest/:site/V1/carts/mine` warm-up for logged-in
22
+ * users (the original does it inside a try/catch and discards the
23
+ * result — it's a cache primer, not a correctness invariant).
24
+ * - Parallel /totals fetch + image-pipeline transform — those land in
25
+ * follow-up PRs that port utils/cart.ts + utils/cache.ts.
26
+ */
27
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
28
+ import { configureMagento } from "../client";
29
+ import cart from "../loaders/cart";
30
+
31
+ function mockResponse(body: unknown, status = 200): Response {
32
+ return new Response(JSON.stringify(body), {
33
+ status,
34
+ headers: { "content-type": "application/json" },
35
+ });
36
+ }
37
+
38
+ function requestWithCookie(cookie: string): Request {
39
+ const r = new Request("http://localhost/");
40
+ // Headers in a freshly-constructed Request enter "request" guard mode
41
+ // which silently drops `cookie`. Build a Headers manually then attach.
42
+ const headers = new Headers();
43
+ headers.set("cookie", cookie);
44
+ Object.defineProperty(r, "headers", { value: headers, configurable: true });
45
+ return r;
46
+ }
47
+
48
+ describe("cart loader", () => {
49
+ let fetchSpy: ReturnType<typeof vi.spyOn>;
50
+
51
+ beforeEach(() => {
52
+ configureMagento({
53
+ baseUrl: "https://loja.example.com/",
54
+ apiKey: "secret",
55
+ storeId: 1,
56
+ site: "example",
57
+ });
58
+ fetchSpy = vi.spyOn(globalThis, "fetch");
59
+ });
60
+
61
+ afterEach(() => {
62
+ vi.restoreAllMocks();
63
+ });
64
+
65
+ it("returns null when no cart cookie is present", async () => {
66
+ const req = new Request("http://localhost/");
67
+ const result = await cart(undefined, req);
68
+ expect(result).toBeNull();
69
+ expect(fetchSpy).not.toHaveBeenCalled();
70
+ });
71
+
72
+ it("reads cartId from the dataservices_cart_id cookie (JSON-encoded)", async () => {
73
+ fetchSpy.mockResolvedValue(mockResponse({ id: "abc123", items: [] }));
74
+ const req = requestWithCookie('dataservices_cart_id="abc123"');
75
+ await cart(undefined, req);
76
+ const [target] = fetchSpy.mock.calls[0] as [URL];
77
+ expect(target.toString()).toBe("https://loja.example.com/rest/example/V1/carts/abc123");
78
+ });
79
+
80
+ it("reads cartId from the cookie when it's not JSON-quoted", async () => {
81
+ fetchSpy.mockResolvedValue(mockResponse({ id: "raw-id", items: [] }));
82
+ const req = requestWithCookie("dataservices_cart_id=raw-id");
83
+ await cart(undefined, req);
84
+ const [target] = fetchSpy.mock.calls[0] as [URL];
85
+ expect(target.toString()).toBe("https://loja.example.com/rest/example/V1/carts/raw-id");
86
+ });
87
+
88
+ it("honors props.cartId override (cookie ignored)", async () => {
89
+ fetchSpy.mockResolvedValue(mockResponse({ id: "from-props", items: [] }));
90
+ const req = requestWithCookie('dataservices_cart_id="from-cookie"');
91
+ await cart({ cartId: "from-props" }, req);
92
+ const [target] = fetchSpy.mock.calls[0] as [URL];
93
+ expect(target.toString()).toContain("/rest/example/V1/carts/from-props");
94
+ });
95
+
96
+ it("URL-encodes the cartId to prevent path injection", async () => {
97
+ // A malicious cookie value tries to break out of the path segment
98
+ // and reach a different admin endpoint with the Bearer attached.
99
+ fetchSpy.mockResolvedValue(mockResponse(null, 404));
100
+ const req = requestWithCookie('dataservices_cart_id="../admin/leak?secret=1"');
101
+ await cart(undefined, req);
102
+ const [target] = fetchSpy.mock.calls[0] as [URL];
103
+ // Encoded: %2F (slash), %3F (?), %3D (=). The trailing
104
+ // "/admin/leak?secret=1" stays inside the path segment.
105
+ expect(target.pathname).toMatch(
106
+ /^\/rest\/example\/V1\/carts\/(\.\.|%2E%2E)%2Fadmin%2Fleak%3Fsecret%3D1$/i,
107
+ );
108
+ });
109
+
110
+ it("returns null on 404 (expired cart cookie)", async () => {
111
+ fetchSpy.mockResolvedValue(mockResponse(null, 404));
112
+ const req = requestWithCookie('dataservices_cart_id="stale"');
113
+ const result = await cart(undefined, req);
114
+ expect(result).toBeNull();
115
+ });
116
+
117
+ it("throws on non-404 errors", async () => {
118
+ fetchSpy.mockResolvedValue(mockResponse(null, 500));
119
+ const req = requestWithCookie('dataservices_cart_id="abc"');
120
+ await expect(cart(undefined, req)).rejects.toThrow(/cart loader: 500/);
121
+ });
122
+
123
+ it("attaches Bearer + x-origin-header (same-origin) on the cart fetch", async () => {
124
+ configureMagento({
125
+ baseUrl: "https://loja.example.com/",
126
+ apiKey: "secret",
127
+ storeId: 1,
128
+ site: "example",
129
+ originHeader: "origin-secret",
130
+ });
131
+ fetchSpy.mockResolvedValue(mockResponse({ id: "abc", items: [] }));
132
+ const req = requestWithCookie('dataservices_cart_id="abc"');
133
+ await cart(undefined, req);
134
+ const [, init] = fetchSpy.mock.calls[0] as [URL, RequestInit];
135
+ const headers = init.headers as Headers;
136
+ expect(headers.get("authorization")).toBe("Bearer secret");
137
+ expect(headers.get("x-origin-header")).toBe("origin-secret");
138
+ });
139
+ });
@@ -0,0 +1,252 @@
1
+ /**
2
+ * Tests for the Magento client config + magentoFetch wrapper.
3
+ *
4
+ * Parity goals — behavior these tests pin down so the port stays aligned
5
+ * with deco-cx/apps/magento (Fresh/Deno, prod):
6
+ *
7
+ * - configureMagento / getMagentoConfig is a write-once-read-many global
8
+ * (mirrors configureVtex). getMagentoConfig() throws before configure().
9
+ * - magentoFetch:
10
+ * • Same-origin: attaches Authorization (Bearer apiKey),
11
+ * x-origin-header (when configured), and a forced Referer pointing
12
+ * at baseUrl — exactly the headers the Fresh `clientAdmin` was
13
+ * built with at App() time.
14
+ * • Cross-origin: strips ALL Magento-only identity headers so the
15
+ * admin Bearer + origin secret never leak to a third party. Caller
16
+ * headers pass through.
17
+ * • authenticated:false opt-out drops Bearer even on same-origin.
18
+ * • Relative paths resolve against baseUrl with proper "/" handling.
19
+ * • Absolute https://… paths bypass baseUrl entirely.
20
+ */
21
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
22
+ import { configureMagento, getMagentoConfig, magentoFetch } from "../client";
23
+
24
+ // Reset module-global config between tests by re-importing.
25
+ beforeEach(() => {
26
+ vi.resetModules();
27
+ });
28
+
29
+ afterEach(() => {
30
+ vi.restoreAllMocks();
31
+ });
32
+
33
+ describe("configureMagento / getMagentoConfig", () => {
34
+ it("throws before configureMagento() is called", async () => {
35
+ const { getMagentoConfig: freshGet } = await import("../client");
36
+ expect(() => freshGet()).toThrow(/configureMagento\(\) must be called/);
37
+ });
38
+
39
+ it("returns the configured value after configureMagento()", async () => {
40
+ const { configureMagento: c, getMagentoConfig: g } = await import("../client");
41
+ c({
42
+ baseUrl: "https://loja.example.com/",
43
+ apiKey: "test-key",
44
+ storeId: 1,
45
+ site: "example",
46
+ });
47
+ expect(g().baseUrl).toBe("https://loja.example.com/");
48
+ expect(g().apiKey).toBe("test-key");
49
+ });
50
+ });
51
+
52
+ describe("magentoFetch — same-origin (configured baseUrl)", () => {
53
+ const baseUrl = "https://loja.example.com/";
54
+ let fetchSpy: ReturnType<typeof vi.spyOn>;
55
+
56
+ beforeEach(() => {
57
+ configureMagento({
58
+ baseUrl,
59
+ apiKey: "secret-bearer",
60
+ storeId: 1,
61
+ site: "example",
62
+ originHeader: "origin-secret",
63
+ });
64
+ fetchSpy = vi
65
+ .spyOn(globalThis, "fetch")
66
+ .mockResolvedValue(
67
+ new Response("{}", { status: 200, headers: { "content-type": "application/json" } }),
68
+ );
69
+ });
70
+
71
+ it("attaches Authorization, x-origin-header, and forced Referer", async () => {
72
+ await magentoFetch("/rest/example/V1/carts/123");
73
+ const [, init] = fetchSpy.mock.calls[0] as [URL, RequestInit];
74
+ const headers = init.headers as Headers;
75
+ expect(headers.get("authorization")).toBe("Bearer secret-bearer");
76
+ expect(headers.get("x-origin-header")).toBe("origin-secret");
77
+ expect(headers.get("referer")).toBe(baseUrl);
78
+ });
79
+
80
+ it("resolves relative path against baseUrl with correct slash handling", async () => {
81
+ await magentoFetch("rest/example/V1/carts/123"); // no leading slash
82
+ const [target] = fetchSpy.mock.calls[0] as [URL, RequestInit];
83
+ expect(target.toString()).toBe("https://loja.example.com/rest/example/V1/carts/123");
84
+ });
85
+
86
+ it("authenticated:false suppresses Bearer even on same-origin", async () => {
87
+ await magentoFetch("/rest/example/V1/carts/123", { authenticated: false });
88
+ const [, init] = fetchSpy.mock.calls[0] as [URL, RequestInit];
89
+ const headers = init.headers as Headers;
90
+ expect(headers.get("authorization")).toBeNull();
91
+ // Same-origin still gets the other Magento-identity headers.
92
+ expect(headers.get("x-origin-header")).toBe("origin-secret");
93
+ });
94
+
95
+ it("preserves caller-supplied Referer (no force-overwrite when caller set one)", async () => {
96
+ await magentoFetch("/rest/example/V1/carts/123", {
97
+ headers: { Referer: "https://caller.example/page" },
98
+ });
99
+ const [, init] = fetchSpy.mock.calls[0] as [URL, RequestInit];
100
+ const headers = init.headers as Headers;
101
+ expect(headers.get("referer")).toBe("https://caller.example/page");
102
+ });
103
+ });
104
+
105
+ describe("magentoFetch — cross-origin guard", () => {
106
+ let fetchSpy: ReturnType<typeof vi.spyOn>;
107
+
108
+ beforeEach(() => {
109
+ configureMagento({
110
+ baseUrl: "https://loja.example.com/",
111
+ apiKey: "secret-bearer",
112
+ storeId: 1,
113
+ site: "example",
114
+ originHeader: "origin-secret",
115
+ });
116
+ fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response("{}", { status: 200 }));
117
+ });
118
+
119
+ it("strips Bearer when fetching a non-Magento host", async () => {
120
+ await magentoFetch("https://attacker.example/api");
121
+ const [, init] = fetchSpy.mock.calls[0] as [URL, RequestInit];
122
+ const headers = init.headers as Headers;
123
+ expect(headers.get("authorization")).toBeNull();
124
+ });
125
+
126
+ it("strips x-origin-header when fetching a non-Magento host", async () => {
127
+ // Regression for cubic review: the previous fix only dropped Bearer
128
+ // while x-origin-header and Referer still leaked.
129
+ await magentoFetch("https://attacker.example/api");
130
+ const [, init] = fetchSpy.mock.calls[0] as [URL, RequestInit];
131
+ const headers = init.headers as Headers;
132
+ expect(headers.get("x-origin-header")).toBeNull();
133
+ });
134
+
135
+ it("strips forced Referer when fetching a non-Magento host", async () => {
136
+ await magentoFetch("https://attacker.example/api");
137
+ const [, init] = fetchSpy.mock.calls[0] as [URL, RequestInit];
138
+ const headers = init.headers as Headers;
139
+ // Referer to https://loja.example.com/ would broadcast the Magento
140
+ // host to the third party — must not be set by us.
141
+ expect(headers.get("referer")).toBeNull();
142
+ });
143
+
144
+ it("still forwards caller-supplied headers cross-origin", async () => {
145
+ await magentoFetch("https://partner.example/api", {
146
+ headers: { "x-correlation-id": "abc123" },
147
+ });
148
+ const [, init] = fetchSpy.mock.calls[0] as [URL, RequestInit];
149
+ const headers = init.headers as Headers;
150
+ expect(headers.get("x-correlation-id")).toBe("abc123");
151
+ });
152
+
153
+ it("treats an absolute URL with the same origin as same-origin", async () => {
154
+ await magentoFetch("https://loja.example.com/rest/example/V1/carts/123");
155
+ const [target, init] = fetchSpy.mock.calls[0] as [URL, RequestInit];
156
+ expect(target.toString()).toBe("https://loja.example.com/rest/example/V1/carts/123");
157
+ expect((init.headers as Headers).get("authorization")).toBe("Bearer secret-bearer");
158
+ });
159
+ });
160
+
161
+ describe("initMagentoFromBlocks — secret resolution", () => {
162
+ beforeEach(() => {
163
+ vi.resetModules();
164
+ });
165
+
166
+ it("returns early when the `magento` block is absent", async () => {
167
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
168
+ const { initMagentoFromBlocks, getMagentoConfig } = await import("../client");
169
+ await initMagentoFromBlocks({});
170
+ expect(warn).toHaveBeenCalledWith(expect.stringContaining("No `magento` block"));
171
+ expect(() => getMagentoConfig()).toThrow(/configureMagento\(\) must be called/);
172
+ });
173
+
174
+ it("reads plain-string apiKey directly", async () => {
175
+ const { initMagentoFromBlocks, getMagentoConfig } = await import("../client");
176
+ await initMagentoFromBlocks({
177
+ magento: {
178
+ apiConfig: {
179
+ baseUrl: "https://loja.example.com/",
180
+ apiKey: "plain-string-key",
181
+ site: "example",
182
+ storeId: 1,
183
+ },
184
+ },
185
+ });
186
+ expect(getMagentoConfig().apiKey).toBe("plain-string-key");
187
+ });
188
+
189
+ it("dereferences a Secret-shaped apiKey via process.env (the env fallback path)", async () => {
190
+ process.env.TEST_MAGENTO_KEY = "from-env";
191
+ const { initMagentoFromBlocks, getMagentoConfig } = await import("../client");
192
+ await initMagentoFromBlocks({
193
+ magento: {
194
+ apiConfig: {
195
+ baseUrl: "https://loja.example.com/",
196
+ apiKey: {
197
+ __resolveType: "website/loaders/secret.ts",
198
+ name: "TEST_MAGENTO_KEY",
199
+ },
200
+ site: "example",
201
+ storeId: 1,
202
+ },
203
+ },
204
+ });
205
+ expect(getMagentoConfig().apiKey).toBe("from-env");
206
+ delete process.env.TEST_MAGENTO_KEY;
207
+ });
208
+
209
+ it("falls back to empty string when secret is unresolvable", async () => {
210
+ // no DECO_CRYPTO_KEY, no env var with this name, no decrypt
211
+ // → resolveSecret returns null → init writes "".
212
+ delete process.env.DECO_CRYPTO_KEY;
213
+ const { initMagentoFromBlocks, getMagentoConfig } = await import("../client");
214
+ await initMagentoFromBlocks({
215
+ magento: {
216
+ apiConfig: {
217
+ baseUrl: "https://loja.example.com/",
218
+ apiKey: {
219
+ __resolveType: "website/loaders/secret.ts",
220
+ encrypted: "deadbeef",
221
+ name: "UNDEFINED_ENV_VAR_DO_NOT_SET",
222
+ },
223
+ site: "example",
224
+ storeId: 1,
225
+ },
226
+ },
227
+ });
228
+ expect(getMagentoConfig().apiKey).toBe("");
229
+ });
230
+
231
+ it("resolves both apiKey and originHeader independently", async () => {
232
+ process.env.TEST_API_KEY = "api-from-env";
233
+ process.env.TEST_ORIGIN = "origin-from-env";
234
+ const { initMagentoFromBlocks, getMagentoConfig } = await import("../client");
235
+ await initMagentoFromBlocks({
236
+ magento: {
237
+ apiConfig: {
238
+ baseUrl: "https://loja.example.com/",
239
+ apiKey: { name: "TEST_API_KEY" },
240
+ originHeader: { name: "TEST_ORIGIN" },
241
+ site: "example",
242
+ storeId: 1,
243
+ },
244
+ },
245
+ });
246
+ const cfg = getMagentoConfig();
247
+ expect(cfg.apiKey).toBe("api-from-env");
248
+ expect(cfg.originHeader).toBe("origin-from-env");
249
+ delete process.env.TEST_API_KEY;
250
+ delete process.env.TEST_ORIGIN;
251
+ });
252
+ });
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Tests for the features loader.
3
+ *
4
+ * Parity with deco-cx/apps/magento/loaders/features.ts (Fresh/Deno, prod):
5
+ * the legacy loader was a 3-arg `(_props, _req, ctx) => ctx.features`.
6
+ * The ported version uses the module-global config instead of a per-
7
+ * request ctx, but the surface area downstream is the same — a plain
8
+ * object pulled from the resolved magento CMS block.
9
+ */
10
+ import { beforeEach, describe, expect, it } from "vitest";
11
+ import { configureMagento } from "../client";
12
+ import features from "../loaders/features";
13
+
14
+ describe("features loader", () => {
15
+ beforeEach(() => {
16
+ configureMagento({
17
+ baseUrl: "https://loja.example.com/",
18
+ apiKey: "key",
19
+ storeId: 1,
20
+ site: "example",
21
+ features: {
22
+ dangerouslyDisableWishlist: false,
23
+ dangerouslyDisableOnLoadUpdate: true,
24
+ dangerouslyReturnNullAfterAction: true,
25
+ dangerouslyDontReturnCartAfterAction: true,
26
+ dangerouslyDisableOnVisibilityChangeUpdate: true,
27
+ },
28
+ });
29
+ });
30
+
31
+ it("returns the feature flags object from the config", () => {
32
+ expect(features()).toEqual({
33
+ dangerouslyDisableWishlist: false,
34
+ dangerouslyDisableOnLoadUpdate: true,
35
+ dangerouslyReturnNullAfterAction: true,
36
+ dangerouslyDontReturnCartAfterAction: true,
37
+ dangerouslyDisableOnVisibilityChangeUpdate: true,
38
+ });
39
+ });
40
+
41
+ it("returns an empty object when features are not configured", () => {
42
+ configureMagento({
43
+ baseUrl: "https://loja.example.com/",
44
+ apiKey: "key",
45
+ storeId: 1,
46
+ site: "example",
47
+ // features omitted
48
+ });
49
+ expect(features()).toEqual({});
50
+ });
51
+ });