@doswiftly/storefront-sdk 4.3.0 → 4.5.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.
Files changed (96) hide show
  1. package/README.md +6 -14
  2. package/dist/core/cart/types.d.ts +53 -20
  3. package/dist/core/cart/types.d.ts.map +1 -1
  4. package/dist/core/cart/types.js +3 -0
  5. package/dist/core/image.d.ts +4 -46
  6. package/dist/core/image.d.ts.map +1 -1
  7. package/dist/core/image.js +4 -65
  8. package/dist/core/index.d.ts +1 -1
  9. package/dist/core/index.d.ts.map +1 -1
  10. package/dist/core/index.js +0 -2
  11. package/dist/core/operations/cart.d.ts +15 -9
  12. package/dist/core/operations/cart.d.ts.map +1 -1
  13. package/dist/core/operations/cart.js +130 -58
  14. package/dist/index.d.ts +1 -1
  15. package/dist/index.js +1 -1
  16. package/package.json +9 -4
  17. package/src/__tests__/contract/storefront-api.contract.test.ts +0 -450
  18. package/src/__tests__/unit/auth-client.test.ts +0 -210
  19. package/src/__tests__/unit/bot-protection.test.ts +0 -461
  20. package/src/__tests__/unit/cart-client.test.ts +0 -233
  21. package/src/__tests__/unit/cart-store.test.ts +0 -349
  22. package/src/__tests__/unit/create-client.test.ts +0 -356
  23. package/src/__tests__/unit/helpers.test.ts +0 -377
  24. package/src/__tests__/unit/middleware.test.ts +0 -374
  25. package/src/__tests__/unit/test-helpers.ts +0 -103
  26. package/src/core/auth/auth-client.ts +0 -123
  27. package/src/core/auth/cookie-config.ts +0 -23
  28. package/src/core/auth/handlers.ts +0 -168
  29. package/src/core/auth/routes.ts +0 -26
  30. package/src/core/auth/token-client.ts +0 -51
  31. package/src/core/auth/types.ts +0 -54
  32. package/src/core/bot-protection/abstract-manager.ts +0 -185
  33. package/src/core/bot-protection/create-manager.ts +0 -37
  34. package/src/core/bot-protection/eucaptcha-manager.ts +0 -88
  35. package/src/core/bot-protection/fallback-manager.ts +0 -43
  36. package/src/core/bot-protection/turnstile-manager.ts +0 -92
  37. package/src/core/bot-protection/types/eucaptcha.d.ts +0 -28
  38. package/src/core/bot-protection/types/turnstile.d.ts +0 -33
  39. package/src/core/cache.ts +0 -102
  40. package/src/core/cart/cart-client.ts +0 -150
  41. package/src/core/cart/cookie-config.ts +0 -13
  42. package/src/core/cart/types.ts +0 -104
  43. package/src/core/client/compose.ts +0 -15
  44. package/src/core/client/create-client.ts +0 -129
  45. package/src/core/client/dedupe.ts +0 -19
  46. package/src/core/client/execute.ts +0 -70
  47. package/src/core/client/hash.ts +0 -21
  48. package/src/core/client/operation-name.ts +0 -12
  49. package/src/core/client/types.ts +0 -171
  50. package/src/core/currency/cookie-config.ts +0 -13
  51. package/src/core/errors.ts +0 -67
  52. package/src/core/format.ts +0 -254
  53. package/src/core/helpers/assert-no-user-errors.ts +0 -21
  54. package/src/core/helpers/normalize-connection.ts +0 -48
  55. package/src/core/helpers/sanitize-html.ts +0 -42
  56. package/src/core/image.ts +0 -103
  57. package/src/core/index.ts +0 -180
  58. package/src/core/language/cookie-config.ts +0 -13
  59. package/src/core/middleware/auth.ts +0 -27
  60. package/src/core/middleware/bot-protection.ts +0 -140
  61. package/src/core/middleware/currency.ts +0 -27
  62. package/src/core/middleware/errors.ts +0 -86
  63. package/src/core/middleware/language.ts +0 -30
  64. package/src/core/middleware/retry.ts +0 -75
  65. package/src/core/middleware/timeout.ts +0 -61
  66. package/src/core/operations/auth.ts +0 -123
  67. package/src/core/operations/cart.ts +0 -185
  68. package/src/index.ts +0 -25
  69. package/src/react/bot-protection/bot-protection-context.ts +0 -17
  70. package/src/react/bot-protection/bot-protection-widget.tsx +0 -46
  71. package/src/react/cookies.ts +0 -89
  72. package/src/react/helpers/create-store-context.ts +0 -56
  73. package/src/react/hooks/use-auth.ts +0 -218
  74. package/src/react/hooks/use-bot-protection.ts +0 -31
  75. package/src/react/hooks/use-cart-manager.ts +0 -236
  76. package/src/react/hooks/use-currency.ts +0 -23
  77. package/src/react/hooks/use-debounced-value.ts +0 -30
  78. package/src/react/hooks/use-hydrated.ts +0 -20
  79. package/src/react/hooks/use-storefront-client.ts +0 -12
  80. package/src/react/index.ts +0 -71
  81. package/src/react/providers/currency-provider.tsx +0 -30
  82. package/src/react/providers/language-provider.tsx +0 -34
  83. package/src/react/providers/storefront-client-provider.tsx +0 -107
  84. package/src/react/providers/storefront-provider.tsx +0 -99
  85. package/src/react/server/get-storefront-client.ts +0 -60
  86. package/src/react/server/index.ts +0 -1
  87. package/src/react/stores/auth.store.ts +0 -112
  88. package/src/react/stores/cart.context.ts +0 -10
  89. package/src/react/stores/cart.store.ts +0 -254
  90. package/src/react/stores/currency.store.ts +0 -93
  91. package/src/react/stores/index.ts +0 -17
  92. package/src/react/stores/language.store.ts +0 -90
  93. package/src/react/stores/store-context.tsx +0 -103
  94. package/src/react/types/shop-config.ts +0 -22
  95. package/tsconfig.json +0 -20
  96. package/vitest.config.ts +0 -14
