@accounter/scraper-app 0.0.1

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 +90 -0
  2. package/docs/plan.md +76 -0
  3. package/index.html +12 -0
  4. package/package.json +40 -0
  5. package/src/env.template +2 -0
  6. package/src/server/__tests__/accounts-routes.test.ts +133 -0
  7. package/src/server/__tests__/check-accounts.test.ts +305 -0
  8. package/src/server/__tests__/filter-payload.test.ts +193 -0
  9. package/src/server/__tests__/graphql-client.integration.test.ts +98 -0
  10. package/src/server/__tests__/graphql-client.test.ts +508 -0
  11. package/src/server/__tests__/healthz.test.ts +22 -0
  12. package/src/server/__tests__/history.test.ts +111 -0
  13. package/src/server/__tests__/otp-manager.test.ts +132 -0
  14. package/src/server/__tests__/scrape-runner.test.ts +144 -0
  15. package/src/server/__tests__/settings-routes.test.ts +117 -0
  16. package/src/server/__tests__/sources-routes.test.ts +149 -0
  17. package/src/server/__tests__/validate-payload.test.ts +193 -0
  18. package/src/server/__tests__/vault-routes.test.ts +174 -0
  19. package/src/server/__tests__/vault.test.ts +33 -0
  20. package/src/server/__tests__/websocket.test.ts +151 -0
  21. package/src/server/account-discovery.ts +49 -0
  22. package/src/server/accounts-routes.ts +74 -0
  23. package/src/server/check-accounts.ts +79 -0
  24. package/src/server/filter-payload.ts +145 -0
  25. package/src/server/graphql/client.ts +103 -0
  26. package/src/server/graphql/mutations.ts +518 -0
  27. package/src/server/history-routes.ts +11 -0
  28. package/src/server/history.ts +53 -0
  29. package/src/server/index.ts +40 -0
  30. package/src/server/otp-manager.ts +63 -0
  31. package/src/server/payload-schemas/amex.schema.ts +2 -0
  32. package/src/server/payload-schemas/cal.schema.ts +27 -0
  33. package/src/server/payload-schemas/currency-rates.schema.ts +11 -0
  34. package/src/server/payload-schemas/discount.schema.ts +26 -0
  35. package/src/server/payload-schemas/isracard.schema.ts +58 -0
  36. package/src/server/payload-schemas/max.schema.ts +27 -0
  37. package/src/server/payload-schemas/poalim-foreign.schema.ts +30 -0
  38. package/src/server/payload-schemas/poalim-ils.schema.ts +31 -0
  39. package/src/server/payload-schemas/poalim-swift.schema.ts +21 -0
  40. package/src/server/scrape-runner.ts +165 -0
  41. package/src/server/scrapers/__tests__/amex.test.ts +142 -0
  42. package/src/server/scrapers/__tests__/cal.test.ts +135 -0
  43. package/src/server/scrapers/__tests__/currency-rates.test.ts +105 -0
  44. package/src/server/scrapers/__tests__/discount.test.ts +160 -0
  45. package/src/server/scrapers/__tests__/isracard.test.ts +142 -0
  46. package/src/server/scrapers/__tests__/max.test.ts +115 -0
  47. package/src/server/scrapers/__tests__/poalim.test.ts +154 -0
  48. package/src/server/scrapers/amex.ts +63 -0
  49. package/src/server/scrapers/cal.ts +56 -0
  50. package/src/server/scrapers/currency-rates.ts +64 -0
  51. package/src/server/scrapers/discount.ts +62 -0
  52. package/src/server/scrapers/isracard.ts +68 -0
  53. package/src/server/scrapers/max.ts +32 -0
  54. package/src/server/scrapers/poalim.ts +103 -0
  55. package/src/server/settings-routes.ts +27 -0
  56. package/src/server/sources-routes.ts +182 -0
  57. package/src/server/validate-payload.ts +74 -0
  58. package/src/server/vault-routes.ts +99 -0
  59. package/src/server/vault-store.ts +42 -0
  60. package/src/server/vault.ts +216 -0
  61. package/src/server/websocket.ts +454 -0
  62. package/src/shared/source-types.ts +10 -0
  63. package/src/shared/types.ts +20 -0
  64. package/src/shared/ws-protocol.ts +177 -0
  65. package/src/test-setup.ts +6 -0
  66. package/src/ui/__tests__/accounts-tab.test.tsx +134 -0
  67. package/src/ui/__tests__/config.test.tsx +99 -0
  68. package/src/ui/__tests__/history.test.tsx +94 -0
  69. package/src/ui/__tests__/run.test.tsx +195 -0
  70. package/src/ui/__tests__/settings-tab.test.tsx +79 -0
  71. package/src/ui/__tests__/sources-tab.test.tsx +139 -0
  72. package/src/ui/__tests__/vault-setup.test.tsx +105 -0
  73. package/src/ui/__tests__/vault-unlock.test.tsx +78 -0
  74. package/src/ui/app.tsx +109 -0
  75. package/src/ui/components/error-boundary.tsx +54 -0
  76. package/src/ui/components/otp-modal.tsx +82 -0
  77. package/src/ui/components/skeleton.tsx +58 -0
  78. package/src/ui/components/task-row.tsx +241 -0
  79. package/src/ui/contexts/vault-context.tsx +77 -0
  80. package/src/ui/lib/api.ts +117 -0
  81. package/src/ui/lib/ws.ts +137 -0
  82. package/src/ui/main.tsx +9 -0
  83. package/src/ui/screens/config/accounts-tab.tsx +185 -0
  84. package/src/ui/screens/config/config.tsx +163 -0
  85. package/src/ui/screens/config/settings-tab.tsx +167 -0
  86. package/src/ui/screens/config/source-forms.tsx +518 -0
  87. package/src/ui/screens/config/source-types.ts +91 -0
  88. package/src/ui/screens/config/sources-tab.tsx +176 -0
  89. package/src/ui/screens/history.tsx +234 -0
  90. package/src/ui/screens/run.tsx +266 -0
  91. package/src/ui/screens/vault-setup.tsx +120 -0
  92. package/src/ui/screens/vault-unlock.tsx +38 -0
  93. package/tsconfig.json +15 -0
  94. package/tsup.config.ts +10 -0
  95. package/vite.config.ts +24 -0
  96. package/vitest.config.ts +7 -0
