@doswiftly/storefront-sdk 4.0.0 → 4.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/README.md +51 -9
- package/dist/core/bot-protection/abstract-manager.d.ts +57 -0
- package/dist/core/bot-protection/abstract-manager.d.ts.map +1 -0
- package/dist/core/bot-protection/abstract-manager.js +144 -0
- package/dist/core/bot-protection/create-manager.d.ts +15 -0
- package/dist/core/bot-protection/create-manager.d.ts.map +1 -0
- package/dist/core/bot-protection/create-manager.js +33 -0
- package/dist/core/bot-protection/eucaptcha-manager.d.ts +15 -0
- package/dist/core/bot-protection/eucaptcha-manager.d.ts.map +1 -0
- package/dist/core/bot-protection/eucaptcha-manager.js +76 -0
- package/dist/core/bot-protection/fallback-manager.d.ts +18 -0
- package/dist/core/bot-protection/fallback-manager.d.ts.map +1 -0
- package/dist/core/bot-protection/fallback-manager.js +42 -0
- package/dist/core/bot-protection/turnstile-manager.d.ts +15 -0
- package/dist/core/bot-protection/turnstile-manager.d.ts.map +1 -0
- package/dist/core/bot-protection/turnstile-manager.js +78 -0
- package/dist/core/cart/cookie-config.d.ts +14 -0
- package/dist/core/cart/cookie-config.d.ts.map +1 -0
- package/dist/core/cart/cookie-config.js +13 -0
- package/dist/core/currency/cookie-config.d.ts +14 -0
- package/dist/core/currency/cookie-config.d.ts.map +1 -0
- package/dist/core/currency/cookie-config.js +13 -0
- package/dist/core/image.d.ts +55 -0
- package/dist/core/image.d.ts.map +1 -0
- package/dist/core/image.js +48 -0
- package/dist/core/index.d.ts +8 -0
- package/dist/core/index.d.ts.map +1 -1
- package/dist/core/index.js +13 -0
- package/dist/core/language/cookie-config.d.ts +14 -0
- package/dist/core/language/cookie-config.d.ts.map +1 -0
- package/dist/core/language/cookie-config.js +13 -0
- package/dist/core/middleware/bot-protection.d.ts +71 -0
- package/dist/core/middleware/bot-protection.d.ts.map +1 -0
- package/dist/core/middleware/bot-protection.js +63 -0
- package/dist/core/middleware/currency.d.ts.map +1 -1
- package/dist/core/middleware/currency.js +2 -1
- package/dist/core/middleware/language.d.ts +18 -0
- package/dist/core/middleware/language.d.ts.map +1 -0
- package/dist/core/middleware/language.js +25 -0
- package/dist/react/bot-protection/bot-protection-context.d.ts +12 -0
- package/dist/react/bot-protection/bot-protection-context.d.ts.map +1 -0
- package/dist/react/bot-protection/bot-protection-context.js +9 -0
- package/dist/react/bot-protection/bot-protection-widget.d.ts +13 -0
- package/dist/react/bot-protection/bot-protection-widget.d.ts.map +1 -0
- package/dist/react/bot-protection/bot-protection-widget.js +34 -0
- package/dist/react/cookies.d.ts +17 -0
- package/dist/react/cookies.d.ts.map +1 -1
- package/dist/react/cookies.js +36 -3
- package/dist/react/hooks/use-bot-protection.d.ts +16 -0
- package/dist/react/hooks/use-bot-protection.d.ts.map +1 -0
- package/dist/react/hooks/use-bot-protection.js +24 -0
- package/dist/react/index.d.ts +10 -1
- package/dist/react/index.d.ts.map +1 -1
- package/dist/react/index.js +9 -1
- package/dist/react/providers/language-provider.d.ts +18 -0
- package/dist/react/providers/language-provider.d.ts.map +1 -0
- package/dist/react/providers/language-provider.js +24 -0
- package/dist/react/providers/storefront-client-provider.d.ts +7 -2
- package/dist/react/providers/storefront-client-provider.d.ts.map +1 -1
- package/dist/react/providers/storefront-client-provider.js +14 -3
- package/dist/react/providers/storefront-provider.d.ts +7 -1
- package/dist/react/providers/storefront-provider.d.ts.map +1 -1
- package/dist/react/providers/storefront-provider.js +11 -4
- package/dist/react/stores/cart.context.d.ts +12 -0
- package/dist/react/stores/cart.context.d.ts.map +1 -0
- package/dist/react/stores/cart.context.js +3 -0
- package/dist/react/stores/cart.store.d.ts +71 -0
- package/dist/react/stores/cart.store.d.ts.map +1 -0
- package/dist/react/stores/cart.store.js +166 -0
- package/dist/react/stores/currency.store.d.ts +6 -9
- package/dist/react/stores/currency.store.d.ts.map +1 -1
- package/dist/react/stores/currency.store.js +5 -22
- package/dist/react/stores/language.store.d.ts +33 -0
- package/dist/react/stores/language.store.d.ts.map +1 -0
- package/dist/react/stores/language.store.js +67 -0
- package/dist/react/stores/store-context.d.ts +5 -0
- package/dist/react/stores/store-context.d.ts.map +1 -1
- package/dist/react/stores/store-context.js +14 -0
- package/dist/react/types/shop-config.d.ts +19 -0
- package/dist/react/types/shop-config.d.ts.map +1 -0
- package/dist/react/types/shop-config.js +7 -0
- package/package.json +1 -1
- package/src/__tests__/unit/bot-protection.test.ts +461 -0
- package/src/__tests__/unit/cart-store.test.ts +349 -0
- package/src/core/bot-protection/abstract-manager.ts +185 -0
- package/src/core/bot-protection/create-manager.ts +37 -0
- package/src/core/bot-protection/eucaptcha-manager.ts +88 -0
- package/src/core/bot-protection/fallback-manager.ts +43 -0
- package/src/core/bot-protection/turnstile-manager.ts +92 -0
- package/src/core/bot-protection/types/eucaptcha.d.ts +28 -0
- package/src/core/bot-protection/types/turnstile.d.ts +33 -0
- package/src/core/cart/cookie-config.ts +13 -0
- package/src/core/currency/cookie-config.ts +13 -0
- package/src/core/image.ts +75 -0
- package/src/core/index.ts +30 -0
- package/src/core/language/cookie-config.ts +13 -0
- package/src/core/middleware/bot-protection.ts +140 -0
- package/src/core/middleware/currency.ts +2 -1
- package/src/core/middleware/language.ts +30 -0
- package/src/react/bot-protection/bot-protection-context.ts +17 -0
- package/src/react/bot-protection/bot-protection-widget.tsx +46 -0
- package/src/react/cookies.ts +39 -4
- package/src/react/hooks/use-bot-protection.ts +31 -0
- package/src/react/index.ts +27 -1
- package/src/react/providers/language-provider.tsx +34 -0
- package/src/react/providers/storefront-client-provider.tsx +20 -3
- package/src/react/providers/storefront-provider.tsx +34 -6
- package/src/react/stores/cart.context.ts +10 -0
- package/src/react/stores/cart.store.ts +254 -0
- package/src/react/stores/currency.store.ts +12 -32
- package/src/react/stores/language.store.ts +90 -0
- package/src/react/stores/store-context.tsx +21 -0
- package/src/react/types/shop-config.ts +22 -0
|
@@ -0,0 +1,349 @@
|
|
|
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
|
+
});
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AbstractBotProtectionManager — base class for all bot protection providers.
|
|
3
|
+
*
|
|
4
|
+
* DRY: singleton script loading, lazy mount, in-flight deduplication, timeout.
|
|
5
|
+
* Subclasses override only: _loadScript(), _renderWidget(), _executeChallenge(), _destroyWidget().
|
|
6
|
+
*
|
|
7
|
+
* Framework-agnostic (DOM APIs only) — works in React, Vue, Svelte, vanilla JS.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { BotProtectionTokenProvider } from '../middleware/bot-protection';
|
|
11
|
+
|
|
12
|
+
export abstract class AbstractBotProtectionManager implements BotProtectionTokenProvider {
|
|
13
|
+
protected readonly siteKey: string;
|
|
14
|
+
protected readonly scriptUrl: string;
|
|
15
|
+
|
|
16
|
+
/** Singleton script loading promise — one load per provider globally */
|
|
17
|
+
private scriptPromise: Promise<void> | null = null;
|
|
18
|
+
/** In-flight deduplication — prevents double-submit */
|
|
19
|
+
private inFlight: Promise<string | null> | null = null;
|
|
20
|
+
/** Whether the widget has been mounted */
|
|
21
|
+
protected widgetId: string | null = null;
|
|
22
|
+
/** Container element for the widget */
|
|
23
|
+
protected container: HTMLElement | null = null;
|
|
24
|
+
|
|
25
|
+
constructor(siteKey: string, scriptUrl: string) {
|
|
26
|
+
this.siteKey = siteKey;
|
|
27
|
+
this.scriptUrl = scriptUrl;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Load the provider's script tag. Singleton — only loads once.
|
|
32
|
+
* Exposed publicly so React wrapper can trigger preload.
|
|
33
|
+
*/
|
|
34
|
+
loadScript(): Promise<void> {
|
|
35
|
+
if (this.scriptPromise) return this.scriptPromise;
|
|
36
|
+
|
|
37
|
+
this.scriptPromise = new Promise<void>((resolve, reject) => {
|
|
38
|
+
// Skip in SSR
|
|
39
|
+
if (typeof document === 'undefined') {
|
|
40
|
+
resolve();
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Check if script already exists
|
|
45
|
+
const existing = document.querySelector(`script[src="${this.scriptUrl}"]`);
|
|
46
|
+
if (existing) {
|
|
47
|
+
// Script tag exists — wait for API to be available
|
|
48
|
+
this._waitForApi().then(resolve).catch(reject);
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const script = document.createElement('script');
|
|
53
|
+
script.src = this.scriptUrl;
|
|
54
|
+
script.async = true;
|
|
55
|
+
script.defer = true;
|
|
56
|
+
|
|
57
|
+
// Set up onload callback if provider supports it
|
|
58
|
+
this._setupOnloadCallback(resolve);
|
|
59
|
+
|
|
60
|
+
script.onload = () => {
|
|
61
|
+
this._waitForApi().then(resolve).catch(reject);
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
script.onerror = () => {
|
|
65
|
+
this.scriptPromise = null; // Allow retry
|
|
66
|
+
reject(new Error(`Failed to load bot protection script: ${this.scriptUrl}`));
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
document.head.appendChild(script);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
return this.scriptPromise;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Mount the invisible widget into a container element.
|
|
77
|
+
* Lazy — called automatically on first execute() if not mounted.
|
|
78
|
+
*/
|
|
79
|
+
mount(container: HTMLElement): void {
|
|
80
|
+
this.container = container;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Execute challenge and get a fresh token.
|
|
85
|
+
* In-flight dedup + timeout + lazy mount + lazy script load.
|
|
86
|
+
*/
|
|
87
|
+
async execute(options?: { action?: string; timeoutMs?: number }): Promise<string | null> {
|
|
88
|
+
// In-flight deduplication — return existing promise if one is running
|
|
89
|
+
if (this.inFlight) return this.inFlight;
|
|
90
|
+
|
|
91
|
+
const timeoutMs = options?.timeoutMs ?? 10000;
|
|
92
|
+
|
|
93
|
+
this.inFlight = this._doExecute(options?.action, timeoutMs).finally(() => {
|
|
94
|
+
this.inFlight = null;
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
return this.inFlight;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Cleanup widget and resources.
|
|
102
|
+
*/
|
|
103
|
+
destroy(): void {
|
|
104
|
+
try {
|
|
105
|
+
if (this.widgetId) {
|
|
106
|
+
this._destroyWidget(this.widgetId);
|
|
107
|
+
}
|
|
108
|
+
} catch {
|
|
109
|
+
// Ignore cleanup errors
|
|
110
|
+
}
|
|
111
|
+
this.widgetId = null;
|
|
112
|
+
this.container = null;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// ---------------------------------------------------------------------------
|
|
116
|
+
// Private execution pipeline
|
|
117
|
+
// ---------------------------------------------------------------------------
|
|
118
|
+
|
|
119
|
+
private async _doExecute(action: string | undefined, timeoutMs: number): Promise<string | null> {
|
|
120
|
+
try {
|
|
121
|
+
// 1. Lazy script load
|
|
122
|
+
await this.loadScript();
|
|
123
|
+
|
|
124
|
+
// 2. Lazy mount — create container if needed
|
|
125
|
+
if (!this.widgetId) {
|
|
126
|
+
await this._ensureMounted(action);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (!this.widgetId) {
|
|
130
|
+
return null; // Mount failed — fail-open
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// 3. Execute challenge with timeout
|
|
134
|
+
const tokenPromise = this._executeChallenge(this.widgetId, action);
|
|
135
|
+
|
|
136
|
+
const timeoutPromise = new Promise<null>((resolve) => {
|
|
137
|
+
setTimeout(() => resolve(null), timeoutMs);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
return await Promise.race([tokenPromise, timeoutPromise]);
|
|
141
|
+
} catch {
|
|
142
|
+
// Any error → return null (fail-open at middleware level)
|
|
143
|
+
return null;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
private async _ensureMounted(action?: string): Promise<void> {
|
|
148
|
+
// Create an invisible container if none provided
|
|
149
|
+
if (!this.container && typeof document !== 'undefined') {
|
|
150
|
+
this.container = document.createElement('div');
|
|
151
|
+
this.container.style.display = 'none';
|
|
152
|
+
this.container.setAttribute('data-bot-protection', 'true');
|
|
153
|
+
document.body.appendChild(this.container);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if (!this.container) return;
|
|
157
|
+
|
|
158
|
+
try {
|
|
159
|
+
this.widgetId = this._renderWidget(this.container, action);
|
|
160
|
+
} catch {
|
|
161
|
+
this.widgetId = null;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// ---------------------------------------------------------------------------
|
|
166
|
+
// Abstract methods — subclass responsibility
|
|
167
|
+
// ---------------------------------------------------------------------------
|
|
168
|
+
|
|
169
|
+
/** Wait for the provider API to be available on window */
|
|
170
|
+
protected abstract _waitForApi(): Promise<void>;
|
|
171
|
+
|
|
172
|
+
/** Optional: setup global onload callback for the script */
|
|
173
|
+
protected _setupOnloadCallback(_resolve: () => void): void {
|
|
174
|
+
// Default: no-op. Override if provider uses a callback pattern.
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** Render the invisible widget. Return widget ID. */
|
|
178
|
+
protected abstract _renderWidget(container: HTMLElement, action?: string): string;
|
|
179
|
+
|
|
180
|
+
/** Execute the challenge and return a token. Called after mount. */
|
|
181
|
+
protected abstract _executeChallenge(widgetId: string, action?: string): Promise<string | null>;
|
|
182
|
+
|
|
183
|
+
/** Destroy/remove the widget. */
|
|
184
|
+
protected abstract _destroyWidget(widgetId: string): void;
|
|
185
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bot protection manager factory.
|
|
3
|
+
*
|
|
4
|
+
* Creates the appropriate manager based on provider config from shop query.
|
|
5
|
+
* Supports runtime fallback chain via FallbackBotProtectionManager.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { BotProtectionTokenProvider, BotProtectionConfig, BotProtectionProviderConfig } from '../middleware/bot-protection';
|
|
9
|
+
import { EuCaptchaManager } from './eucaptcha-manager';
|
|
10
|
+
import { TurnstileManager } from './turnstile-manager';
|
|
11
|
+
import { FallbackBotProtectionManager } from './fallback-manager';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Create a single provider manager instance.
|
|
15
|
+
*/
|
|
16
|
+
function createSingleManager(config: BotProtectionProviderConfig): BotProtectionTokenProvider {
|
|
17
|
+
switch (config.provider) {
|
|
18
|
+
case 'eucaptcha':
|
|
19
|
+
return new EuCaptchaManager(config.siteKey, config.scriptUrl);
|
|
20
|
+
case 'turnstile':
|
|
21
|
+
return new TurnstileManager(config.siteKey, config.scriptUrl);
|
|
22
|
+
default:
|
|
23
|
+
throw new Error(`Unknown bot protection provider: ${config.provider}`);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Create a bot protection manager with optional fallback chain.
|
|
29
|
+
*
|
|
30
|
+
* @param config - Bot protection configuration from shop query
|
|
31
|
+
* @returns BotProtectionTokenProvider (FallbackBotProtectionManager if fallback configured)
|
|
32
|
+
*/
|
|
33
|
+
export function createBotProtectionManager(config: BotProtectionConfig): BotProtectionTokenProvider {
|
|
34
|
+
const primary = createSingleManager(config.primary);
|
|
35
|
+
const fallback = config.fallback ? createSingleManager(config.fallback) : null;
|
|
36
|
+
return new FallbackBotProtectionManager(primary, fallback);
|
|
37
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* EuCaptchaManager — EU CAPTCHA (Myra Security) bot protection provider.
|
|
3
|
+
*
|
|
4
|
+
* Default provider — GDPR by design, EU-hosted (Germany), privacy-first.
|
|
5
|
+
* Implements BotProtectionTokenProvider via AbstractBotProtectionManager.
|
|
6
|
+
* Invisible behavioral analysis, zero user interaction.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/// <reference path="./types/eucaptcha.d.ts" />
|
|
10
|
+
import { AbstractBotProtectionManager } from './abstract-manager';
|
|
11
|
+
|
|
12
|
+
export class EuCaptchaManager extends AbstractBotProtectionManager {
|
|
13
|
+
protected _waitForApi(): Promise<void> {
|
|
14
|
+
if (typeof window !== 'undefined' && window.eucaptcha) {
|
|
15
|
+
return Promise.resolve();
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
return new Promise<void>((resolve) => {
|
|
19
|
+
const check = setInterval(() => {
|
|
20
|
+
if (typeof window !== 'undefined' && window.eucaptcha) {
|
|
21
|
+
clearInterval(check);
|
|
22
|
+
resolve();
|
|
23
|
+
}
|
|
24
|
+
}, 50);
|
|
25
|
+
|
|
26
|
+
// Safety timeout
|
|
27
|
+
setTimeout(() => {
|
|
28
|
+
clearInterval(check);
|
|
29
|
+
resolve();
|
|
30
|
+
}, 15000);
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
protected _renderWidget(container: HTMLElement, action?: string): string {
|
|
35
|
+
if (!window.eucaptcha) {
|
|
36
|
+
throw new Error('EU CAPTCHA API not loaded');
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return window.eucaptcha.render(container, {
|
|
40
|
+
sitekey: this.siteKey,
|
|
41
|
+
size: 'invisible',
|
|
42
|
+
action,
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
protected _executeChallenge(widgetId: string, action?: string): Promise<string | null> {
|
|
47
|
+
return new Promise<string | null>((resolve) => {
|
|
48
|
+
if (!window.eucaptcha) {
|
|
49
|
+
resolve(null);
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Reset for fresh token
|
|
54
|
+
window.eucaptcha.reset(widgetId);
|
|
55
|
+
|
|
56
|
+
// Remove old widget and re-render with callback
|
|
57
|
+
if (!this.container) {
|
|
58
|
+
resolve(null);
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Re-render with callback to capture token
|
|
63
|
+
this.widgetId = window.eucaptcha.render(this.container, {
|
|
64
|
+
sitekey: this.siteKey,
|
|
65
|
+
size: 'invisible',
|
|
66
|
+
action,
|
|
67
|
+
callback: (token: string) => {
|
|
68
|
+
resolve(token);
|
|
69
|
+
},
|
|
70
|
+
'error-callback': () => {
|
|
71
|
+
resolve(null);
|
|
72
|
+
},
|
|
73
|
+
'expired-callback': () => {
|
|
74
|
+
resolve(null);
|
|
75
|
+
},
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
// Trigger execution
|
|
79
|
+
window.eucaptcha.execute(this.widgetId);
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
protected _destroyWidget(widgetId: string): void {
|
|
84
|
+
if (window.eucaptcha) {
|
|
85
|
+
window.eucaptcha.reset(widgetId);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FallbackBotProtectionManager — runtime fallback chain.
|
|
3
|
+
*
|
|
4
|
+
* Tries primary provider, falls back to secondary, ultimately returns null (fail-open).
|
|
5
|
+
* RULE: NEVER block the customer — fail-open is the ultimate fallback.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { BotProtectionTokenProvider } from '../middleware/bot-protection';
|
|
9
|
+
|
|
10
|
+
export class FallbackBotProtectionManager implements BotProtectionTokenProvider {
|
|
11
|
+
constructor(
|
|
12
|
+
private readonly primary: BotProtectionTokenProvider,
|
|
13
|
+
private readonly fallback: BotProtectionTokenProvider | null,
|
|
14
|
+
) {}
|
|
15
|
+
|
|
16
|
+
async execute(options?: { action?: string; timeoutMs?: number }): Promise<string | null> {
|
|
17
|
+
// 1. Try primary
|
|
18
|
+
try {
|
|
19
|
+
const token = await this.primary.execute(options);
|
|
20
|
+
if (token) return token;
|
|
21
|
+
} catch {
|
|
22
|
+
// Primary failed — try fallback
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// 2. Try fallback
|
|
26
|
+
if (this.fallback) {
|
|
27
|
+
try {
|
|
28
|
+
const token = await this.fallback.execute(options);
|
|
29
|
+
if (token) return token;
|
|
30
|
+
} catch {
|
|
31
|
+
// Fallback also failed
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// 3. Ultimate fail-open: return null -> middleware decides per-operation strategy
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
destroy(): void {
|
|
40
|
+
this.primary.destroy();
|
|
41
|
+
this.fallback?.destroy();
|
|
42
|
+
}
|
|
43
|
+
}
|