@@ -1,349 +0,0 @@
1
- /**
2
- * Unit tests for createCartStore — DI-based cart state management.
3
- *
4
- * Tests factory creation, cookie persistence, UI actions, initCart orchestration
5
- * (including deduplication), mutations, callbacks, and error handling.
6
- */
7
-
8
- import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
9
- import { createCartStore, type CartActions, type CartData } from '../../react/stores/cart.store';
10
-
11
- // Mock cookies module
12
- vi.mock('../../react/cookies', () => ({
13
- getCookie: vi.fn(() => null),
14
- setCookie: vi.fn(),
15
- deleteCookie: vi.fn(),
16
- }));
17
-
18
- import { getCookie, setCookie, deleteCookie } from '../../react/cookies';
19
- const mockGetCookie = vi.mocked(getCookie);
20
- const mockSetCookie = vi.mocked(setCookie);
21
- const mockDeleteCookie = vi.mocked(deleteCookie);
22
-
23
- // ---------------------------------------------------------------------------
24
- // Helpers
25
- // ---------------------------------------------------------------------------
26
-
27
- function createMockActions(overrides?: Partial<CartActions>): CartActions {
28
- return {
29
- fetchCart: vi.fn(async () => ({ id: 'cart-1', totalQuantity: 1 })),
30
- createCart: vi.fn(async () => 'new-cart-id'),
31
- addLines: vi.fn(async () => ({ id: 'cart-1', totalQuantity: 2 })),
32
- updateLines: vi.fn(async () => ({ id: 'cart-1', totalQuantity: 3 })),
33
- removeLines: vi.fn(async () => ({ id: 'cart-1', totalQuantity: 0 })),
34
- ...overrides,
35
- };
36
- }
37
-
38
- function createTestStore(
39
- actions?: CartActions,
40
- options?: {
41
- onMutationSuccess?: (action: string, cart: CartData) => void;
42
- onMutationError?: (action: string, error: unknown) => void;
43
- },
44
- ) {
45
- const mockActions = actions ?? createMockActions();
46
- return {
47
- store: createCartStore({
48
- getActions: () => mockActions,
49
- onMutationSuccess: options?.onMutationSuccess,
50
- onMutationError: options?.onMutationError,
51
- }),
52
- actions: mockActions,
53
- };
54
- }
55
-
56
- // ---------------------------------------------------------------------------
57
- // Task 3.1: Factory, cookie persistence, UI actions
58
- // ---------------------------------------------------------------------------
59
-
60
- describe('createCartStore — factory & UI actions', () => {
61
- afterEach(() => {
62
- vi.clearAllMocks();
63
- });
64
-
65
- it('should create a valid store instance with initial state', () => {
66
- const { store } = createTestStore();
67
- const state = store.getState();
68
-
69
- expect(state.cartId).toBeNull();
70
- expect(state.isOpen).toBe(false);
71
- expect(state.isLoading).toBe(false);
72
- expect(state.error).toBeNull();
73
- });
74
-
75
- it('should read initial cartId from cookie', () => {
76
- mockGetCookie.mockReturnValueOnce('existing-cart-from-cookie');
77
- const actions = createMockActions();
78
- const store = createCartStore({ getActions: () => actions });
79
-
80
- expect(store.getState().cartId).toBe('existing-cart-from-cookie');
81
- expect(mockGetCookie).toHaveBeenCalledWith('cart-id');
82
- });
83
-
84
- it('should write cartId to cookie when set', () => {
85
- const { store } = createTestStore();
86
-
87
- store.setState({ cartId: 'new-cart' });
88
-
89
- expect(mockSetCookie).toHaveBeenCalledWith('cart-id', 'new-cart', { maxAge: 30 * 24 * 60 * 60 });
90
- });
91
-
92
- it('should delete cookie when cartId cleared', () => {
93
- const { store } = createTestStore();
94
- store.setState({ cartId: 'to-clear' });
95
- vi.clearAllMocks();
96
-
97
- store.getState().clearCart();
98
-
99
- expect(mockDeleteCookie).toHaveBeenCalledWith('cart-id');
100
- expect(store.getState().cartId).toBeNull();
101
- });
102
-
103
- it('openCart should set isOpen to true', () => {
104
- const { store } = createTestStore();
105
- store.getState().openCart();
106
- expect(store.getState().isOpen).toBe(true);
107
- });
108
-
109
- it('closeCart should set isOpen to false', () => {
110
- const { store } = createTestStore();
111
- store.getState().openCart();
112
- store.getState().closeCart();
113
- expect(store.getState().isOpen).toBe(false);
114
- });
115
-
116
- it('toggleCart should flip isOpen', () => {
117
- const { store } = createTestStore();
118
- expect(store.getState().isOpen).toBe(false);
119
- store.getState().toggleCart();
120
- expect(store.getState().isOpen).toBe(true);
121
- store.getState().toggleCart();
122
- expect(store.getState().isOpen).toBe(false);
123
- });
124
-
125
- it('clearCart should reset all state', () => {
126
- const { store } = createTestStore();
127
- store.setState({ cartId: 'cart-1', isOpen: true, isLoading: true, error: new Error('err') });
128
-
129
- store.getState().clearCart();
130
-
131
- const state = store.getState();
132
- expect(state.cartId).toBeNull();
133
- expect(state.isOpen).toBe(false);
134
- expect(state.isLoading).toBe(false);
135
- expect(state.error).toBeNull();
136
- });
137
- });
138
-
139
- // ---------------------------------------------------------------------------
140
- // Task 3.2: initCart orchestration
141
- // ---------------------------------------------------------------------------
142
-
143
- describe('createCartStore — initCart orchestration', () => {
144
- afterEach(() => { vi.clearAllMocks(); });
145
-
146
- it('should fetch existing cart when cartId is set', async () => {
147
- const actions = createMockActions({
148
- fetchCart: vi.fn(async () => ({ id: 'existing-cart', totalQuantity: 5 })),
149
- });
150
- const { store } = createTestStore(actions);
151
- store.setState({ cartId: 'existing-cart' });
152
-
153
- await store.getState().initCart();
154
-
155
- expect(actions.fetchCart).toHaveBeenCalledWith('existing-cart');
156
- expect(actions.createCart).not.toHaveBeenCalled();
157
- expect(store.getState().cartId).toBe('existing-cart');
158
- expect(store.getState().isLoading).toBe(false);
159
- });
160
-
161
- it('should create new cart when fetchCart returns null (expired)', async () => {
162
- const actions = createMockActions({
163
- fetchCart: vi.fn(async () => null),
164
- createCart: vi.fn(async () => 'brand-new-cart'),
165
- });
166
- const { store } = createTestStore(actions);
167
- store.setState({ cartId: 'expired-cart' });
168
-
169
- await store.getState().initCart();
170
-
171
- expect(actions.fetchCart).toHaveBeenCalledWith('expired-cart');
172
- expect(actions.createCart).toHaveBeenCalled();
173
- expect(store.getState().cartId).toBe('brand-new-cart');
174
- expect(store.getState().isLoading).toBe(false);
175
- });
176
-
177
- it('should create new cart when cartId is null', async () => {
178
- const actions = createMockActions({
179
- createCart: vi.fn(async () => 'fresh-cart'),
180
- });
181
- const { store } = createTestStore(actions);
182
-
183
- await store.getState().initCart();
184
-
185
- expect(actions.fetchCart).not.toHaveBeenCalled();
186
- expect(actions.createCart).toHaveBeenCalled();
187
- expect(store.getState().cartId).toBe('fresh-cart');
188
- });
189
-
190
- it('should deduplicate concurrent initCart calls', async () => {
191
- let resolveCreate: (value: string) => void;
192
- const createPromise = new Promise<string>((resolve) => {
193
- resolveCreate = resolve;
194
- });
195
-
196
- const actions = createMockActions({
197
- createCart: vi.fn(() => createPromise),
198
- });
199
- const { store } = createTestStore(actions);
200
-
201
- // Fire two concurrent initCart calls
202
- const p1 = store.getState().initCart();
203
- const p2 = store.getState().initCart();
204
-
205
- // Resolve
206
- resolveCreate!('deduped-cart');
207
- await Promise.all([p1, p2]);
208
-
209
- // createCart should be called only once
210
- expect(actions.createCart).toHaveBeenCalledTimes(1);
211
- expect(store.getState().cartId).toBe('deduped-cart');
212
- });
213
-
214
- it('should set error and call onMutationError on initCart failure', async () => {
215
- const err = new Error('Network error');
216
- const actions = createMockActions({
217
- createCart: vi.fn(async () => { throw err; }),
218
- });
219
- const onMutationError = vi.fn();
220
- const { store } = createTestStore(actions, { onMutationError });
221
-
222
- await store.getState().initCart();
223
-
224
- expect(store.getState().error).toBe(err);
225
- expect(store.getState().isLoading).toBe(false);
226
- expect(onMutationError).toHaveBeenCalledWith('initCart', err);
227
- });
228
- });
229
-
230
- // ---------------------------------------------------------------------------
231
- // Task 3.3: Mutations and callbacks
232
- // ---------------------------------------------------------------------------
233
-
234
- describe('createCartStore — mutations', () => {
235
- afterEach(() => { vi.clearAllMocks(); });
236
-
237
- it('addToCart with existing cartId should call addLines', async () => {
238
- const cartData: CartData = { id: 'cart-1', totalQuantity: 2 };
239
- const actions = createMockActions({
240
- addLines: vi.fn(async () => cartData),
241
- });
242
- const onMutationSuccess = vi.fn();
243
- const { store } = createTestStore(actions, { onMutationSuccess });
244
- store.setState({ cartId: 'cart-1' });
245
-
246
- await store.getState().addToCart([{ merchandiseId: 'var-1', quantity: 1 }]);
247
-
248
- expect(actions.addLines).toHaveBeenCalledWith('cart-1', [{ merchandiseId: 'var-1', quantity: 1 }]);
249
- expect(onMutationSuccess).toHaveBeenCalledWith('addToCart', cartData);
250
- expect(store.getState().isLoading).toBe(false);
251
- });
252
-
253
- it('addToCart without cartId should auto-init then addLines', async () => {
254
- const actions = createMockActions({
255
- createCart: vi.fn(async () => 'auto-cart'),
256
- addLines: vi.fn(async () => ({ id: 'auto-cart', totalQuantity: 1 })),
257
- });
258
- const { store } = createTestStore(actions);
259
-
260
- await store.getState().addToCart([{ merchandiseId: 'var-1' }]);
261
-
262
- expect(actions.createCart).toHaveBeenCalled();
263
- expect(actions.addLines).toHaveBeenCalledWith('auto-cart', [{ merchandiseId: 'var-1' }]);
264
- expect(store.getState().cartId).toBe('auto-cart');
265
- });
266
-
267
- it('updateQuantity should call updateLines with correct args', async () => {
268
- const cartData: CartData = { id: 'cart-1', totalQuantity: 3 };
269
- const actions = createMockActions({
270
- updateLines: vi.fn(async () => cartData),
271
- });
272
- const onMutationSuccess = vi.fn();
273
- const { store } = createTestStore(actions, { onMutationSuccess });
274
- store.setState({ cartId: 'cart-1' });
275
-
276
- await store.getState().updateQuantity([{ id: 'line-1', quantity: 5 }]);
277
-
278
- expect(actions.updateLines).toHaveBeenCalledWith('cart-1', [{ id: 'line-1', quantity: 5 }]);
279
- expect(onMutationSuccess).toHaveBeenCalledWith('updateQuantity', cartData);
280
- });
281
-
282
- it('updateQuantity without cartId should set error', async () => {
283
- const onMutationError = vi.fn();
284
- const { store, actions } = createTestStore(undefined, { onMutationError });
285
-
286
- await store.getState().updateQuantity([{ id: 'line-1', quantity: 5 }]);
287
-
288
- expect(actions.updateLines).not.toHaveBeenCalled();
289
- expect(store.getState().error).toBeInstanceOf(Error);
290
- expect(onMutationError).toHaveBeenCalledWith('updateQuantity', expect.any(Error));
291
- });
292
-
293
- it('removeFromCart should call removeLines with correct args', async () => {
294
- const cartData: CartData = { id: 'cart-1', totalQuantity: 0 };
295
- const actions = createMockActions({
296
- removeLines: vi.fn(async () => cartData),
297
- });
298
- const onMutationSuccess = vi.fn();
299
- const { store } = createTestStore(actions, { onMutationSuccess });
300
- store.setState({ cartId: 'cart-1' });
301
-
302
- await store.getState().removeFromCart(['line-1']);
303
-
304
- expect(actions.removeLines).toHaveBeenCalledWith('cart-1', ['line-1']);
305
- expect(onMutationSuccess).toHaveBeenCalledWith('removeFromCart', cartData);
306
- });
307
-
308
- it('removeFromCart without cartId should silently return', async () => {
309
- const { store, actions } = createTestStore();
310
-
311
- await store.getState().removeFromCart(['line-1']);
312
-
313
- expect(actions.removeLines).not.toHaveBeenCalled();
314
- expect(store.getState().isLoading).toBe(false);
315
- });
316
-
317
- it('should set error state and call onMutationError on DI failure', async () => {
318
- const err = new Error('GraphQL error');
319
- const actions = createMockActions({
320
- addLines: vi.fn(async () => { throw err; }),
321
- });
322
- const onMutationError = vi.fn();
323
- const { store } = createTestStore(actions, { onMutationError });
324
- store.setState({ cartId: 'cart-1' });
325
-
326
- await store.getState().addToCart([{ merchandiseId: 'var-1' }]);
327
-
328
- expect(store.getState().error).toBe(err);
329
- expect(store.getState().isLoading).toBe(false);
330
- expect(onMutationError).toHaveBeenCalledWith('addToCart', err);
331
- });
332
-
333
- it('should call getActions on every operation (freshness)', async () => {
334
- const actions = createMockActions();
335
- const getActions = vi.fn(() => actions);
336
- const store = createCartStore({
337
- getActions,
338
- onMutationSuccess: vi.fn(),
339
- });
340
- store.setState({ cartId: 'cart-1' });
341
-
342
- await store.getState().addToCart([{ merchandiseId: 'v1' }]);
343
- await store.getState().updateQuantity([{ id: 'l1', quantity: 2 }]);
344
- await store.getState().removeFromCart(['l1']);
345
-
346
- // getActions called at least once per operation
347
- expect(getActions.mock.calls.length).toBeGreaterThanOrEqual(3);
348
- });
349
- });
@@ -1,356 +0,0 @@
1
- /**
2
- * Unit tests for createStorefrontClient — transport factory.
3
- *
4
- * Tests: query/mutate, middleware pipeline, request deduplication,
5
- * lazy pipeline compilation, use() API, TypedDocumentString support.
6
- */
7
-
8
- import { describe, it, expect, vi } from 'vitest';
9
- import { createStorefrontClient } from '../../core/client/create-client';
10
- import { TypedDocumentString } from '../../core/client/types';
11
- import { createSpyFetch, createMockFetch } from './test-helpers';
12
-
13
- const PRODUCT_QUERY = 'query Product($handle: String!) { product(handle: $handle) { id title } }';
14
- const CART_CREATE = 'mutation CartCreate($input: CartInput!) { cartCreate(input: $input) { cart { id } userErrors { message } } }';
15
-
16
- describe('createStorefrontClient', () => {
17
- it('should create a client with query and mutate methods', () => {
18
- const client = createStorefrontClient({
19
- apiUrl: 'https://api.test.com',
20
- shopSlug: 'test-shop',
21
- fetch: createMockFetch({}),
22
- });
23
-
24
- expect(client.query).toBeDefined();
25
- expect(client.mutate).toBeDefined();
26
- expect(client.use).toBeDefined();
27
- });
28
-
29
- describe('query()', () => {
30
- it('should send POST request with correct body', async () => {
31
- const { fetch, calls } = createSpyFetch({ product: { id: '1', title: 'Test' } });
32
-
33
- const client = createStorefrontClient({
34
- apiUrl: 'https://api.test.com',
35
- shopSlug: 'my-shop',
36
- fetch,
37
- });
38
-
39
- await client.query(PRODUCT_QUERY, { handle: 'test-product' });
40
-
41
- expect(calls).toHaveLength(1);
42
- expect(calls[0].url).toBe('https://api.test.com/storefront/graphql');
43
-
44
- const body = JSON.parse(calls[0].init.body as string);
45
- expect(body.query).toBe(PRODUCT_QUERY);
46
- expect(body.variables).toEqual({ handle: 'test-product' });
47
- expect(body.operationName).toBe('Product');
48
- });
49
-
50
- it('should include X-Shop-Slug header', async () => {
51
- const { fetch, calls } = createSpyFetch({ product: null });
52
-
53
- const client = createStorefrontClient({
54
- apiUrl: 'https://api.test.com',
55
- shopSlug: 'my-shop',
56
- fetch,
57
- });
58
-
59
- await client.query(PRODUCT_QUERY, { handle: 'test' });
60
-
61
- const headers = calls[0].init.headers as Record<string, string>;
62
- expect(headers['X-Shop-Slug']).toBe('my-shop');
63
- });
64
-
65
- it('should include X-Operation-Name header for named queries', async () => {
66
- const { fetch, calls } = createSpyFetch({ product: null });
67
-
68
- const client = createStorefrontClient({
69
- apiUrl: 'https://api.test.com',
70
- shopSlug: 'test',
71
- fetch,
72
- });
73
-
74
- await client.query(PRODUCT_QUERY, { handle: 'test' });
75
-
76
- const headers = calls[0].init.headers as Record<string, string>;
77
- expect(headers['X-Operation-Name']).toBe('Product');
78
- });
79
-
80
- it('should NOT include X-Operation-Name for anonymous queries', async () => {
81
- const { fetch, calls } = createSpyFetch({ something: true });
82
-
83
- const client = createStorefrontClient({
84
- apiUrl: 'https://api.test.com',
85
- shopSlug: 'test',
86
- fetch,
87
- });
88
-
89
- await client.query('{ shop { id } }');
90
-
91
- const headers = calls[0].init.headers as Record<string, string>;
92
- expect(headers['X-Operation-Name']).toBeUndefined();
93
- });
94
-
95
- it('should return data from response', async () => {
96
- const mockData = { product: { id: '1', title: 'Cool Shirt' } };
97
- const client = createStorefrontClient({
98
- apiUrl: 'https://api.test.com',
99
- shopSlug: 'test',
100
- fetch: createMockFetch(mockData),
101
- });
102
-
103
- const result = await client.query(PRODUCT_QUERY, { handle: 'cool-shirt' });
104
- expect(result).toEqual(mockData);
105
- });
106
-
107
- it('should strip trailing slash from apiUrl', async () => {
108
- const { fetch, calls } = createSpyFetch({});
109
-
110
- const client = createStorefrontClient({
111
- apiUrl: 'https://api.test.com/',
112
- shopSlug: 'test',
113
- fetch,
114
- });
115
-
116
- await client.query('{ shop { id } }');
117
- expect(calls[0].url).toBe('https://api.test.com/storefront/graphql');
118
- });
119
-
120
- it('should merge defaultHeaders into request', async () => {
121
- const { fetch, calls } = createSpyFetch({});
122
-
123
- const client = createStorefrontClient({
124
- apiUrl: 'https://api.test.com',
125
- shopSlug: 'test',
126
- defaultHeaders: { 'X-Custom': 'value' },
127
- fetch,
128
- });
129
-
130
- await client.query('{ shop { id } }');
131
-
132
- const headers = calls[0].init.headers as Record<string, string>;
133
- expect(headers['X-Custom']).toBe('value');
134
- });
135
-
136
- it('should support TypedDocumentString', async () => {
137
- const typedDoc = new TypedDocumentString<{ product: { id: string } }, { handle: string }>(PRODUCT_QUERY);
138
-
139
- const { fetch, calls } = createSpyFetch({ product: { id: '1' } });
140
-
141
- const client = createStorefrontClient({
142
- apiUrl: 'https://api.test.com',
143
- shopSlug: 'test',
144
- fetch,
145
- });
146
-
147
- const result = await client.query(typedDoc, { handle: 'test' });
148
-
149
- const body = JSON.parse(calls[0].init.body as string);
150
- expect(body.query).toBe(PRODUCT_QUERY);
151
- expect(result).toEqual({ product: { id: '1' } });
152
- });
153
-
154
- it('should not include empty variables in request body', async () => {
155
- const { fetch, calls } = createSpyFetch({});
156
-
157
- const client = createStorefrontClient({
158
- apiUrl: 'https://api.test.com',
159
- shopSlug: 'test',
160
- fetch,
161
- });
162
-
163
- await client.query('{ shop { id } }');
164
-
165
- const body = JSON.parse(calls[0].init.body as string);
166
- expect(body.variables).toBeUndefined();
167
- });
168
- });
169
-
170
- describe('mutate()', () => {
171
- it('should send mutation correctly', async () => {
172
- const mockData = { cartCreate: { cart: { id: 'cart-1' }, userErrors: [] } };
173
- const { fetch, calls } = createSpyFetch(mockData);
174
-
175
- const client = createStorefrontClient({
176
- apiUrl: 'https://api.test.com',
177
- shopSlug: 'test',
178
- fetch,
179
- });
180
-
181
- const result = await client.mutate(CART_CREATE, { input: {} });
182
-
183
- expect(result).toEqual(mockData);
184
- const body = JSON.parse(calls[0].init.body as string);
185
- expect(body.operationName).toBe('CartCreate');
186
- });
187
- });
188
-
189
- describe('request deduplication', () => {
190
- it('should deduplicate identical concurrent queries', async () => {
191
- const { fetch, calls } = createSpyFetch({ product: { id: '1' } });
192
-
193
- const client = createStorefrontClient({
194
- apiUrl: 'https://api.test.com',
195
- shopSlug: 'test',
196
- fetch,
197
- });
198
-
199
- // Fire 3 identical queries concurrently
200
- const [r1, r2, r3] = await Promise.all([
201
- client.query(PRODUCT_QUERY, { handle: 'test' }),
202
- client.query(PRODUCT_QUERY, { handle: 'test' }),
203
- client.query(PRODUCT_QUERY, { handle: 'test' }),
204
- ]);
205
-
206
- // Only 1 actual fetch
207
- expect(calls).toHaveLength(1);
208
- // All return same data
209
- expect(r1).toEqual(r2);
210
- expect(r2).toEqual(r3);
211
- });
212
-
213
- it('should NOT deduplicate queries with different variables', async () => {
214
- const { fetch, calls } = createSpyFetch({ product: { id: '1' } });
215
-
216
- const client = createStorefrontClient({
217
- apiUrl: 'https://api.test.com',
218
- shopSlug: 'test',
219
- fetch,
220
- });
221
-
222
- await Promise.all([
223
- client.query(PRODUCT_QUERY, { handle: 'product-a' }),
224
- client.query(PRODUCT_QUERY, { handle: 'product-b' }),
225
- ]);
226
-
227
- expect(calls).toHaveLength(2);
228
- });
229
-
230
- it('should NOT deduplicate mutations', async () => {
231
- const { fetch, calls } = createSpyFetch({ cartCreate: { cart: { id: '1' }, userErrors: [] } });
232
-
233
- const client = createStorefrontClient({
234
- apiUrl: 'https://api.test.com',
235
- shopSlug: 'test',
236
- fetch,
237
- });
238
-
239
- await Promise.all([
240
- client.mutate(CART_CREATE, { input: {} }),
241
- client.mutate(CART_CREATE, { input: {} }),
242
- ]);
243
-
244
- expect(calls).toHaveLength(2);
245
- });
246
- });
247
-
248
- describe('use() — imperative middleware', () => {
249
- it('should add middleware that runs on subsequent requests', async () => {
250
- const { fetch, calls } = createSpyFetch({ data: true });
251
-
252
- const client = createStorefrontClient({
253
- apiUrl: 'https://api.test.com',
254
- shopSlug: 'test',
255
- fetch,
256
- });
257
-
258
- client.use((req, next) => {
259
- req.headers['X-Added'] = 'by-use';
260
- return next(req);
261
- });
262
-
263
- await client.query('{ shop { id } }');
264
-
265
- const headers = calls[0].init.headers as Record<string, string>;
266
- expect(headers['X-Added']).toBe('by-use');
267
- });
268
-
269
- it('should invalidate compiled pipeline on use()', async () => {
270
- let callOrder: string[] = [];
271
-
272
- const client = createStorefrontClient({
273
- apiUrl: 'https://api.test.com',
274
- shopSlug: 'test',
275
- fetch: createMockFetch({}),
276
- middleware: [
277
- async (req, next) => { callOrder.push('A'); return next(req); },
278
- ],
279
- });
280
-
281
- await client.query('{ shop { id } }');
282
- expect(callOrder).toEqual(['A']);
283
-
284
- callOrder = [];
285
- client.use(async (req, next) => { callOrder.push('B'); return next(req); });
286
-
287
- await client.query('{ shop { id } }');
288
- expect(callOrder).toEqual(['A', 'B']);
289
- });
290
- });
291
-
292
- describe('middleware pipeline', () => {
293
- it('should execute middleware in registration order', async () => {
294
- const order: string[] = [];
295
-
296
- const client = createStorefrontClient({
297
- apiUrl: 'https://api.test.com',
298
- shopSlug: 'test',
299
- fetch: createMockFetch({}),
300
- middleware: [
301
- async (req, next) => { order.push('first'); return next(req); },
302
- async (req, next) => { order.push('second'); return next(req); },
303
- async (req, next) => { order.push('third'); return next(req); },
304
- ],
305
- });
306
-
307
- await client.query('{ shop { id } }');
308
- expect(order).toEqual(['first', 'second', 'third']);
309
- });
310
-
311
- it('should allow middleware to modify request headers', async () => {
312
- const { fetch, calls } = createSpyFetch({});
313
-
314
- const client = createStorefrontClient({
315
- apiUrl: 'https://api.test.com',
316
- shopSlug: 'test',
317
- fetch,
318
- middleware: [
319
- async (req, next) => {
320
- req.headers['Authorization'] = 'Bearer test-token';
321
- return next(req);
322
- },
323
- ],
324
- });
325
-
326
- await client.query('{ shop { id } }');
327
-
328
- const headers = calls[0].init.headers as Record<string, string>;
329
- expect(headers['Authorization']).toBe('Bearer test-token');
330
- });
331
-
332
- it('should allow middleware to short-circuit', async () => {
333
- const { fetch, calls } = createSpyFetch({});
334
-
335
- const client = createStorefrontClient({
336
- apiUrl: 'https://api.test.com',
337
- shopSlug: 'test',
338
- fetch,
339
- middleware: [
340
- async (_req, _next) => {
341
- // Short-circuit — don't call next()
342
- return {
343
- data: { cached: true },
344
- status: 200,
345
- headers: new Headers(),
346
- };
347
- },
348
- ],
349
- });
350
-
351
- const result = await client.query('{ shop { id } }');
352
- expect(result).toEqual({ cached: true });
353
- expect(calls).toHaveLength(0); // No fetch call
354
- });
355
- });
356
- });