@@ -0,0 +1,508 @@
1
+ import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest';
2
+ import { setupServer } from 'msw/node';
3
+ import { graphql, HttpResponse } from 'msw';
4
+ import { createUploadClient } from '../graphql/client.js';
5
+
6
+ const MOCK_URL = 'http://localhost:4000/graphql';
7
+ const MOCK_API_KEY = 'test-api-key-123';
8
+ const MOCK_RESULT = {
9
+ inserted: 2,
10
+ skipped: 1,
11
+ insertedIds: ['id-1', 'id-2'],
12
+ insertedTransactions: [],
13
+ changedTransactions: [],
14
+ };
15
+
16
+ // ── MSW server ────────────────────────────────────────────────────────────────
17
+
18
+ let lastAuthHeader: string | null = null;
19
+ let lastMutationName: string | null = null;
20
+ let lastVariables: Record<string, unknown> = {};
21
+
22
+ const server = setupServer(
23
+ graphql.link(MOCK_URL).mutation('UploadPoalimIlsTransactions', ({ request, variables }) => {
24
+ lastAuthHeader = request.headers.get('X-API-Key');
25
+ lastMutationName = 'UploadPoalimIlsTransactions';
26
+ lastVariables = variables as Record<string, unknown>;
27
+ return HttpResponse.json({ data: { uploadPoalimIlsTransactions: MOCK_RESULT } });
28
+ }),
29
+
30
+ graphql.link(MOCK_URL).mutation('UploadPoalimForeignTransactions', ({ request, variables }) => {
31
+ lastAuthHeader = request.headers.get('X-API-Key');
32
+ lastMutationName = 'UploadPoalimForeignTransactions';
33
+ lastVariables = variables as Record<string, unknown>;
34
+ return HttpResponse.json({ data: { uploadPoalimForeignTransactions: MOCK_RESULT } });
35
+ }),
36
+
37
+ graphql.link(MOCK_URL).mutation('UploadPoalimSwiftTransactions', ({ request, variables }) => {
38
+ lastAuthHeader = request.headers.get('X-API-Key');
39
+ lastMutationName = 'UploadPoalimSwiftTransactions';
40
+ lastVariables = variables as Record<string, unknown>;
41
+ return HttpResponse.json({ data: { uploadPoalimSwiftTransactions: MOCK_RESULT } });
42
+ }),
43
+
44
+ graphql.link(MOCK_URL).mutation('UploadIsracardTransactions', ({ request, variables }) => {
45
+ lastAuthHeader = request.headers.get('X-API-Key');
46
+ lastMutationName = 'UploadIsracardTransactions';
47
+ lastVariables = variables as Record<string, unknown>;
48
+ return HttpResponse.json({ data: { uploadIsracardTransactions: MOCK_RESULT } });
49
+ }),
50
+
51
+ graphql.link(MOCK_URL).mutation('UploadAmexTransactions', ({ request, variables }) => {
52
+ lastAuthHeader = request.headers.get('X-API-Key');
53
+ lastMutationName = 'UploadAmexTransactions';
54
+ lastVariables = variables as Record<string, unknown>;
55
+ return HttpResponse.json({ data: { uploadAmexTransactions: MOCK_RESULT } });
56
+ }),
57
+
58
+ graphql.link(MOCK_URL).mutation('UploadCalTransactions', ({ request, variables }) => {
59
+ lastAuthHeader = request.headers.get('X-API-Key');
60
+ lastMutationName = 'UploadCalTransactions';
61
+ lastVariables = variables as Record<string, unknown>;
62
+ return HttpResponse.json({ data: { uploadCalTransactions: MOCK_RESULT } });
63
+ }),
64
+
65
+ graphql.link(MOCK_URL).mutation('UploadDiscountTransactions', ({ request, variables }) => {
66
+ lastAuthHeader = request.headers.get('X-API-Key');
67
+ lastMutationName = 'UploadDiscountTransactions';
68
+ lastVariables = variables as Record<string, unknown>;
69
+ return HttpResponse.json({ data: { uploadDiscountTransactions: MOCK_RESULT } });
70
+ }),
71
+
72
+ graphql.link(MOCK_URL).mutation('UploadMaxTransactions', ({ request, variables }) => {
73
+ lastAuthHeader = request.headers.get('X-API-Key');
74
+ lastMutationName = 'UploadMaxTransactions';
75
+ lastVariables = variables as Record<string, unknown>;
76
+ return HttpResponse.json({ data: { uploadMaxTransactions: MOCK_RESULT } });
77
+ }),
78
+
79
+ graphql.link(MOCK_URL).mutation('UploadCurrencyRates', ({ request, variables }) => {
80
+ lastAuthHeader = request.headers.get('X-API-Key');
81
+ lastMutationName = 'UploadCurrencyRates';
82
+ lastVariables = variables as Record<string, unknown>;
83
+ return HttpResponse.json({ data: { uploadCurrencyRates: MOCK_RESULT } });
84
+ }),
85
+ );
86
+
87
+ beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
88
+ afterEach(() => {
89
+ server.resetHandlers();
90
+ lastAuthHeader = null;
91
+ lastMutationName = null;
92
+ lastVariables = {};
93
+ });
94
+ afterAll(() => server.close());
95
+
96
+ // ── Fixtures ──────────────────────────────────────────────────────────────────
97
+
98
+ const ILS_PAYLOAD = {
99
+ transactions: [
100
+ {
101
+ activityDescription: 'Credit',
102
+ activityTypeCode: 1,
103
+ eventAmount: 100,
104
+ eventDate: 20240101,
105
+ serialNumber: 1,
106
+ transactionType: 'REGULAR' as const,
107
+ currentBalance: 5000,
108
+ referenceNumber: 42,
109
+ },
110
+ ],
111
+ retrievalTransactionData: { accountNumber: 100000, branchNumber: 600, bankNumber: 12 },
112
+ };
113
+
114
+ const FOREIGN_PAYLOAD = {
115
+ balancesAndLimitsDataList: [
116
+ {
117
+ currencySwiftCode: 'USD',
118
+ currencyCode: 1,
119
+ transactions: [
120
+ {
121
+ activityDescription: 'Transfer',
122
+ activityTypeCode: 1,
123
+ eventAmount: 500,
124
+ currencySwiftCode: 'USD',
125
+ currencyRate: 3.7,
126
+ currentBalance: 1000,
127
+ referenceNumber: 99,
128
+ transactionType: 'REGULAR',
129
+ },
130
+ ],
131
+ },
132
+ ],
133
+ };
134
+
135
+ const SWIFT_PAYLOAD = {
136
+ swiftsList: [
137
+ {
138
+ startDate: 20240101,
139
+ swiftStatusCode: 'OK',
140
+ amount: 1000,
141
+ currencyCodeCatenatedKey: 'USD',
142
+ chargePartyName: 'Test',
143
+ referenceNumber: 'REF-1',
144
+ transferCatenatedId: 'TID-1',
145
+ },
146
+ ],
147
+ };
148
+
149
+ const ISRACARD_PAYLOAD = [
150
+ {
151
+ Header: { Status: '1', Message: null },
152
+ CardsTransactionsListBean: {
153
+ cardNumberList: ['card 7567'],
154
+ Index0: {
155
+ '@AllCards': 'AllCards',
156
+ CurrentCardTransactions: [
157
+ {
158
+ txnIsrael: [
159
+ {
160
+ cardIndex: '0',
161
+ supplierName: 'Shop',
162
+ dealSum: '100',
163
+ fullPurchaseDate: '01/01/2024',
164
+ purchaseDate: '01/01/2024',
165
+ voucherNumber: '1234',
166
+ voucherNumberRatz: '5678',
167
+ },
168
+ ],
169
+ txnAbroad: null,
170
+ },
171
+ ],
172
+ },
173
+ },
174
+ },
175
+ ];
176
+
177
+ const CAL_PAYLOAD = [
178
+ {
179
+ card: '1234',
180
+ month: '2024-01',
181
+ transactions: [
182
+ {
183
+ trnIntId: 'TRN-1',
184
+ merchantName: 'Store',
185
+ trnAmt: 150,
186
+ trnPurchaseDate: '2024-01-15',
187
+ trnCurrencySymbol: 'ILS',
188
+ trnType: 'normal',
189
+ debCrdDate: '2024-02-01',
190
+ amtBeforeConvAndIndex: 150,
191
+ debCrdCurrencySymbol: 'ILS',
192
+ },
193
+ ],
194
+ },
195
+ ];
196
+
197
+ const DISCOUNT_PAYLOAD = [
198
+ {
199
+ accountNumber: 'ACC-001',
200
+ month: '2024-01',
201
+ balance: 5000,
202
+ transactions: [
203
+ {
204
+ OperationDate: '20240101',
205
+ ValueDate: '20240101',
206
+ OperationCode: '1',
207
+ OperationDescription: 'Credit',
208
+ OperationAmount: 1000,
209
+ BalanceAfterOperation: 5000,
210
+ OperationNumber: 1,
211
+ OperationDescription2: '',
212
+ OperationDescription3: '',
213
+ OperationBranch: 1,
214
+ OperationBank: 1,
215
+ Channel: 'web',
216
+ ChannelName: 'Web',
217
+ InstituteCode: '1',
218
+ BranchTreasuryNumber: '1',
219
+ Urn: 'urn-1',
220
+ OperationDetailsServiceName: '',
221
+ CommissionChannelCode: '',
222
+ CommissionChannelName: '',
223
+ CommissionTypeName: '',
224
+ BusinessDayDate: '20240101',
225
+ EventName: '',
226
+ CategoryCode: 1,
227
+ CategoryDescCode: 1,
228
+ CategoryDescription: '',
229
+ OperationDescriptionToDisplay: 'Credit',
230
+ OperationOrder: 1,
231
+ IsLastSeen: false,
232
+ },
233
+ ],
234
+ },
235
+ ];
236
+
237
+ const MAX_PAYLOAD = [
238
+ {
239
+ accountNumber: '7',
240
+ txns: [
241
+ {
242
+ cardIndex: 7,
243
+ categoryId: 1,
244
+ merchantName: 'Store',
245
+ originalAmount: 200,
246
+ originalCurrency: 'ILS',
247
+ purchaseDate: '2024-01-15',
248
+ uid: 'UID-1',
249
+ planName: 'regular',
250
+ planTypeId: 1,
251
+ },
252
+ ],
253
+ },
254
+ ];
255
+
256
+ const CURRENCY_RATES_PAYLOAD = [
257
+ { date: '2024-01-01', currency: 'USD' as const, rate: 3.712 },
258
+ { date: '2024-01-01', currency: 'EUR' as const, rate: 4.01 },
259
+ { date: '2024-01-02', currency: 'USD' as const, rate: 3.72 },
260
+ ];
261
+
262
+ // ── Helper ────────────────────────────────────────────────────────────────────
263
+
264
+ function client() {
265
+ return createUploadClient(MOCK_URL, MOCK_API_KEY);
266
+ }
267
+
268
+ // ── X-API-Key header ──────────────────────────────────────────────────────
269
+
270
+ describe('X-API-Key header', () => {
271
+ it('sends API key on every request', async () => {
272
+ await client().uploadPoalimIls(ILS_PAYLOAD);
273
+ expect(lastAuthHeader).toBe(MOCK_API_KEY);
274
+ });
275
+
276
+ it('uses the apiKey passed to createUploadClient', async () => {
277
+ const c = createUploadClient(MOCK_URL, 'different-key');
278
+ await c.uploadPoalimIls(ILS_PAYLOAD);
279
+ expect(lastAuthHeader).toBe('different-key');
280
+ });
281
+ });
282
+
283
+ // ── Mutation names and variables ──────────────────────────────────────────────
284
+
285
+ describe('uploadPoalimIls', () => {
286
+ it('sends correct mutation name', async () => {
287
+ await client().uploadPoalimIls(ILS_PAYLOAD);
288
+ expect(lastMutationName).toBe('UploadPoalimIlsTransactions');
289
+ });
290
+
291
+ it('embeds accountNumber and branchNumber inside each transaction row', async () => {
292
+ await client().uploadPoalimIls(ILS_PAYLOAD);
293
+ const txns = lastVariables['transactions'] as unknown[];
294
+ expect(Array.isArray(txns)).toBe(true);
295
+ expect((txns[0] as Record<string, unknown>)['accountNumber']).toBe(100000);
296
+ expect((txns[0] as Record<string, unknown>)['branchNumber']).toBe(600);
297
+ });
298
+
299
+ it('sends transactions array as variable', async () => {
300
+ await client().uploadPoalimIls(ILS_PAYLOAD);
301
+ expect(Array.isArray(lastVariables['transactions'])).toBe(true);
302
+ });
303
+
304
+ it('returns UploadResult', async () => {
305
+ const result = await client().uploadPoalimIls(ILS_PAYLOAD);
306
+ expect(result).toEqual(MOCK_RESULT);
307
+ });
308
+ });
309
+
310
+ describe('uploadPoalimForeign', () => {
311
+ it('sends correct mutation name', async () => {
312
+ await client().uploadPoalimForeign(FOREIGN_PAYLOAD);
313
+ expect(lastMutationName).toBe('UploadPoalimForeignTransactions');
314
+ });
315
+
316
+ it('flattens currency entries into a single transactions array', async () => {
317
+ await client().uploadPoalimForeign(FOREIGN_PAYLOAD);
318
+ const txns = lastVariables['transactions'] as unknown[];
319
+ expect(Array.isArray(txns)).toBe(true);
320
+ expect(txns).toHaveLength(1);
321
+ expect((txns[0] as Record<string, unknown>)['currencySwiftCode']).toBe('USD');
322
+ });
323
+
324
+ it('returns UploadResult', async () => {
325
+ const result = await client().uploadPoalimForeign(FOREIGN_PAYLOAD);
326
+ expect(result).toEqual(MOCK_RESULT);
327
+ });
328
+ });
329
+
330
+ describe('uploadPoalimSwift', () => {
331
+ it('sends correct mutation name', async () => {
332
+ await client().uploadPoalimSwift(SWIFT_PAYLOAD);
333
+ expect(lastMutationName).toBe('UploadPoalimSwiftTransactions');
334
+ });
335
+
336
+ it('sends swifts variable', async () => {
337
+ await client().uploadPoalimSwift(SWIFT_PAYLOAD);
338
+ expect(Array.isArray(lastVariables['swifts'])).toBe(true);
339
+ expect(lastVariables['swifts'] as unknown[]).toHaveLength(1);
340
+ });
341
+
342
+ it('returns UploadResult', async () => {
343
+ const result = await client().uploadPoalimSwift(SWIFT_PAYLOAD);
344
+ expect(result).toEqual(MOCK_RESULT);
345
+ });
346
+ });
347
+
348
+ describe('uploadIsracard', () => {
349
+ it('sends correct mutation name', async () => {
350
+ await client().uploadIsracard(ISRACARD_PAYLOAD);
351
+ expect(lastMutationName).toBe('UploadIsracardTransactions');
352
+ });
353
+
354
+ it('flattens txnIsrael/txnAbroad entries with card attached', async () => {
355
+ await client().uploadIsracard(ISRACARD_PAYLOAD);
356
+ const txns = lastVariables['transactions'] as unknown[];
357
+ expect(Array.isArray(txns)).toBe(true);
358
+ expect(txns).toHaveLength(1);
359
+ expect((txns[0] as Record<string, unknown>)['card']).toBe('7567');
360
+ });
361
+
362
+ it('returns UploadResult', async () => {
363
+ const result = await client().uploadIsracard(ISRACARD_PAYLOAD);
364
+ expect(result).toEqual(MOCK_RESULT);
365
+ });
366
+ });
367
+
368
+ describe('uploadAmex', () => {
369
+ it('sends correct mutation name', async () => {
370
+ await client().uploadAmex(ISRACARD_PAYLOAD);
371
+ expect(lastMutationName).toBe('UploadAmexTransactions');
372
+ });
373
+
374
+ it('flattens with card attached', async () => {
375
+ await client().uploadAmex(ISRACARD_PAYLOAD);
376
+ const txns = lastVariables['transactions'] as unknown[];
377
+ expect((txns[0] as Record<string, unknown>)['card']).toBe('7567');
378
+ });
379
+
380
+ it('returns UploadResult', async () => {
381
+ const result = await client().uploadAmex(ISRACARD_PAYLOAD);
382
+ expect(result).toEqual(MOCK_RESULT);
383
+ });
384
+ });
385
+
386
+ describe('uploadCal', () => {
387
+ it('sends correct mutation name', async () => {
388
+ await client().uploadCal(CAL_PAYLOAD);
389
+ expect(lastMutationName).toBe('UploadCalTransactions');
390
+ });
391
+
392
+ it('flattens to per-transaction rows with card attached', async () => {
393
+ await client().uploadCal(CAL_PAYLOAD);
394
+ const txns = lastVariables['transactions'] as unknown[];
395
+ expect(Array.isArray(txns)).toBe(true);
396
+ expect(txns).toHaveLength(1);
397
+ expect((txns[0] as Record<string, unknown>)['card']).toBe('1234');
398
+ expect((txns[0] as Record<string, unknown>)['trnIntId']).toBe('TRN-1');
399
+ });
400
+
401
+ it('returns UploadResult', async () => {
402
+ const result = await client().uploadCal(CAL_PAYLOAD);
403
+ expect(result).toEqual(MOCK_RESULT);
404
+ });
405
+ });
406
+
407
+ describe('uploadDiscount', () => {
408
+ it('sends correct mutation name', async () => {
409
+ await client().uploadDiscount(DISCOUNT_PAYLOAD);
410
+ expect(lastMutationName).toBe('UploadDiscountTransactions');
411
+ });
412
+
413
+ it('flattens to per-transaction rows with accountNumber attached', async () => {
414
+ await client().uploadDiscount(DISCOUNT_PAYLOAD);
415
+ const txns = lastVariables['transactions'] as unknown[];
416
+ expect(Array.isArray(txns)).toBe(true);
417
+ expect(txns).toHaveLength(1);
418
+ expect((txns[0] as Record<string, unknown>)['accountNumber']).toBe('ACC-001');
419
+ expect((txns[0] as Record<string, unknown>)['urn']).toBe('urn-1');
420
+ });
421
+
422
+ it('returns UploadResult', async () => {
423
+ const result = await client().uploadDiscount(DISCOUNT_PAYLOAD);
424
+ expect(result).toEqual(MOCK_RESULT);
425
+ });
426
+ });
427
+
428
+ describe('uploadMax', () => {
429
+ it('sends correct mutation name', async () => {
430
+ await client().uploadMax(MAX_PAYLOAD);
431
+ expect(lastMutationName).toBe('UploadMaxTransactions');
432
+ });
433
+
434
+ it('flattens to per-transaction rows', async () => {
435
+ await client().uploadMax(MAX_PAYLOAD);
436
+ const txns = lastVariables['transactions'] as unknown[];
437
+ expect(Array.isArray(txns)).toBe(true);
438
+ expect(txns).toHaveLength(1);
439
+ expect((txns[0] as Record<string, unknown>)['uid']).toBe('UID-1');
440
+ });
441
+
442
+ it('returns UploadResult', async () => {
443
+ const result = await client().uploadMax(MAX_PAYLOAD);
444
+ expect(result).toEqual(MOCK_RESULT);
445
+ });
446
+ });
447
+
448
+ describe('uploadCurrencyRates', () => {
449
+ it('sends correct mutation name', async () => {
450
+ await client().uploadCurrencyRates(CURRENCY_RATES_PAYLOAD);
451
+ expect(lastMutationName).toBe('UploadCurrencyRates');
452
+ });
453
+
454
+ it('pivots { date, currency, rate }[] into { exchangeDate, usd, eur, ... }[] rows', async () => {
455
+ await client().uploadCurrencyRates(CURRENCY_RATES_PAYLOAD);
456
+ const rates = lastVariables['rates'] as Array<Record<string, unknown>>;
457
+ expect(Array.isArray(rates)).toBe(true);
458
+ // Two distinct dates in fixture
459
+ expect(rates).toHaveLength(2);
460
+ const jan1 = rates.find(r => r['exchangeDate'] === '2024-01-01')!;
461
+ expect(jan1['usd']).toBe(3.712);
462
+ expect(jan1['eur']).toBe(4.01);
463
+ const jan2 = rates.find(r => r['exchangeDate'] === '2024-01-02')!;
464
+ expect(jan2['usd']).toBe(3.72);
465
+ expect(jan2['eur']).toBeUndefined();
466
+ });
467
+
468
+ it('returns UploadResult', async () => {
469
+ const result = await client().uploadCurrencyRates(CURRENCY_RATES_PAYLOAD);
470
+ expect(result).toEqual(MOCK_RESULT);
471
+ });
472
+ });
473
+
474
+ // ── Error handling ────────────────────────────────────────────────────────────
475
+
476
+ describe('error responses', () => {
477
+ it('throws on 4xx HTTP response', async () => {
478
+ server.use(
479
+ graphql.link(MOCK_URL).mutation('UploadPoalimIlsTransactions', () => {
480
+ return HttpResponse.json({ errors: [{ message: 'Unauthorized' }] }, { status: 401 });
481
+ }),
482
+ );
483
+ await expect(client().uploadPoalimIls(ILS_PAYLOAD)).rejects.toThrow();
484
+ });
485
+
486
+ it('throws on 5xx HTTP response', async () => {
487
+ server.use(
488
+ graphql.link(MOCK_URL).mutation('UploadPoalimIlsTransactions', () => {
489
+ return HttpResponse.json(
490
+ { errors: [{ message: 'Internal Server Error' }] },
491
+ { status: 500 },
492
+ );
493
+ }),
494
+ );
495
+ await expect(client().uploadPoalimIls(ILS_PAYLOAD)).rejects.toThrow();
496
+ });
497
+
498
+ it('throws on GraphQL-level errors', async () => {
499
+ server.use(
500
+ graphql.link(MOCK_URL).mutation('UploadPoalimIlsTransactions', () => {
501
+ return HttpResponse.json({
502
+ errors: [{ message: 'Not authenticated', extensions: { code: 'UNAUTHENTICATED' } }],
503
+ });
504
+ }),
505
+ );
506
+ await expect(client().uploadPoalimIls(ILS_PAYLOAD)).rejects.toThrow();
507
+ });
508
+ });
@@ -0,0 +1,22 @@
1
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
2
+ import type { FastifyInstance } from 'fastify';
3
+ import { buildServer } from '../index.js';
4
+
5
+ let app: FastifyInstance;
6
+
7
+ beforeEach(async () => {
8
+ app = await buildServer();
9
+ await app.ready();
10
+ });
11
+
12
+ afterEach(async () => {
13
+ await app.close();
14
+ });
15
+
16
+ describe('GET /healthz', () => {
17
+ it('returns 200 with { ok: true }', async () => {
18
+ const res = await app.inject({ method: 'GET', url: '/healthz' });
19
+ expect(res.statusCode).toBe(200);
20
+ expect(res.json()).toEqual({ ok: true });
21
+ });
22
+ });
@@ -0,0 +1,111 @@
1
+ import { mkdtemp, rm } from 'node:fs/promises';
2
+ import { tmpdir } from 'node:os';
3
+ import { join } from 'node:path';
4
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
5
+ import { appendRun, clearHistory, readHistory } from '../history.js';
6
+ import type { RunRecord } from '../../shared/types.js';
7
+
8
+ function makeRecord(i: number): RunRecord {
9
+ return {
10
+ id: `run-${i}`,
11
+ startedAt: new Date(Date.now() + i * 1000).toISOString(),
12
+ completedAt: new Date(Date.now() + i * 1000 + 500).toISOString(),
13
+ totalInserted: i,
14
+ totalSkipped: 0,
15
+ errorCount: 0,
16
+ sources: [],
17
+ };
18
+ }
19
+
20
+ let tmpDir: string;
21
+ let filePath: string;
22
+
23
+ beforeEach(async () => {
24
+ tmpDir = await mkdtemp(join(tmpdir(), 'history-test-'));
25
+ filePath = join(tmpDir, 'history.json');
26
+ });
27
+
28
+ afterEach(async () => {
29
+ await rm(tmpDir, { recursive: true, force: true });
30
+ });
31
+
32
+ describe('readHistory', () => {
33
+ it('returns [] when file is missing', async () => {
34
+ expect(await readHistory(filePath)).toEqual([]);
35
+ });
36
+
37
+ it('returns records newest-first after appending 3', async () => {
38
+ await appendRun(makeRecord(1), filePath);
39
+ await appendRun(makeRecord(2), filePath);
40
+ await appendRun(makeRecord(3), filePath);
41
+
42
+ const result = await readHistory(filePath);
43
+ expect(result).toHaveLength(3);
44
+ expect(result[0]!.id).toBe('run-3');
45
+ expect(result[1]!.id).toBe('run-2');
46
+ expect(result[2]!.id).toBe('run-1');
47
+ });
48
+
49
+ it('returns last 100 records when 150 are appended', async () => {
50
+ for (let i = 1; i <= 150; i++) {
51
+ await appendRun(makeRecord(i), filePath);
52
+ }
53
+ const result = await readHistory(filePath);
54
+ expect(result).toHaveLength(100);
55
+ expect(result[0]!.id).toBe('run-150');
56
+ expect(result[99]!.id).toBe('run-51');
57
+ });
58
+ });
59
+
60
+ describe('concurrent writes', () => {
61
+ it('retains all records when appendRun is called concurrently', async () => {
62
+ // Fire 10 appends simultaneously — without the queue they would race and
63
+ // overwrite each other; with it every record must survive.
64
+ await Promise.all(Array.from({ length: 10 }, (_, i) => appendRun(makeRecord(i + 1), filePath)));
65
+
66
+ const result = await readHistory(filePath);
67
+ expect(result).toHaveLength(10);
68
+ const ids = new Set(result.map(r => r.id));
69
+ for (let i = 1; i <= 10; i++) expect(ids.has(`run-${i}`)).toBe(true);
70
+ });
71
+ });
72
+
73
+ describe('clearHistory', () => {
74
+ it('empties the history file', async () => {
75
+ await appendRun(makeRecord(1), filePath);
76
+ await clearHistory(filePath);
77
+ expect(await readHistory(filePath)).toEqual([]);
78
+ });
79
+ });
80
+
81
+ describe('saveHistory gate in websocket', () => {
82
+ it('does not call appendRun when saveHistory is false', async () => {
83
+ const spy = vi.spyOn(await import('../history.js'), 'appendRun').mockResolvedValue(undefined);
84
+
85
+ const { startRun } = await import('../scrape-runner.js');
86
+ const { _resetRunState } = await import('../scrape-runner.js');
87
+ _resetRunState();
88
+
89
+ const noopEmit = () => {};
90
+ const task = {
91
+ sourceId: 'test',
92
+ nickname: 'test',
93
+ type: 'discount',
94
+ run: async () => ({ inserted: 1, skipped: 0, insertedIds: [], insertedTransactions: [], changedTransactions: [] }),
95
+ };
96
+
97
+ const runRecord = await startRun([task], false, noopEmit);
98
+
99
+ // Simulate the saveHistory=false branch: appendRun should NOT be called
100
+ const saveHistory = false;
101
+ if (saveHistory) {
102
+ await appendRun(
103
+ { ...runRecord, startedAt: runRecord.startedAt.toISOString(), completedAt: runRecord.completedAt.toISOString(), sources: [] },
104
+ filePath,
105
+ );
106
+ }
107
+
108
+ expect(spy).not.toHaveBeenCalled();
109
+ spy.mockRestore();
110
+ });
111
+ });