@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,105 @@
1
+ import { describe, expect, it, vi, afterEach } from 'vitest';
2
+ import { scrapeCurrencyRates } from '../currency-rates.js';
3
+
4
+ const noop = () => {};
5
+
6
+ // Minimal valid BOI XML with one currency (USD) and one past date
7
+ const VALID_BOI_XML = `<?xml version="1.0" encoding="UTF-8"?>
8
+ <message:StructureSpecificData xmlns:message="http://www.sdmx.org/resources/sdmxml/schemas/v2_1/message">
9
+ <message:DataSet>
10
+ <Series BASE_CURRENCY="USD">
11
+ <Obs TIME_PERIOD="2024-01-01" OBS_VALUE="3.65"/>
12
+ </Series>
13
+ </message:DataSet>
14
+ </message:StructureSpecificData>`;
15
+
16
+ // XML with an unsupported currency
17
+ const BAD_CURRENCY_XML = `<?xml version="1.0" encoding="UTF-8"?>
18
+ <message:StructureSpecificData xmlns:message="http://www.sdmx.org/resources/sdmxml/schemas/v2_1/message">
19
+ <message:DataSet>
20
+ <Series BASE_CURRENCY="XYZ">
21
+ <Obs TIME_PERIOD="2024-01-01" OBS_VALUE="1.0"/>
22
+ </Series>
23
+ </message:DataSet>
24
+ </message:StructureSpecificData>`;
25
+
26
+ // XML that produces no valid entries (empty series)
27
+ const EMPTY_BOI_XML = `<?xml version="1.0" encoding="UTF-8"?>
28
+ <message:StructureSpecificData xmlns:message="http://www.sdmx.org/resources/sdmxml/schemas/v2_1/message">
29
+ <message:DataSet>
30
+ </message:DataSet>
31
+ </message:StructureSpecificData>`;
32
+
33
+ function mockFetch(xml: string, ok = true) {
34
+ vi.stubGlobal(
35
+ 'fetch',
36
+ vi.fn().mockResolvedValue({
37
+ ok,
38
+ status: ok ? 200 : 500,
39
+ statusText: ok ? 'OK' : 'Internal Server Error',
40
+ text: () => Promise.resolve(xml),
41
+ }),
42
+ );
43
+ }
44
+
45
+ afterEach(() => {
46
+ vi.unstubAllGlobals();
47
+ });
48
+
49
+ describe('scrapeCurrencyRates — happy path', () => {
50
+ it('resolves with a validated CurrencyRatesPayload', async () => {
51
+ mockFetch(VALID_BOI_XML);
52
+
53
+ const result = await scrapeCurrencyRates(noop);
54
+
55
+ expect(Array.isArray(result)).toBe(true);
56
+ expect(result.length).toBeGreaterThan(0);
57
+ expect(result[0]).toMatchObject({ date: '2024-01-01', currency: 'USD', rate: 3.65 });
58
+ });
59
+ });
60
+
61
+ describe('scrapeCurrencyRates — fetch error', () => {
62
+ it('throws when the BOI fetch returns a non-OK status', async () => {
63
+ mockFetch('', false);
64
+
65
+ await expect(scrapeCurrencyRates(noop)).rejects.toThrow('BOI fetch failed');
66
+ });
67
+ });
68
+
69
+ describe('scrapeCurrencyRates — empty response', () => {
70
+ it('returns an empty array when no valid series are found', async () => {
71
+ mockFetch(EMPTY_BOI_XML);
72
+
73
+ // No series → no entries → empty validated array
74
+ const result = await scrapeCurrencyRates(noop);
75
+ expect(result).toEqual([]);
76
+ });
77
+ });
78
+
79
+ describe('scrapeCurrencyRates — unsupported currency', () => {
80
+ it('skips entries with unsupported currencies without throwing', async () => {
81
+ mockFetch(BAD_CURRENCY_XML);
82
+
83
+ const result = await scrapeCurrencyRates(noop);
84
+ expect(result).toEqual([]);
85
+ });
86
+ });
87
+
88
+ describe('scrapeCurrencyRates — NaN rate', () => {
89
+ it('skips entries where OBS_VALUE is not a valid number', async () => {
90
+ const badRateXml = `<?xml version="1.0" encoding="UTF-8"?>
91
+ <message:StructureSpecificData xmlns:message="http://www.sdmx.org/resources/sdmxml/schemas/v2_1/message">
92
+ <message:DataSet>
93
+ <Series BASE_CURRENCY="USD">
94
+ <Obs TIME_PERIOD="2024-01-01" OBS_VALUE="not-a-number"/>
95
+ </Series>
96
+ </message:DataSet>
97
+ </message:StructureSpecificData>`;
98
+
99
+ mockFetch(badRateXml);
100
+
101
+ // NaN is filtered out by the isNaN guard, resulting in an empty array
102
+ const result = await scrapeCurrencyRates(noop);
103
+ expect(result).toEqual([]);
104
+ });
105
+ });
@@ -0,0 +1,160 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import { PayloadValidationError } from '../../validate-payload.js';
3
+ import { scrapeDiscount } from '../discount.js';
4
+
5
+ const CREDS = { id: 'src-1', ID: '123456789', password: 'pass' };
6
+ const DATE_FROM = new Date('2024-01-01');
7
+ const DATE_TO = new Date('2024-01-31');
8
+ const noop = () => {};
9
+
10
+ const VALID_MONTH_RESULT = {
11
+ success: true,
12
+ accountNumber: 'ACC-001',
13
+ balance: 5000,
14
+ transactions: [
15
+ {
16
+ OperationDate: '20240101',
17
+ ValueDate: '20240101',
18
+ OperationCode: '1',
19
+ OperationDescription: 'Credit',
20
+ OperationAmount: 1000,
21
+ BalanceAfterOperation: 5000,
22
+ OperationNumber: 1,
23
+ OperationDescription2: '',
24
+ OperationDescription3: '',
25
+ OperationBranch: 1,
26
+ OperationBank: 1,
27
+ Channel: 'web',
28
+ ChannelName: 'Web',
29
+ InstituteCode: '1',
30
+ BranchTreasuryNumber: '1',
31
+ Urn: 'urn-1',
32
+ OperationDetailsServiceName: '',
33
+ CommissionChannelCode: '',
34
+ CommissionChannelName: '',
35
+ CommissionTypeName: '',
36
+ BusinessDayDate: '20240101',
37
+ EventName: '',
38
+ CategoryCode: 1,
39
+ CategoryDescCode: 1,
40
+ CategoryDescription: '',
41
+ OperationDescriptionToDisplay: 'Credit',
42
+ OperationOrder: 1,
43
+ IsLastSeen: false,
44
+ },
45
+ ],
46
+ };
47
+
48
+ vi.mock('@accounter/modern-poalim-scraper', () => ({
49
+ init: vi.fn(),
50
+ }));
51
+
52
+ async function getInitMock() {
53
+ const mod = await import('@accounter/modern-poalim-scraper');
54
+ return mod.init as ReturnType<typeof vi.fn>;
55
+ }
56
+
57
+ describe('scrapeDiscount — happy path', () => {
58
+ it('resolves with a validated DiscountPayload containing one entry per month', async () => {
59
+ const initMock = await getInitMock();
60
+ initMock.mockResolvedValue({
61
+ discount: vi.fn().mockResolvedValue({
62
+ getMonthTransactions: vi.fn().mockResolvedValue(VALID_MONTH_RESULT),
63
+ }),
64
+ close: vi.fn().mockResolvedValue(undefined),
65
+ });
66
+
67
+ const result = await scrapeDiscount(CREDS, DATE_FROM, DATE_TO, noop);
68
+ expect(Array.isArray(result)).toBe(true);
69
+ expect(result).toHaveLength(1);
70
+ expect(result[0]).toMatchObject({
71
+ accountNumber: 'ACC-001',
72
+ month: '2024-01',
73
+ balance: 5000,
74
+ });
75
+ expect(result[0]!.transactions).toHaveLength(1);
76
+ });
77
+
78
+ it('fetches one month per month in the date range', async () => {
79
+ const getMonthTransactions = vi.fn().mockResolvedValue(VALID_MONTH_RESULT);
80
+ const initMock = await getInitMock();
81
+ initMock.mockResolvedValue({
82
+ discount: vi.fn().mockResolvedValue({ getMonthTransactions }),
83
+ close: vi.fn().mockResolvedValue(undefined),
84
+ });
85
+
86
+ const threeMonthsFrom = new Date('2024-01-01');
87
+ const threeMonthsTo = new Date('2024-03-31');
88
+ const result = await scrapeDiscount(CREDS, threeMonthsFrom, threeMonthsTo, noop);
89
+
90
+ expect(getMonthTransactions).toHaveBeenCalledTimes(3);
91
+ expect(result).toHaveLength(3);
92
+ expect(result.map(r => r.month)).toEqual(['2024-01', '2024-02', '2024-03']);
93
+ });
94
+
95
+ it('passes the correct month Date to getMonthTransactions', async () => {
96
+ const getMonthTransactions = vi.fn().mockResolvedValue(VALID_MONTH_RESULT);
97
+ const initMock = await getInitMock();
98
+ initMock.mockResolvedValue({
99
+ discount: vi.fn().mockResolvedValue({ getMonthTransactions }),
100
+ close: vi.fn().mockResolvedValue(undefined),
101
+ });
102
+
103
+ await scrapeDiscount(CREDS, DATE_FROM, DATE_TO, noop);
104
+ expect(getMonthTransactions).toHaveBeenCalledWith(expect.any(Date));
105
+ });
106
+
107
+ it('calls close() even on success', async () => {
108
+ const close = vi.fn().mockResolvedValue(undefined);
109
+ const initMock = await getInitMock();
110
+ initMock.mockResolvedValue({
111
+ discount: vi.fn().mockResolvedValue({
112
+ getMonthTransactions: vi.fn().mockResolvedValue(VALID_MONTH_RESULT),
113
+ }),
114
+ close,
115
+ });
116
+
117
+ await scrapeDiscount(CREDS, DATE_FROM, DATE_TO, noop);
118
+ expect(close).toHaveBeenCalledOnce();
119
+ });
120
+ });
121
+
122
+ describe('scrapeDiscount — invalid payload', () => {
123
+ it('throws PayloadValidationError when data fails schema validation', async () => {
124
+ const initMock = await getInitMock();
125
+ initMock.mockResolvedValue({
126
+ discount: vi.fn().mockResolvedValue({
127
+ getMonthTransactions: vi.fn().mockResolvedValue({
128
+ success: true,
129
+ accountNumber: 'ACC-001',
130
+ balance: 0,
131
+ transactions: [{ OperationDate: 'bad' }], // missing required fields
132
+ }),
133
+ }),
134
+ close: vi.fn().mockResolvedValue(undefined),
135
+ });
136
+
137
+ await expect(scrapeDiscount(CREDS, DATE_FROM, DATE_TO, noop)).rejects.toBeInstanceOf(
138
+ PayloadValidationError,
139
+ );
140
+ });
141
+
142
+ it('calls close() even on validation error', async () => {
143
+ const close = vi.fn().mockResolvedValue(undefined);
144
+ const initMock = await getInitMock();
145
+ initMock.mockResolvedValue({
146
+ discount: vi.fn().mockResolvedValue({
147
+ getMonthTransactions: vi.fn().mockResolvedValue({
148
+ success: true,
149
+ accountNumber: 'ACC-001',
150
+ balance: 0,
151
+ transactions: [{ OperationDate: 'bad' }],
152
+ }),
153
+ }),
154
+ close,
155
+ });
156
+
157
+ await expect(scrapeDiscount(CREDS, DATE_FROM, DATE_TO, noop)).rejects.toThrow();
158
+ expect(close).toHaveBeenCalledOnce();
159
+ });
160
+ });
@@ -0,0 +1,142 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import { PayloadValidationError } from '../../validate-payload.js';
3
+ import { scrapeIsracard } from '../isracard.js';
4
+
5
+ const CREDS = { id: 'src-1', ownerId: '123456789', password: 'pass', last6Digits: '123456' };
6
+
7
+ const VALID_PAYLOAD = {
8
+ Header: { Status: '1', Message: null },
9
+ CardsTransactionsListBean: {
10
+ cardNumberList: ['123456'],
11
+ Index0: { '@AllCards': 'AllCards', CurrentCardTransactions: [] },
12
+ },
13
+ };
14
+
15
+ const DATE_FROM = new Date('2024-01-01');
16
+ const DATE_TO = new Date('2024-01-31');
17
+ const noop = () => {};
18
+
19
+ vi.mock('@accounter/modern-poalim-scraper', () => ({
20
+ init: vi.fn(),
21
+ }));
22
+
23
+ async function getInitMock() {
24
+ const mod = await import('@accounter/modern-poalim-scraper');
25
+ return mod.init as ReturnType<typeof vi.fn>;
26
+ }
27
+
28
+ describe('scrapeIsracard — happy path', () => {
29
+ it('resolves with an array of validated IsracardPayloads', async () => {
30
+ const initMock = await getInitMock();
31
+ initMock.mockResolvedValue({
32
+ isracard: vi.fn().mockResolvedValue({
33
+ getMonthTransactions: vi.fn().mockResolvedValue({ data: VALID_PAYLOAD, isValid: true }),
34
+ }),
35
+ close: vi.fn().mockResolvedValue(undefined),
36
+ });
37
+
38
+ const result = await scrapeIsracard(CREDS, DATE_FROM, DATE_TO, noop);
39
+ expect(Array.isArray(result)).toBe(true);
40
+ expect(result).toHaveLength(1);
41
+ expect(result[0]!.Header.Status).toBe('1');
42
+ });
43
+
44
+ it('fetches one month per month in the date range', async () => {
45
+ const getMonthTransactions = vi
46
+ .fn()
47
+ .mockResolvedValue({ data: VALID_PAYLOAD, isValid: true });
48
+ const initMock = await getInitMock();
49
+ initMock.mockResolvedValue({
50
+ isracard: vi.fn().mockResolvedValue({ getMonthTransactions }),
51
+ close: vi.fn().mockResolvedValue(undefined),
52
+ });
53
+
54
+ const threeMonthsFrom = new Date('2024-01-01');
55
+ const threeMonthsTo = new Date('2024-03-31');
56
+ await scrapeIsracard(CREDS, threeMonthsFrom, threeMonthsTo, noop);
57
+
58
+ expect(getMonthTransactions).toHaveBeenCalledTimes(3);
59
+ });
60
+
61
+ it('skips months where data is null', async () => {
62
+ const initMock = await getInitMock();
63
+ initMock.mockResolvedValue({
64
+ isracard: vi.fn().mockResolvedValue({
65
+ getMonthTransactions: vi.fn().mockResolvedValue({ data: null, isValid: null }),
66
+ }),
67
+ close: vi.fn().mockResolvedValue(undefined),
68
+ });
69
+
70
+ const result = await scrapeIsracard(CREDS, DATE_FROM, DATE_TO, noop);
71
+ expect(result).toEqual([]);
72
+ });
73
+
74
+ it('calls close() even on success', async () => {
75
+ const close = vi.fn().mockResolvedValue(undefined);
76
+ const initMock = await getInitMock();
77
+ initMock.mockResolvedValue({
78
+ isracard: vi.fn().mockResolvedValue({
79
+ getMonthTransactions: vi.fn().mockResolvedValue({ data: VALID_PAYLOAD, isValid: true }),
80
+ }),
81
+ close,
82
+ });
83
+
84
+ await scrapeIsracard(CREDS, DATE_FROM, DATE_TO, noop);
85
+ expect(close).toHaveBeenCalledOnce();
86
+ });
87
+ });
88
+
89
+ describe('scrapeIsracard — Header.Status check', () => {
90
+ it('throws when Header.Status is not "1"', async () => {
91
+ const initMock = await getInitMock();
92
+ initMock.mockResolvedValue({
93
+ isracard: vi.fn().mockResolvedValue({
94
+ getMonthTransactions: vi.fn().mockResolvedValue({
95
+ data: { ...VALID_PAYLOAD, Header: { Status: '2', Message: 'Replace password' } },
96
+ isValid: true,
97
+ }),
98
+ }),
99
+ close: vi.fn().mockResolvedValue(undefined),
100
+ });
101
+
102
+ await expect(scrapeIsracard(CREDS, DATE_FROM, DATE_TO, noop)).rejects.toThrow(
103
+ 'login/password issue',
104
+ );
105
+ });
106
+ });
107
+
108
+ describe('scrapeIsracard — invalid payload', () => {
109
+ it('throws PayloadValidationError when data fails schema validation', async () => {
110
+ const initMock = await getInitMock();
111
+ initMock.mockResolvedValue({
112
+ isracard: vi.fn().mockResolvedValue({
113
+ getMonthTransactions: vi.fn().mockResolvedValue({
114
+ data: { Header: { Status: '1', Message: null }, unexpected: 'shape' },
115
+ isValid: true,
116
+ }),
117
+ }),
118
+ close: vi.fn().mockResolvedValue(undefined),
119
+ });
120
+
121
+ await expect(scrapeIsracard(CREDS, DATE_FROM, DATE_TO, noop)).rejects.toBeInstanceOf(
122
+ PayloadValidationError,
123
+ );
124
+ });
125
+
126
+ it('calls close() even on validation error', async () => {
127
+ const close = vi.fn().mockResolvedValue(undefined);
128
+ const initMock = await getInitMock();
129
+ initMock.mockResolvedValue({
130
+ isracard: vi.fn().mockResolvedValue({
131
+ getMonthTransactions: vi.fn().mockResolvedValue({
132
+ data: { Header: { Status: '1', Message: null }, unexpected: 'shape' },
133
+ isValid: true,
134
+ }),
135
+ }),
136
+ close,
137
+ });
138
+
139
+ await expect(scrapeIsracard(CREDS, DATE_FROM, DATE_TO, noop)).rejects.toThrow();
140
+ expect(close).toHaveBeenCalledOnce();
141
+ });
142
+ });
@@ -0,0 +1,115 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import { PayloadValidationError } from '../../validate-payload.js';
3
+ import { scrapeMax } from '../max.js';
4
+
5
+ const CREDS = { id: 'src-1', username: 'user', password: 'pass' };
6
+ const DATE_FROM = new Date('2024-01-01');
7
+ const DATE_TO = new Date('2024-01-31');
8
+ const noop = () => {};
9
+
10
+ const VALID_PAYLOAD = [
11
+ { accountNumber: '1234', txns: [] },
12
+ ];
13
+
14
+ vi.mock('@accounter/modern-poalim-scraper', () => ({
15
+ init: vi.fn(),
16
+ }));
17
+
18
+ async function getInitMock() {
19
+ const mod = await import('@accounter/modern-poalim-scraper');
20
+ return mod.init as ReturnType<typeof vi.fn>;
21
+ }
22
+
23
+ describe('scrapeMax — happy path', () => {
24
+ it('resolves with a validated MaxPayload (array of accounts)', async () => {
25
+ const initMock = await getInitMock();
26
+ initMock.mockResolvedValue({
27
+ max: vi.fn().mockResolvedValue({
28
+ getTransactions: vi.fn().mockResolvedValue(VALID_PAYLOAD),
29
+ }),
30
+ close: vi.fn().mockResolvedValue(undefined),
31
+ });
32
+
33
+ const result = await scrapeMax(CREDS, DATE_FROM, DATE_TO, noop);
34
+ expect(Array.isArray(result)).toBe(true);
35
+ expect(result).toHaveLength(1);
36
+ expect(result[0]!.accountNumber).toBe('1234');
37
+ expect(result[0]!.txns).toEqual([]);
38
+ });
39
+
40
+ it('passes startDate to the scraper via MaxOptions', async () => {
41
+ const maxFn = vi.fn().mockResolvedValue({
42
+ getTransactions: vi.fn().mockResolvedValue(VALID_PAYLOAD),
43
+ });
44
+ const initMock = await getInitMock();
45
+ initMock.mockResolvedValue({ max: maxFn, close: vi.fn().mockResolvedValue(undefined) });
46
+
47
+ await scrapeMax(CREDS, DATE_FROM, DATE_TO, noop);
48
+
49
+ expect(maxFn).toHaveBeenCalledWith(
50
+ { username: 'user', password: 'pass' },
51
+ { startDate: DATE_FROM },
52
+ );
53
+ });
54
+
55
+ it('returns multiple accounts when the scraper reports them', async () => {
56
+ const multiAccountPayload = [
57
+ { accountNumber: 'ACC-1', txns: [] },
58
+ { accountNumber: 'ACC-2', txns: [] },
59
+ ];
60
+ const initMock = await getInitMock();
61
+ initMock.mockResolvedValue({
62
+ max: vi.fn().mockResolvedValue({
63
+ getTransactions: vi.fn().mockResolvedValue(multiAccountPayload),
64
+ }),
65
+ close: vi.fn().mockResolvedValue(undefined),
66
+ });
67
+
68
+ const result = await scrapeMax(CREDS, DATE_FROM, DATE_TO, noop);
69
+ expect(result.map(a => a.accountNumber)).toEqual(['ACC-1', 'ACC-2']);
70
+ });
71
+
72
+ it('calls close() even on success', async () => {
73
+ const close = vi.fn().mockResolvedValue(undefined);
74
+ const initMock = await getInitMock();
75
+ initMock.mockResolvedValue({
76
+ max: vi.fn().mockResolvedValue({
77
+ getTransactions: vi.fn().mockResolvedValue(VALID_PAYLOAD),
78
+ }),
79
+ close,
80
+ });
81
+
82
+ await scrapeMax(CREDS, DATE_FROM, DATE_TO, noop);
83
+ expect(close).toHaveBeenCalledOnce();
84
+ });
85
+ });
86
+
87
+ describe('scrapeMax — invalid payload', () => {
88
+ it('throws PayloadValidationError when data fails schema validation', async () => {
89
+ const initMock = await getInitMock();
90
+ initMock.mockResolvedValue({
91
+ max: vi.fn().mockResolvedValue({
92
+ getTransactions: vi.fn().mockResolvedValue('not-an-array'),
93
+ }),
94
+ close: vi.fn().mockResolvedValue(undefined),
95
+ });
96
+
97
+ await expect(scrapeMax(CREDS, DATE_FROM, DATE_TO, noop)).rejects.toBeInstanceOf(
98
+ PayloadValidationError,
99
+ );
100
+ });
101
+
102
+ it('calls close() even on validation error', async () => {
103
+ const close = vi.fn().mockResolvedValue(undefined);
104
+ const initMock = await getInitMock();
105
+ initMock.mockResolvedValue({
106
+ max: vi.fn().mockResolvedValue({
107
+ getTransactions: vi.fn().mockResolvedValue('not-an-array'),
108
+ }),
109
+ close,
110
+ });
111
+
112
+ await expect(scrapeMax(CREDS, DATE_FROM, DATE_TO, noop)).rejects.toThrow();
113
+ expect(close).toHaveBeenCalledOnce();
114
+ });
115
+ });
@@ -0,0 +1,154 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import { OtpManager } from '../../otp-manager.js';
3
+ import { PayloadValidationError } from '../../validate-payload.js';
4
+ import { scrapePoalim } from '../poalim.js';
5
+
6
+ const VALID_ACCOUNTS = [{ bankNumber: 12, branchNumber: 600, accountNumber: 100_000 }];
7
+
8
+ const VALID_ILS_DATA = {
9
+ transactions: [
10
+ {
11
+ activityDescription: 'Credit',
12
+ activityTypeCode: 1,
13
+ eventAmount: 1000,
14
+ eventDate: 20_240_101,
15
+ serialNumber: 1,
16
+ transactionType: 'REGULAR',
17
+ currentBalance: 5000,
18
+ referenceNumber: 12_345,
19
+ },
20
+ ],
21
+ retrievalTransactionData: { accountNumber: 100_000, branchNumber: 600, bankNumber: 12 },
22
+ };
23
+
24
+ const VALID_FOREIGN_DATA = {
25
+ balancesAndLimitsDataList: [
26
+ { currencySwiftCode: 'USD', currencyCode: 1, transactions: [] },
27
+ ],
28
+ };
29
+
30
+ const VALID_SWIFT_DATA = { swiftsList: [] };
31
+
32
+ function makeScraper(overrides: Record<string, unknown> = {}) {
33
+ return {
34
+ getAccountsData: vi.fn().mockResolvedValue({ data: VALID_ACCOUNTS, isValid: true }),
35
+ getILSTransactions: vi.fn().mockResolvedValue({ data: VALID_ILS_DATA, isValid: true }),
36
+ getForeignTransactions: vi.fn().mockResolvedValue({ data: VALID_FOREIGN_DATA, isValid: true }),
37
+ getForeignSwiftTransactions: vi.fn().mockResolvedValue({ data: VALID_SWIFT_DATA, isValid: true }),
38
+ ...overrides,
39
+ };
40
+ }
41
+
42
+ const CREDS = { id: 'src-1', userCode: 'user', password: 'pass' };
43
+ const noop = () => {};
44
+
45
+ vi.mock('@accounter/modern-poalim-scraper', () => ({
46
+ init: vi.fn(),
47
+ }));
48
+
49
+ async function getInitMock() {
50
+ const mod = await import('@accounter/modern-poalim-scraper');
51
+ return mod.init as ReturnType<typeof vi.fn>;
52
+ }
53
+
54
+ describe('scrapePoalim — happy path', () => {
55
+ it('resolves with typed ILS, foreign, and swift payloads', async () => {
56
+ const scraper = makeScraper();
57
+ const initMock = await getInitMock();
58
+ initMock.mockResolvedValue({
59
+ hapoalim: vi.fn().mockResolvedValue(scraper),
60
+ close: vi.fn().mockResolvedValue(undefined),
61
+ });
62
+
63
+ const result = await scrapePoalim(CREDS, new Date(), new Date(), true, new OtpManager(), noop);
64
+
65
+ expect(result.ils).toHaveLength(1);
66
+ expect(result.ils[0]!.retrievalTransactionData.accountNumber).toBe(100_000);
67
+ expect(result.foreign).toHaveLength(1);
68
+ expect(result.swift).toHaveLength(1);
69
+ });
70
+
71
+ it('calls close() even on success', async () => {
72
+ const close = vi.fn().mockResolvedValue(undefined);
73
+ const initMock = await getInitMock();
74
+ initMock.mockResolvedValue({ hapoalim: vi.fn().mockResolvedValue(makeScraper()), close });
75
+
76
+ await scrapePoalim(CREDS, new Date(), new Date(), true, new OtpManager(), noop);
77
+
78
+ expect(close).toHaveBeenCalledOnce();
79
+ });
80
+ });
81
+
82
+ describe('scrapePoalim — OTP path', () => {
83
+ it('resolves with OTP when submitOtp is called after waitForOtp', async () => {
84
+ const otpManager = new OtpManager();
85
+ const emit = vi.fn();
86
+
87
+ const scraper = makeScraper();
88
+ const initMock = await getInitMock();
89
+ initMock.mockResolvedValue({
90
+ hapoalim: vi.fn().mockImplementation(async (_creds: unknown, opts: { otpCallback?: () => Promise<string> }) => {
91
+ // Simulate scraper triggering OTP
92
+ if (opts?.otpCallback) {
93
+ // Resolve the OTP externally while waitForOtp is pending
94
+ setTimeout(() => otpManager.submitOtp('src-1', '123456'), 10);
95
+ const otp = await opts.otpCallback();
96
+ expect(otp).toBe('123456');
97
+ }
98
+ return scraper;
99
+ }),
100
+ close: vi.fn().mockResolvedValue(undefined),
101
+ });
102
+
103
+ const result = await scrapePoalim(CREDS, new Date(), new Date(), true, otpManager, emit);
104
+
105
+ expect(emit).toHaveBeenCalledWith(expect.objectContaining({ type: 'otp-required', sourceId: 'src-1' }));
106
+ expect(result.ils).toHaveLength(1);
107
+ });
108
+ });
109
+
110
+ describe('scrapePoalim — Unknown Error', () => {
111
+ it('throws when hapoalim returns "Unknown Error"', async () => {
112
+ const initMock = await getInitMock();
113
+ initMock.mockResolvedValue({
114
+ hapoalim: vi.fn().mockResolvedValue('Unknown Error'),
115
+ close: vi.fn().mockResolvedValue(undefined),
116
+ });
117
+
118
+ await expect(
119
+ scrapePoalim(CREDS, new Date(), new Date(), true, new OtpManager(), noop),
120
+ ).rejects.toThrow('Hapoalim login failed');
121
+ });
122
+
123
+ it('calls close() even when Unknown Error is thrown', async () => {
124
+ const close = vi.fn().mockResolvedValue(undefined);
125
+ const initMock = await getInitMock();
126
+ initMock.mockResolvedValue({
127
+ hapoalim: vi.fn().mockResolvedValue('Unknown Error'),
128
+ close,
129
+ });
130
+
131
+ await expect(scrapePoalim(CREDS, new Date(), new Date(), true, new OtpManager(), noop)).rejects.toThrow();
132
+ expect(close).toHaveBeenCalledOnce();
133
+ });
134
+ });
135
+
136
+ describe('scrapePoalim — invalid payload', () => {
137
+ it('throws PayloadValidationError when ILS data fails schema validation', async () => {
138
+ const scraper = makeScraper({
139
+ getILSTransactions: vi.fn().mockResolvedValue({
140
+ data: { transactions: 'not-an-array', retrievalTransactionData: {} },
141
+ isValid: false,
142
+ }),
143
+ });
144
+ const initMock = await getInitMock();
145
+ initMock.mockResolvedValue({
146
+ hapoalim: vi.fn().mockResolvedValue(scraper),
147
+ close: vi.fn().mockResolvedValue(undefined),
148
+ });
149
+
150
+ await expect(
151
+ scrapePoalim(CREDS, new Date(), new Date(), true, new OtpManager(), noop),
152
+ ).rejects.toBeInstanceOf(PayloadValidationError);
153
+ });
154
+ });