@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,252 +0,0 @@
|
|
|
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
|
-
});
|
|
@@ -1,51 +0,0 @@
|
|
|
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
|
-
});
|
|
@@ -1,183 +0,0 @@
|
|
|
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("/acme/")).toBe("acme/");
|
|
152
|
-
});
|
|
153
|
-
|
|
154
|
-
it("appends trailing slash when missing", () => {
|
|
155
|
-
expect(formatUrlSuffix("acme")).toBe("acme/");
|
|
156
|
-
});
|
|
157
|
-
|
|
158
|
-
it("leaves trailing slash alone", () => {
|
|
159
|
-
expect(formatUrlSuffix("acme/")).toBe("acme/");
|
|
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
|
-
});
|
|
@@ -1,166 +0,0 @@
|
|
|
1
|
-
// @vitest-environment node
|
|
2
|
-
/**
|
|
3
|
-
* The v8 Magento client (/next/upstream-clients): every request goes through
|
|
4
|
-
* the instrumented fetch as provider "magento", named by its operation; the
|
|
5
|
-
* store's credentials only go to the store; errors never carry bodies.
|
|
6
|
-
*/
|
|
7
|
-
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
8
|
-
|
|
9
|
-
const instrumented = vi.hoisted(() => ({
|
|
10
|
-
providers: [] as string[],
|
|
11
|
-
operations: [] as (string | undefined)[],
|
|
12
|
-
}));
|
|
13
|
-
|
|
14
|
-
vi.mock("@decocms/blocks/fetch", async (importOriginal) => {
|
|
15
|
-
const real = await importOriginal<typeof import("@decocms/blocks/fetch")>();
|
|
16
|
-
return {
|
|
17
|
-
createInstrumentedFetch: (options: Parameters<typeof real.createInstrumentedFetch>[0]) => {
|
|
18
|
-
instrumented.providers.push(options.provider);
|
|
19
|
-
const request = real.createInstrumentedFetch(options);
|
|
20
|
-
return (input: string | URL | Request, init?: RequestInit & { operation?: string }) => {
|
|
21
|
-
instrumented.operations.push(init?.operation);
|
|
22
|
-
return request(input, init);
|
|
23
|
-
};
|
|
24
|
-
},
|
|
25
|
-
};
|
|
26
|
-
});
|
|
27
|
-
|
|
28
|
-
import { createMagentoClient, MagentoError } from "../magentoClient";
|
|
29
|
-
|
|
30
|
-
/** The error a call rejects with (fails the test if it resolves). */
|
|
31
|
-
const failure = (call: Promise<unknown>): Promise<Error> =>
|
|
32
|
-
call.then(
|
|
33
|
-
() => {
|
|
34
|
-
throw new Error("expected the call to fail");
|
|
35
|
-
},
|
|
36
|
-
(error: Error) => error,
|
|
37
|
-
);
|
|
38
|
-
|
|
39
|
-
function upstream(body: unknown, status = 200) {
|
|
40
|
-
return vi.fn(
|
|
41
|
-
async (_input: string | URL | Request, _init?: RequestInit) =>
|
|
42
|
-
new Response(JSON.stringify(body), { status }),
|
|
43
|
-
);
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
const config = { baseUrl: "https://store.example.com/", apiKey: "key", originHeader: "origin" };
|
|
47
|
-
|
|
48
|
-
beforeEach(() => {
|
|
49
|
-
instrumented.providers.length = 0;
|
|
50
|
-
instrumented.operations.length = 0;
|
|
51
|
-
});
|
|
52
|
-
|
|
53
|
-
describe("createMagentoClient", () => {
|
|
54
|
-
it("calls REST with the store credentials, labeled by operation", async () => {
|
|
55
|
-
const fetch = upstream({ id: 7 });
|
|
56
|
-
const magento = createMagentoClient(config, { fetch });
|
|
57
|
-
|
|
58
|
-
expect(
|
|
59
|
-
await magento.rest<{ id: number }>("/rest/default/V1/carts/7", { operation: "getCart" }),
|
|
60
|
-
).toEqual({ id: 7 });
|
|
61
|
-
expect(instrumented.providers).toEqual(["magento"]);
|
|
62
|
-
expect(instrumented.operations).toEqual(["getCart"]);
|
|
63
|
-
const [url, init] = fetch.mock.calls[0]!;
|
|
64
|
-
expect(String(url)).toBe("https://store.example.com/rest/default/V1/carts/7");
|
|
65
|
-
const headers = new Headers(init?.headers);
|
|
66
|
-
expect(headers.get("authorization")).toBe("Bearer key");
|
|
67
|
-
expect(headers.get("x-origin-header")).toBe("origin");
|
|
68
|
-
});
|
|
69
|
-
|
|
70
|
-
it("omits the token when a call opts out", async () => {
|
|
71
|
-
const fetch = upstream({});
|
|
72
|
-
const magento = createMagentoClient(config, { fetch });
|
|
73
|
-
await magento.request("/customer/section/load", {
|
|
74
|
-
operation: "loadSections",
|
|
75
|
-
authenticated: false,
|
|
76
|
-
});
|
|
77
|
-
expect(new Headers(fetch.mock.calls[0]![1]?.headers).get("authorization")).toBeNull();
|
|
78
|
-
});
|
|
79
|
-
|
|
80
|
-
it("refuses URLs on another origin, so the token never leaves the store", async () => {
|
|
81
|
-
const fetch = upstream({});
|
|
82
|
-
const magento = createMagentoClient(config, { fetch });
|
|
83
|
-
await expect(magento.request("https://evil.example/steal", { operation: "x" })).rejects.toThrow(
|
|
84
|
-
/only paths on the configured store/,
|
|
85
|
-
);
|
|
86
|
-
await expect(magento.request("//evil.example/steal", { operation: "x" })).rejects.toThrow(
|
|
87
|
-
/only paths on the configured store/,
|
|
88
|
-
);
|
|
89
|
-
expect(fetch).not.toHaveBeenCalled();
|
|
90
|
-
});
|
|
91
|
-
|
|
92
|
-
it("keeps a customer Authorization header instead of the store's token", async () => {
|
|
93
|
-
const fetch = upstream({ data: { customer: { email: "a" } }, items: [] });
|
|
94
|
-
const magento = createMagentoClient(config, { fetch });
|
|
95
|
-
await magento.graphql("query Customer { customer { email } }", undefined, {
|
|
96
|
-
operationName: "Customer",
|
|
97
|
-
headers: { Authorization: "Bearer customer" },
|
|
98
|
-
});
|
|
99
|
-
await magento.rest("/rest/default/V1/carts/mine", {
|
|
100
|
-
operation: "getCart",
|
|
101
|
-
headers: { Authorization: "Bearer customer" },
|
|
102
|
-
});
|
|
103
|
-
for (const [, init] of fetch.mock.calls) {
|
|
104
|
-
expect(new Headers(init?.headers).get("authorization")).toBe("Bearer customer");
|
|
105
|
-
}
|
|
106
|
-
});
|
|
107
|
-
|
|
108
|
-
it("sends GraphQL without the store's token unless asked, and keeps the JSON content type", async () => {
|
|
109
|
-
const fetch = upstream({ data: {} });
|
|
110
|
-
const magento = createMagentoClient(config, { fetch });
|
|
111
|
-
await magento.graphql("query Q { a }", undefined, {
|
|
112
|
-
operationName: "Q",
|
|
113
|
-
headers: { "Content-Type": "text/plain" },
|
|
114
|
-
});
|
|
115
|
-
await magento.graphql("query Q { a }", undefined, { operationName: "Q", authenticated: true });
|
|
116
|
-
const [first, second] = fetch.mock.calls.map(([, init]) => new Headers(init?.headers));
|
|
117
|
-
expect(first!.get("authorization")).toBeNull();
|
|
118
|
-
expect(first!.get("content-type")).toBe("application/json");
|
|
119
|
-
expect(second!.get("authorization")).toBe("Bearer key");
|
|
120
|
-
});
|
|
121
|
-
|
|
122
|
-
it("returns undefined for a 204 REST response", async () => {
|
|
123
|
-
const fetch = vi.fn(async () => new Response(null, { status: 204 }));
|
|
124
|
-
const magento = createMagentoClient(config, { fetch });
|
|
125
|
-
await expect(
|
|
126
|
-
magento.rest("/rest/default/V1/carts/mine/items/1", {
|
|
127
|
-
operation: "removeCartItem",
|
|
128
|
-
method: "DELETE",
|
|
129
|
-
}),
|
|
130
|
-
).resolves.toBeUndefined();
|
|
131
|
-
});
|
|
132
|
-
|
|
133
|
-
it("sends GraphQL with its operationName, which is the label", async () => {
|
|
134
|
-
const fetch = upstream({ data: { productStockAlert: { status: true } } });
|
|
135
|
-
const magento = createMagentoClient(config, { fetch });
|
|
136
|
-
const data = await magento.graphql<{ productStockAlert: { status: boolean } }>(
|
|
137
|
-
"mutation ProductStockAlert($sku: String!) { productStockAlert(sku: $sku) { status } }",
|
|
138
|
-
{ sku: "A1" },
|
|
139
|
-
{ operationName: "ProductStockAlert", headers: { Store: "default" } },
|
|
140
|
-
);
|
|
141
|
-
expect(data.productStockAlert.status).toBe(true);
|
|
142
|
-
expect(instrumented.operations).toEqual(["ProductStockAlert"]);
|
|
143
|
-
const [url, init] = fetch.mock.calls[0]!;
|
|
144
|
-
expect(String(url)).toBe("https://store.example.com/graphql");
|
|
145
|
-
expect(new Headers(init?.headers).get("store")).toBe("default");
|
|
146
|
-
expect(JSON.parse(String(init?.body))).toMatchObject({
|
|
147
|
-
operationName: "ProductStockAlert",
|
|
148
|
-
variables: { sku: "A1" },
|
|
149
|
-
});
|
|
150
|
-
});
|
|
151
|
-
|
|
152
|
-
it("throws a MagentoError without the body", async () => {
|
|
153
|
-
const magento = createMagentoClient(config, {
|
|
154
|
-
fetch: upstream({ message: "token key invalid" }, 401),
|
|
155
|
-
});
|
|
156
|
-
const error = await failure(magento.rest("/rest/V1/carts/mine", { operation: "getCart" }));
|
|
157
|
-
expect(error).toBeInstanceOf(MagentoError);
|
|
158
|
-
expect(error.message).toBe("magento getCart failed with HTTP 401");
|
|
159
|
-
|
|
160
|
-
const gql = createMagentoClient(config, {
|
|
161
|
-
fetch: upstream({ errors: [{ message: "x@example.com" }] }),
|
|
162
|
-
});
|
|
163
|
-
const gqlError = await failure(gql.graphql("query Q { a }", undefined, { operationName: "Q" }));
|
|
164
|
-
expect(gqlError.message).toBe("magento Q returned 1 GraphQL error(s)");
|
|
165
|
-
});
|
|
166
|
-
});
|