@doswiftly/storefront-sdk 4.0.0 → 4.1.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 (109) hide show
  1. package/README.md +33 -6
  2. package/dist/core/bot-protection/abstract-manager.d.ts +57 -0
  3. package/dist/core/bot-protection/abstract-manager.d.ts.map +1 -0
  4. package/dist/core/bot-protection/abstract-manager.js +144 -0
  5. package/dist/core/bot-protection/create-manager.d.ts +15 -0
  6. package/dist/core/bot-protection/create-manager.d.ts.map +1 -0
  7. package/dist/core/bot-protection/create-manager.js +33 -0
  8. package/dist/core/bot-protection/eucaptcha-manager.d.ts +15 -0
  9. package/dist/core/bot-protection/eucaptcha-manager.d.ts.map +1 -0
  10. package/dist/core/bot-protection/eucaptcha-manager.js +76 -0
  11. package/dist/core/bot-protection/fallback-manager.d.ts +18 -0
  12. package/dist/core/bot-protection/fallback-manager.d.ts.map +1 -0
  13. package/dist/core/bot-protection/fallback-manager.js +42 -0
  14. package/dist/core/bot-protection/turnstile-manager.d.ts +15 -0
  15. package/dist/core/bot-protection/turnstile-manager.d.ts.map +1 -0
  16. package/dist/core/bot-protection/turnstile-manager.js +78 -0
  17. package/dist/core/cart/cookie-config.d.ts +14 -0
  18. package/dist/core/cart/cookie-config.d.ts.map +1 -0
  19. package/dist/core/cart/cookie-config.js +13 -0
  20. package/dist/core/currency/cookie-config.d.ts +14 -0
  21. package/dist/core/currency/cookie-config.d.ts.map +1 -0
  22. package/dist/core/currency/cookie-config.js +13 -0
  23. package/dist/core/index.d.ts +7 -0
  24. package/dist/core/index.d.ts.map +1 -1
  25. package/dist/core/index.js +11 -0
  26. package/dist/core/language/cookie-config.d.ts +14 -0
  27. package/dist/core/language/cookie-config.d.ts.map +1 -0
  28. package/dist/core/language/cookie-config.js +13 -0
  29. package/dist/core/middleware/bot-protection.d.ts +71 -0
  30. package/dist/core/middleware/bot-protection.d.ts.map +1 -0
  31. package/dist/core/middleware/bot-protection.js +63 -0
  32. package/dist/core/middleware/currency.d.ts.map +1 -1
  33. package/dist/core/middleware/currency.js +2 -1
  34. package/dist/core/middleware/language.d.ts +18 -0
  35. package/dist/core/middleware/language.d.ts.map +1 -0
  36. package/dist/core/middleware/language.js +25 -0
  37. package/dist/react/bot-protection/bot-protection-context.d.ts +12 -0
  38. package/dist/react/bot-protection/bot-protection-context.d.ts.map +1 -0
  39. package/dist/react/bot-protection/bot-protection-context.js +9 -0
  40. package/dist/react/bot-protection/bot-protection-widget.d.ts +13 -0
  41. package/dist/react/bot-protection/bot-protection-widget.d.ts.map +1 -0
  42. package/dist/react/bot-protection/bot-protection-widget.js +34 -0
  43. package/dist/react/cookies.d.ts +17 -0
  44. package/dist/react/cookies.d.ts.map +1 -1
  45. package/dist/react/cookies.js +36 -3
  46. package/dist/react/hooks/use-bot-protection.d.ts +16 -0
  47. package/dist/react/hooks/use-bot-protection.d.ts.map +1 -0
  48. package/dist/react/hooks/use-bot-protection.js +24 -0
  49. package/dist/react/index.d.ts +10 -1
  50. package/dist/react/index.d.ts.map +1 -1
  51. package/dist/react/index.js +9 -1
  52. package/dist/react/providers/language-provider.d.ts +18 -0
  53. package/dist/react/providers/language-provider.d.ts.map +1 -0
  54. package/dist/react/providers/language-provider.js +24 -0
  55. package/dist/react/providers/storefront-client-provider.d.ts +7 -2
  56. package/dist/react/providers/storefront-client-provider.d.ts.map +1 -1
  57. package/dist/react/providers/storefront-client-provider.js +14 -3
  58. package/dist/react/providers/storefront-provider.d.ts +7 -1
  59. package/dist/react/providers/storefront-provider.d.ts.map +1 -1
  60. package/dist/react/providers/storefront-provider.js +11 -4
  61. package/dist/react/stores/cart.context.d.ts +12 -0
  62. package/dist/react/stores/cart.context.d.ts.map +1 -0
  63. package/dist/react/stores/cart.context.js +3 -0
  64. package/dist/react/stores/cart.store.d.ts +71 -0
  65. package/dist/react/stores/cart.store.d.ts.map +1 -0
  66. package/dist/react/stores/cart.store.js +166 -0
  67. package/dist/react/stores/currency.store.d.ts +6 -9
  68. package/dist/react/stores/currency.store.d.ts.map +1 -1
  69. package/dist/react/stores/currency.store.js +5 -22
  70. package/dist/react/stores/language.store.d.ts +33 -0
  71. package/dist/react/stores/language.store.d.ts.map +1 -0
  72. package/dist/react/stores/language.store.js +67 -0
  73. package/dist/react/stores/store-context.d.ts +5 -0
  74. package/dist/react/stores/store-context.d.ts.map +1 -1
  75. package/dist/react/stores/store-context.js +14 -0
  76. package/dist/react/types/shop-config.d.ts +19 -0
  77. package/dist/react/types/shop-config.d.ts.map +1 -0
  78. package/dist/react/types/shop-config.js +7 -0
  79. package/package.json +1 -1
  80. package/src/__tests__/unit/bot-protection.test.ts +461 -0
  81. package/src/__tests__/unit/cart-store.test.ts +349 -0
  82. package/src/core/bot-protection/abstract-manager.ts +185 -0
  83. package/src/core/bot-protection/create-manager.ts +37 -0
  84. package/src/core/bot-protection/eucaptcha-manager.ts +88 -0
  85. package/src/core/bot-protection/fallback-manager.ts +43 -0
  86. package/src/core/bot-protection/turnstile-manager.ts +92 -0
  87. package/src/core/bot-protection/types/eucaptcha.d.ts +28 -0
  88. package/src/core/bot-protection/types/turnstile.d.ts +33 -0
  89. package/src/core/cart/cookie-config.ts +13 -0
  90. package/src/core/currency/cookie-config.ts +13 -0
  91. package/src/core/index.ts +23 -0
  92. package/src/core/language/cookie-config.ts +13 -0
  93. package/src/core/middleware/bot-protection.ts +140 -0
  94. package/src/core/middleware/currency.ts +2 -1
  95. package/src/core/middleware/language.ts +30 -0
  96. package/src/react/bot-protection/bot-protection-context.ts +17 -0
  97. package/src/react/bot-protection/bot-protection-widget.tsx +46 -0
  98. package/src/react/cookies.ts +39 -4
  99. package/src/react/hooks/use-bot-protection.ts +31 -0
  100. package/src/react/index.ts +27 -1
  101. package/src/react/providers/language-provider.tsx +34 -0
  102. package/src/react/providers/storefront-client-provider.tsx +20 -3
  103. package/src/react/providers/storefront-provider.tsx +34 -6
  104. package/src/react/stores/cart.context.ts +10 -0
  105. package/src/react/stores/cart.store.ts +254 -0
  106. package/src/react/stores/currency.store.ts +12 -32
  107. package/src/react/stores/language.store.ts +90 -0
  108. package/src/react/stores/store-context.tsx +21 -0
  109. package/src/react/types/shop-config.ts +22 -0
@@ -1 +1 @@
1
- {"version":3,"file":"store-context.d.ts","sourceRoot":"","sources":["../../../src/react/stores/store-context.tsx"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAKH,OAAO,EAAY,KAAK,QAAQ,EAAE,MAAM,SAAS,CAAC;AAClD,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,cAAc,CAAC;AAC9C,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,kBAAkB,CAAC;AAMtD,eAAO,MAAM,gBAAgB,qDAAkD,CAAC;AAChF,eAAO,MAAM,oBAAoB,yDAAsD,CAAC;AAMxF,wBAAgB,YAAY,IAAI,SAAS,CAAC;AAC1C,wBAAgB,YAAY,CAAC,CAAC,EAAE,QAAQ,EAAE,CAAC,CAAC,EAAE,SAAS,KAAK,CAAC,GAAG,CAAC,CAAC;AAQlE,wBAAgB,eAAe,IAAI,QAAQ,CAAC,SAAS,CAAC,CAIrD;AAED;;;GAGG;AACH,wBAAgB,eAAe,IAAI,OAAO,CAczC;AAMD,wBAAgB,gBAAgB,IAAI,aAAa,CAAC;AAClD,wBAAgB,gBAAgB,CAAC,CAAC,EAAE,QAAQ,EAAE,CAAC,CAAC,EAAE,aAAa,KAAK,CAAC,GAAG,CAAC,CAAC;AAQ1E,wBAAgB,mBAAmB,IAAI,QAAQ,CAAC,aAAa,CAAC,CAI7D"}
1
+ {"version":3,"file":"store-context.d.ts","sourceRoot":"","sources":["../../../src/react/stores/store-context.tsx"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAKH,OAAO,EAAY,KAAK,QAAQ,EAAE,MAAM,SAAS,CAAC;AAClD,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,cAAc,CAAC;AAC9C,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,kBAAkB,CAAC;AACtD,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,kBAAkB,CAAC;AAMtD,eAAO,MAAM,gBAAgB,qDAAkD,CAAC;AAChF,eAAO,MAAM,oBAAoB,yDAAsD,CAAC;AACxF,eAAO,MAAM,oBAAoB,yDAAsD,CAAC;AAMxF,wBAAgB,YAAY,IAAI,SAAS,CAAC;AAC1C,wBAAgB,YAAY,CAAC,CAAC,EAAE,QAAQ,EAAE,CAAC,CAAC,EAAE,SAAS,KAAK,CAAC,GAAG,CAAC,CAAC;AAQlE,wBAAgB,eAAe,IAAI,QAAQ,CAAC,SAAS,CAAC,CAIrD;AAED;;;GAGG;AACH,wBAAgB,eAAe,IAAI,OAAO,CAczC;AAMD,wBAAgB,gBAAgB,IAAI,aAAa,CAAC;AAClD,wBAAgB,gBAAgB,CAAC,CAAC,EAAE,QAAQ,EAAE,CAAC,CAAC,EAAE,aAAa,KAAK,CAAC,GAAG,CAAC,CAAC;AAQ1E,wBAAgB,mBAAmB,IAAI,QAAQ,CAAC,aAAa,CAAC,CAI7D;AAMD,wBAAgB,gBAAgB,IAAI,aAAa,CAAC;AAClD,wBAAgB,gBAAgB,CAAC,CAAC,EAAE,QAAQ,EAAE,CAAC,CAAC,EAAE,aAAa,KAAK,CAAC,GAAG,CAAC,CAAC;AAQ1E,wBAAgB,mBAAmB,IAAI,QAAQ,CAAC,aAAa,CAAC,CAI7D"}
@@ -16,6 +16,7 @@ import { useStore } from 'zustand';
16
16
  // ---------------------------------------------------------------------------
17
17
  export const AuthStoreContext = createContext(null);
18
18
  export const CurrencyStoreContext = createContext(null);
19
+ export const LanguageStoreContext = createContext(null);
19
20
  export function useAuthStore(selector) {
20
21
  const store = useContext(AuthStoreContext);
21
22
  if (!store)
@@ -60,3 +61,16 @@ export function useCurrencyStoreApi() {
60
61
  throw new Error('useCurrencyStoreApi must be used within StorefrontProvider');
61
62
  return store;
62
63
  }
64
+ export function useLanguageStore(selector) {
65
+ const store = useContext(LanguageStoreContext);
66
+ if (!store)
67
+ throw new Error('useLanguageStore must be used within StorefrontProvider');
68
+ // eslint-disable-next-line react-hooks/rules-of-hooks
69
+ return selector ? useStore(store, selector) : useStore(store);
70
+ }
71
+ export function useLanguageStoreApi() {
72
+ const store = useContext(LanguageStoreContext);
73
+ if (!store)
74
+ throw new Error('useLanguageStoreApi must be used within StorefrontProvider');
75
+ return store;
76
+ }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * ShopConfig — full shop configuration from backend shop query.
3
+ *
4
+ * Flat interface — new features added here directly.
5
+ * Each concern is consumed by its own provider internally.
6
+ */
7
+ import type { BotProtectionConfig } from '../../core/middleware/bot-protection';
8
+ export interface ShopConfig {
9
+ currencyCode: string;
10
+ supportedCurrencies: string[];
11
+ localeToCurrencyMap?: Array<{
12
+ locale: string;
13
+ currency: string;
14
+ }>;
15
+ defaultLanguage?: string | null;
16
+ supportedLanguages?: string[] | null;
17
+ botProtection?: BotProtectionConfig | null;
18
+ }
19
+ //# sourceMappingURL=shop-config.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"shop-config.d.ts","sourceRoot":"","sources":["../../../src/react/types/shop-config.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,sCAAsC,CAAC;AAEhF,MAAM,WAAW,UAAU;IAEzB,YAAY,EAAE,MAAM,CAAC;IACrB,mBAAmB,EAAE,MAAM,EAAE,CAAC;IAC9B,mBAAmB,CAAC,EAAE,KAAK,CAAC;QAAE,MAAM,EAAE,MAAM,CAAC;QAAC,QAAQ,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IAGlE,eAAe,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAChC,kBAAkB,CAAC,EAAE,MAAM,EAAE,GAAG,IAAI,CAAC;IAGrC,aAAa,CAAC,EAAE,mBAAmB,GAAG,IAAI,CAAC;CAC5C"}
@@ -0,0 +1,7 @@
1
+ /**
2
+ * ShopConfig — full shop configuration from backend shop query.
3
+ *
4
+ * Flat interface — new features added here directly.
5
+ * Each concern is consumed by its own provider internally.
6
+ */
7
+ export {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@doswiftly/storefront-sdk",
3
- "version": "4.0.0",
3
+ "version": "4.1.0",
4
4
  "description": "Storefront runtime SDK for DoSwiftly Commerce — layered transport, middleware pipeline, React providers, Zustand stores, cache strategies. 0 runtime dependencies in core.",
5
5
  "type": "module",
6
6
  "types": "dist/core/index.d.ts",
@@ -0,0 +1,461 @@
1
+ /**
2
+ * Unit tests for bot protection: middleware, fallback manager, factory.
3
+ *
4
+ * Tests: botProtectionMiddleware, FallbackBotProtectionManager, createBotProtectionManager.
5
+ */
6
+
7
+ import { describe, it, expect, vi } from 'vitest';
8
+ import {
9
+ botProtectionMiddleware,
10
+ BOT_PROTECTION_HEADER,
11
+ type BotProtectionTokenProvider,
12
+ } from '../../core/middleware/bot-protection';
13
+ import { FallbackBotProtectionManager } from '../../core/bot-protection/fallback-manager';
14
+ import { StorefrontError } from '../../core/errors';
15
+ import type { GraphQLRequest, GraphQLResponse, ExecuteFn } from '../../core/client/types';
16
+
17
+ // ---------------------------------------------------------------------------
18
+ // Helpers
19
+ // ---------------------------------------------------------------------------
20
+
21
+ function makeRequest(overrides?: Partial<GraphQLRequest>): GraphQLRequest {
22
+ return {
23
+ query: '{ shop { id } }',
24
+ headers: {},
25
+ isMutation: false,
26
+ ...overrides,
27
+ };
28
+ }
29
+
30
+ function makeResponse(overrides?: Partial<GraphQLResponse>): GraphQLResponse {
31
+ return {
32
+ data: {},
33
+ status: 200,
34
+ headers: new Headers(),
35
+ ...overrides,
36
+ };
37
+ }
38
+
39
+ function makeNext(response?: GraphQLResponse): ExecuteFn {
40
+ return vi.fn(async () => response ?? makeResponse());
41
+ }
42
+
43
+ function makeTokenProvider(
44
+ token: string | null = 'test-token',
45
+ options?: { delay?: number; shouldThrow?: boolean },
46
+ ): BotProtectionTokenProvider {
47
+ return {
48
+ execute: vi.fn(async () => {
49
+ if (options?.delay) {
50
+ await new Promise((r) => setTimeout(r, options.delay));
51
+ }
52
+ if (options?.shouldThrow) {
53
+ throw new Error('Provider error');
54
+ }
55
+ return token;
56
+ }),
57
+ destroy: vi.fn(),
58
+ };
59
+ }
60
+
61
+ // ---------------------------------------------------------------------------
62
+ // botProtectionMiddleware
63
+ // ---------------------------------------------------------------------------
64
+
65
+ describe('botProtectionMiddleware', () => {
66
+ const protectedOperations = ['CustomerCreate', 'CheckoutComplete', 'ReviewCreate'];
67
+
68
+ it('should skip queries (non-mutations)', async () => {
69
+ const provider = makeTokenProvider();
70
+ const mw = botProtectionMiddleware({
71
+ tokenProvider: provider,
72
+ protectedOperations,
73
+ });
74
+ const next = makeNext();
75
+ const req = makeRequest({ isMutation: false, operationName: 'Shop' });
76
+
77
+ await mw(req, next);
78
+
79
+ expect(provider.execute).not.toHaveBeenCalled();
80
+ expect(req.headers[BOT_PROTECTION_HEADER]).toBeUndefined();
81
+ expect(next).toHaveBeenCalledWith(req);
82
+ });
83
+
84
+ it('should skip non-protected mutations', async () => {
85
+ const provider = makeTokenProvider();
86
+ const mw = botProtectionMiddleware({
87
+ tokenProvider: provider,
88
+ protectedOperations,
89
+ });
90
+ const next = makeNext();
91
+ const req = makeRequest({
92
+ isMutation: true,
93
+ operationName: 'CartLinesAdd',
94
+ });
95
+
96
+ await mw(req, next);
97
+
98
+ expect(provider.execute).not.toHaveBeenCalled();
99
+ expect(req.headers[BOT_PROTECTION_HEADER]).toBeUndefined();
100
+ expect(next).toHaveBeenCalledWith(req);
101
+ });
102
+
103
+ it('should skip mutations without operationName', async () => {
104
+ const provider = makeTokenProvider();
105
+ const mw = botProtectionMiddleware({
106
+ tokenProvider: provider,
107
+ protectedOperations,
108
+ });
109
+ const next = makeNext();
110
+ const req = makeRequest({ isMutation: true }); // no operationName
111
+
112
+ await mw(req, next);
113
+
114
+ expect(provider.execute).not.toHaveBeenCalled();
115
+ expect(next).toHaveBeenCalledWith(req);
116
+ });
117
+
118
+ it('should add token header for protected mutations', async () => {
119
+ const provider = makeTokenProvider('fresh-token');
120
+ const mw = botProtectionMiddleware({
121
+ tokenProvider: provider,
122
+ protectedOperations,
123
+ });
124
+ const next = makeNext();
125
+ const req = makeRequest({
126
+ isMutation: true,
127
+ operationName: 'CustomerCreate',
128
+ });
129
+
130
+ await mw(req, next);
131
+
132
+ expect(provider.execute).toHaveBeenCalledWith({
133
+ action: 'CustomerCreate',
134
+ timeoutMs: 10000,
135
+ });
136
+ expect(req.headers[BOT_PROTECTION_HEADER]).toBe('fresh-token');
137
+ expect(next).toHaveBeenCalledWith(req);
138
+ });
139
+
140
+ it('should pass action matching operationName', async () => {
141
+ const provider = makeTokenProvider('token');
142
+ const mw = botProtectionMiddleware({
143
+ tokenProvider: provider,
144
+ protectedOperations,
145
+ });
146
+ const next = makeNext();
147
+ const req = makeRequest({
148
+ isMutation: true,
149
+ operationName: 'CheckoutComplete',
150
+ });
151
+
152
+ await mw(req, next);
153
+
154
+ expect(provider.execute).toHaveBeenCalledWith(
155
+ expect.objectContaining({ action: 'CheckoutComplete' }),
156
+ );
157
+ });
158
+
159
+ it('should fail-open by default when token is null', async () => {
160
+ const provider = makeTokenProvider(null);
161
+ const mw = botProtectionMiddleware({
162
+ tokenProvider: provider,
163
+ protectedOperations,
164
+ });
165
+ const next = makeNext();
166
+ const req = makeRequest({
167
+ isMutation: true,
168
+ operationName: 'CustomerCreate',
169
+ });
170
+
171
+ await mw(req, next);
172
+
173
+ // No header, but request passes through
174
+ expect(req.headers[BOT_PROTECTION_HEADER]).toBeUndefined();
175
+ expect(next).toHaveBeenCalledWith(req);
176
+ });
177
+
178
+ it('should throw on fail-closed when token is null', async () => {
179
+ const provider = makeTokenProvider(null);
180
+ const mw = botProtectionMiddleware({
181
+ tokenProvider: provider,
182
+ protectedOperations,
183
+ defaultFailStrategy: 'closed',
184
+ });
185
+ const next = makeNext();
186
+ const req = makeRequest({
187
+ isMutation: true,
188
+ operationName: 'CustomerCreate',
189
+ });
190
+
191
+ await expect(mw(req, next)).rejects.toThrow(StorefrontError);
192
+ expect(next).not.toHaveBeenCalled();
193
+ });
194
+
195
+ it('should respect per-operation failStrategyOverrides', async () => {
196
+ const provider = makeTokenProvider(null);
197
+ const mw = botProtectionMiddleware({
198
+ tokenProvider: provider,
199
+ protectedOperations,
200
+ defaultFailStrategy: 'open',
201
+ failStrategyOverrides: {
202
+ CheckoutComplete: 'closed',
203
+ },
204
+ });
205
+ const next = makeNext();
206
+
207
+ // CustomerCreate: default open → should pass
208
+ const req1 = makeRequest({
209
+ isMutation: true,
210
+ operationName: 'CustomerCreate',
211
+ });
212
+ await mw(req1, next);
213
+ expect(next).toHaveBeenCalled();
214
+
215
+ // CheckoutComplete: override closed → should throw
216
+ const req2 = makeRequest({
217
+ isMutation: true,
218
+ operationName: 'CheckoutComplete',
219
+ });
220
+ await expect(mw(req2, next)).rejects.toThrow(StorefrontError);
221
+ });
222
+
223
+ it('should throw StorefrontError with BOT_PROTECTION_REQUIRED code on closed fail', async () => {
224
+ const provider = makeTokenProvider(null);
225
+ const mw = botProtectionMiddleware({
226
+ tokenProvider: provider,
227
+ protectedOperations,
228
+ defaultFailStrategy: 'closed',
229
+ });
230
+ const req = makeRequest({
231
+ isMutation: true,
232
+ operationName: 'CustomerCreate',
233
+ });
234
+
235
+ try {
236
+ await mw(req, makeNext());
237
+ expect.fail('Should have thrown');
238
+ } catch (err) {
239
+ expect(err).toBeInstanceOf(StorefrontError);
240
+ expect((err as StorefrontError).code).toBe('BOT_PROTECTION_REQUIRED');
241
+ }
242
+ });
243
+
244
+ it('should forward response from next() without modification', async () => {
245
+ const provider = makeTokenProvider('token');
246
+ const mw = botProtectionMiddleware({
247
+ tokenProvider: provider,
248
+ protectedOperations,
249
+ });
250
+ const expectedResponse = makeResponse({ data: { foo: 'bar' }, status: 201 });
251
+ const next = makeNext(expectedResponse);
252
+ const req = makeRequest({
253
+ isMutation: true,
254
+ operationName: 'CustomerCreate',
255
+ });
256
+
257
+ const result = await mw(req, next);
258
+
259
+ expect(result).toBe(expectedResponse);
260
+ });
261
+
262
+ it('should propagate provider execute() exceptions to caller', async () => {
263
+ const provider = makeTokenProvider(null, { shouldThrow: true });
264
+ const mw = botProtectionMiddleware({
265
+ tokenProvider: provider,
266
+ protectedOperations,
267
+ defaultFailStrategy: 'open', // fail-open, but execute throws before we get to fail strategy
268
+ });
269
+ const next = makeNext();
270
+ const req = makeRequest({
271
+ isMutation: true,
272
+ operationName: 'CustomerCreate',
273
+ });
274
+
275
+ // Provider throws → error propagates (not caught by middleware)
276
+ await expect(mw(req, next)).rejects.toThrow('Provider error');
277
+ });
278
+
279
+ it('should treat empty string token as falsy (no header added)', async () => {
280
+ const provider = makeTokenProvider('');
281
+ const mw = botProtectionMiddleware({
282
+ tokenProvider: provider,
283
+ protectedOperations,
284
+ });
285
+ const next = makeNext();
286
+ const req = makeRequest({
287
+ isMutation: true,
288
+ operationName: 'CustomerCreate',
289
+ });
290
+
291
+ await mw(req, next);
292
+
293
+ // Empty string is falsy → no header, but fail-open passes through
294
+ expect(req.headers[BOT_PROTECTION_HEADER]).toBeUndefined();
295
+ expect(next).toHaveBeenCalled();
296
+ });
297
+
298
+ it('should do nothing when protectedOperations list is empty', async () => {
299
+ const provider = makeTokenProvider('token');
300
+ const mw = botProtectionMiddleware({
301
+ tokenProvider: provider,
302
+ protectedOperations: [], // empty
303
+ });
304
+ const next = makeNext();
305
+ const req = makeRequest({
306
+ isMutation: true,
307
+ operationName: 'CustomerCreate',
308
+ });
309
+
310
+ await mw(req, next);
311
+
312
+ expect(provider.execute).not.toHaveBeenCalled();
313
+ expect(req.headers[BOT_PROTECTION_HEADER]).toBeUndefined();
314
+ });
315
+
316
+ it('should call execute fresh on every request (no caching)', async () => {
317
+ let callCount = 0;
318
+ const provider: BotProtectionTokenProvider = {
319
+ execute: vi.fn(async () => {
320
+ callCount++;
321
+ return `token-${callCount}`;
322
+ }),
323
+ destroy: vi.fn(),
324
+ };
325
+ const mw = botProtectionMiddleware({
326
+ tokenProvider: provider,
327
+ protectedOperations,
328
+ });
329
+
330
+ const req1 = makeRequest({ isMutation: true, operationName: 'CustomerCreate' });
331
+ await mw(req1, makeNext());
332
+ expect(req1.headers[BOT_PROTECTION_HEADER]).toBe('token-1');
333
+
334
+ const req2 = makeRequest({ isMutation: true, operationName: 'CustomerCreate' });
335
+ await mw(req2, makeNext());
336
+ expect(req2.headers[BOT_PROTECTION_HEADER]).toBe('token-2');
337
+
338
+ expect(provider.execute).toHaveBeenCalledTimes(2);
339
+ });
340
+ });
341
+
342
+ // ---------------------------------------------------------------------------
343
+ // FallbackBotProtectionManager
344
+ // ---------------------------------------------------------------------------
345
+
346
+ describe('FallbackBotProtectionManager', () => {
347
+ it('should return token from primary on success', async () => {
348
+ const primary = makeTokenProvider('primary-token');
349
+ const fallback = makeTokenProvider('fallback-token');
350
+ const manager = new FallbackBotProtectionManager(primary, fallback);
351
+
352
+ const token = await manager.execute({ action: 'test' });
353
+
354
+ expect(token).toBe('primary-token');
355
+ expect(primary.execute).toHaveBeenCalled();
356
+ expect(fallback.execute).not.toHaveBeenCalled();
357
+ });
358
+
359
+ it('should fallback when primary returns null', async () => {
360
+ const primary = makeTokenProvider(null);
361
+ const fallback = makeTokenProvider('fallback-token');
362
+ const manager = new FallbackBotProtectionManager(primary, fallback);
363
+
364
+ const token = await manager.execute({ action: 'test' });
365
+
366
+ expect(token).toBe('fallback-token');
367
+ expect(primary.execute).toHaveBeenCalled();
368
+ expect(fallback.execute).toHaveBeenCalled();
369
+ });
370
+
371
+ it('should fallback when primary throws', async () => {
372
+ const primary = makeTokenProvider(null, { shouldThrow: true });
373
+ const fallback = makeTokenProvider('fallback-token');
374
+ const manager = new FallbackBotProtectionManager(primary, fallback);
375
+
376
+ const token = await manager.execute({ action: 'test' });
377
+
378
+ expect(token).toBe('fallback-token');
379
+ });
380
+
381
+ it('should return null when both fail (fail-open)', async () => {
382
+ const primary = makeTokenProvider(null, { shouldThrow: true });
383
+ const fallback = makeTokenProvider(null, { shouldThrow: true });
384
+ const manager = new FallbackBotProtectionManager(primary, fallback);
385
+
386
+ const token = await manager.execute({ action: 'test' });
387
+
388
+ expect(token).toBeNull();
389
+ });
390
+
391
+ it('should return null when primary fails and no fallback', async () => {
392
+ const primary = makeTokenProvider(null, { shouldThrow: true });
393
+ const manager = new FallbackBotProtectionManager(primary, null);
394
+
395
+ const token = await manager.execute({ action: 'test' });
396
+
397
+ expect(token).toBeNull();
398
+ });
399
+
400
+ it('should return null when primary returns null and no fallback', async () => {
401
+ const primary = makeTokenProvider(null);
402
+ const manager = new FallbackBotProtectionManager(primary, null);
403
+
404
+ const token = await manager.execute({ action: 'test' });
405
+
406
+ expect(token).toBeNull();
407
+ });
408
+
409
+ it('should destroy both primary and fallback', () => {
410
+ const primary = makeTokenProvider();
411
+ const fallback = makeTokenProvider();
412
+ const manager = new FallbackBotProtectionManager(primary, fallback);
413
+
414
+ manager.destroy();
415
+
416
+ expect(primary.destroy).toHaveBeenCalled();
417
+ expect(fallback.destroy).toHaveBeenCalled();
418
+ });
419
+
420
+ it('should handle destroy with null fallback', () => {
421
+ const primary = makeTokenProvider();
422
+ const manager = new FallbackBotProtectionManager(primary, null);
423
+
424
+ // Should not throw
425
+ manager.destroy();
426
+ expect(primary.destroy).toHaveBeenCalled();
427
+ });
428
+
429
+ it('should pass options through to providers', async () => {
430
+ const primary = makeTokenProvider('token');
431
+ const manager = new FallbackBotProtectionManager(primary, null);
432
+
433
+ await manager.execute({ action: 'CustomerCreate', timeoutMs: 5000 });
434
+
435
+ expect(primary.execute).toHaveBeenCalledWith({
436
+ action: 'CustomerCreate',
437
+ timeoutMs: 5000,
438
+ });
439
+ });
440
+
441
+ it('should treat empty string token from primary as falsy and fallback', async () => {
442
+ const primary = makeTokenProvider('');
443
+ const fallback = makeTokenProvider('fallback-token');
444
+ const manager = new FallbackBotProtectionManager(primary, fallback);
445
+
446
+ const token = await manager.execute();
447
+
448
+ // Empty string is falsy → should fallback
449
+ expect(token).toBe('fallback-token');
450
+ });
451
+
452
+ it('should work when called without options (undefined)', async () => {
453
+ const primary = makeTokenProvider('token');
454
+ const manager = new FallbackBotProtectionManager(primary, null);
455
+
456
+ const token = await manager.execute();
457
+
458
+ expect(token).toBe('token');
459
+ expect(primary.execute).toHaveBeenCalledWith(undefined);
460
+ });
461
+ });