@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.
- package/README.md +90 -0
- package/docs/plan.md +76 -0
- package/index.html +12 -0
- package/package.json +40 -0
- package/src/env.template +2 -0
- package/src/server/__tests__/accounts-routes.test.ts +133 -0
- package/src/server/__tests__/check-accounts.test.ts +305 -0
- package/src/server/__tests__/filter-payload.test.ts +193 -0
- package/src/server/__tests__/graphql-client.integration.test.ts +98 -0
- package/src/server/__tests__/graphql-client.test.ts +508 -0
- package/src/server/__tests__/healthz.test.ts +22 -0
- package/src/server/__tests__/history.test.ts +111 -0
- package/src/server/__tests__/otp-manager.test.ts +132 -0
- package/src/server/__tests__/scrape-runner.test.ts +144 -0
- package/src/server/__tests__/settings-routes.test.ts +117 -0
- package/src/server/__tests__/sources-routes.test.ts +149 -0
- package/src/server/__tests__/validate-payload.test.ts +193 -0
- package/src/server/__tests__/vault-routes.test.ts +174 -0
- package/src/server/__tests__/vault.test.ts +33 -0
- package/src/server/__tests__/websocket.test.ts +151 -0
- package/src/server/account-discovery.ts +49 -0
- package/src/server/accounts-routes.ts +74 -0
- package/src/server/check-accounts.ts +79 -0
- package/src/server/filter-payload.ts +145 -0
- package/src/server/graphql/client.ts +103 -0
- package/src/server/graphql/mutations.ts +518 -0
- package/src/server/history-routes.ts +11 -0
- package/src/server/history.ts +53 -0
- package/src/server/index.ts +40 -0
- package/src/server/otp-manager.ts +63 -0
- package/src/server/payload-schemas/amex.schema.ts +2 -0
- package/src/server/payload-schemas/cal.schema.ts +27 -0
- package/src/server/payload-schemas/currency-rates.schema.ts +11 -0
- package/src/server/payload-schemas/discount.schema.ts +26 -0
- package/src/server/payload-schemas/isracard.schema.ts +58 -0
- package/src/server/payload-schemas/max.schema.ts +27 -0
- package/src/server/payload-schemas/poalim-foreign.schema.ts +30 -0
- package/src/server/payload-schemas/poalim-ils.schema.ts +31 -0
- package/src/server/payload-schemas/poalim-swift.schema.ts +21 -0
- package/src/server/scrape-runner.ts +165 -0
- package/src/server/scrapers/__tests__/amex.test.ts +142 -0
- package/src/server/scrapers/__tests__/cal.test.ts +135 -0
- package/src/server/scrapers/__tests__/currency-rates.test.ts +105 -0
- package/src/server/scrapers/__tests__/discount.test.ts +160 -0
- package/src/server/scrapers/__tests__/isracard.test.ts +142 -0
- package/src/server/scrapers/__tests__/max.test.ts +115 -0
- package/src/server/scrapers/__tests__/poalim.test.ts +154 -0
- package/src/server/scrapers/amex.ts +63 -0
- package/src/server/scrapers/cal.ts +56 -0
- package/src/server/scrapers/currency-rates.ts +64 -0
- package/src/server/scrapers/discount.ts +62 -0
- package/src/server/scrapers/isracard.ts +68 -0
- package/src/server/scrapers/max.ts +32 -0
- package/src/server/scrapers/poalim.ts +103 -0
- package/src/server/settings-routes.ts +27 -0
- package/src/server/sources-routes.ts +182 -0
- package/src/server/validate-payload.ts +74 -0
- package/src/server/vault-routes.ts +99 -0
- package/src/server/vault-store.ts +42 -0
- package/src/server/vault.ts +216 -0
- package/src/server/websocket.ts +454 -0
- package/src/shared/source-types.ts +10 -0
- package/src/shared/types.ts +20 -0
- package/src/shared/ws-protocol.ts +177 -0
- package/src/test-setup.ts +6 -0
- package/src/ui/__tests__/accounts-tab.test.tsx +134 -0
- package/src/ui/__tests__/config.test.tsx +99 -0
- package/src/ui/__tests__/history.test.tsx +94 -0
- package/src/ui/__tests__/run.test.tsx +195 -0
- package/src/ui/__tests__/settings-tab.test.tsx +79 -0
- package/src/ui/__tests__/sources-tab.test.tsx +139 -0
- package/src/ui/__tests__/vault-setup.test.tsx +105 -0
- package/src/ui/__tests__/vault-unlock.test.tsx +78 -0
- package/src/ui/app.tsx +109 -0
- package/src/ui/components/error-boundary.tsx +54 -0
- package/src/ui/components/otp-modal.tsx +82 -0
- package/src/ui/components/skeleton.tsx +58 -0
- package/src/ui/components/task-row.tsx +241 -0
- package/src/ui/contexts/vault-context.tsx +77 -0
- package/src/ui/lib/api.ts +117 -0
- package/src/ui/lib/ws.ts +137 -0
- package/src/ui/main.tsx +9 -0
- package/src/ui/screens/config/accounts-tab.tsx +185 -0
- package/src/ui/screens/config/config.tsx +163 -0
- package/src/ui/screens/config/settings-tab.tsx +167 -0
- package/src/ui/screens/config/source-forms.tsx +518 -0
- package/src/ui/screens/config/source-types.ts +91 -0
- package/src/ui/screens/config/sources-tab.tsx +176 -0
- package/src/ui/screens/history.tsx +234 -0
- package/src/ui/screens/run.tsx +266 -0
- package/src/ui/screens/vault-setup.tsx +120 -0
- package/src/ui/screens/vault-unlock.tsx +38 -0
- package/tsconfig.json +15 -0
- package/tsup.config.ts +10 -0
- package/vite.config.ts +24 -0
- package/vitest.config.ts +7 -0
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
|
|
3
|
+
import { render, screen, waitFor } from '@testing-library/react';
|
|
4
|
+
import { userEvent } from '@testing-library/user-event';
|
|
5
|
+
import React from 'react';
|
|
6
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
7
|
+
import { AccountsTab } from '../screens/config/accounts-tab.js';
|
|
8
|
+
import { AccountRecord } from '../../server/check-accounts.js';
|
|
9
|
+
|
|
10
|
+
function mockFetch(initial: AccountRecord[]) {
|
|
11
|
+
let accounts = [...initial];
|
|
12
|
+
const fetchMock = vi.fn(async (url: string, options?: RequestInit) => {
|
|
13
|
+
if (url === '/api/vault/accounts') {
|
|
14
|
+
return { ok: true, json: async () => accounts } as Response;
|
|
15
|
+
}
|
|
16
|
+
const putMatch = (url as string).match(/\/api\/vault\/accounts\/(.+)/);
|
|
17
|
+
if (putMatch && options?.method === 'PUT') {
|
|
18
|
+
const id = putMatch[1];
|
|
19
|
+
const { status } = JSON.parse(options.body as string) as { status: AccountRecord['status'] };
|
|
20
|
+
accounts = accounts.map(a => (a.id === id ? { ...a, status } : a));
|
|
21
|
+
return { ok: true, json: async () => accounts } as Response;
|
|
22
|
+
}
|
|
23
|
+
return { ok: false, json: async () => ({}) } as Response;
|
|
24
|
+
});
|
|
25
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
26
|
+
return fetchMock;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const SAMPLE_ACCOUNTS: AccountRecord[] = [
|
|
30
|
+
{
|
|
31
|
+
id: 'acc-1',
|
|
32
|
+
sourceId: 'src-a',
|
|
33
|
+
sourceType: 'poalim',
|
|
34
|
+
accountNumber: '123456',
|
|
35
|
+
branchNumber: '700',
|
|
36
|
+
status: 'pending',
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
id: 'acc-2',
|
|
40
|
+
sourceId: 'src-a',
|
|
41
|
+
sourceType: 'poalim',
|
|
42
|
+
accountNumber: '789012',
|
|
43
|
+
status: 'accepted',
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
id: 'acc-3',
|
|
47
|
+
sourceId: 'src-b',
|
|
48
|
+
sourceType: 'max',
|
|
49
|
+
accountNumber: '333333',
|
|
50
|
+
status: 'ignored',
|
|
51
|
+
},
|
|
52
|
+
];
|
|
53
|
+
|
|
54
|
+
beforeEach(() => {
|
|
55
|
+
mockFetch([]);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
afterEach(() => {
|
|
59
|
+
vi.unstubAllGlobals();
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
describe('AccountsTab', () => {
|
|
63
|
+
it('renders empty state when no accounts', async () => {
|
|
64
|
+
render(<AccountsTab />);
|
|
65
|
+
await waitFor(() => {
|
|
66
|
+
expect(screen.getByText(/no account records discovered/i)).toBeTruthy();
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('renders status badges for each account', async () => {
|
|
71
|
+
vi.unstubAllGlobals();
|
|
72
|
+
mockFetch(SAMPLE_ACCOUNTS);
|
|
73
|
+
render(<AccountsTab />);
|
|
74
|
+
|
|
75
|
+
await waitFor(() => screen.getAllByText('pending'));
|
|
76
|
+
expect(screen.getAllByText('accepted').length).toBeGreaterThan(0);
|
|
77
|
+
expect(screen.getAllByText('ignored').length).toBeGreaterThan(0);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('shows pending notice for pending accounts', async () => {
|
|
81
|
+
vi.unstubAllGlobals();
|
|
82
|
+
mockFetch(SAMPLE_ACCOUNTS);
|
|
83
|
+
render(<AccountsTab />);
|
|
84
|
+
|
|
85
|
+
await waitFor(() => {
|
|
86
|
+
expect(screen.getByText(/visit the accounter client/i)).toBeTruthy();
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('changes status via dropdown', async () => {
|
|
91
|
+
vi.unstubAllGlobals();
|
|
92
|
+
const fetchMock = mockFetch(SAMPLE_ACCOUNTS);
|
|
93
|
+
render(<AccountsTab />);
|
|
94
|
+
|
|
95
|
+
await waitFor(() => screen.getAllByRole('combobox'));
|
|
96
|
+
const selects = screen.getAllByRole('combobox') as HTMLSelectElement[];
|
|
97
|
+
const pendingSelect = selects.find(s => s.value === 'pending')!;
|
|
98
|
+
|
|
99
|
+
await userEvent.selectOptions(pendingSelect, 'accepted');
|
|
100
|
+
|
|
101
|
+
await waitFor(() => {
|
|
102
|
+
const putCalls = fetchMock.mock.calls.filter(
|
|
103
|
+
([, opts]) => (opts as RequestInit | undefined)?.method === 'PUT',
|
|
104
|
+
);
|
|
105
|
+
expect(putCalls.length).toBeGreaterThan(0);
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('groups accounts by source', async () => {
|
|
110
|
+
vi.unstubAllGlobals();
|
|
111
|
+
mockFetch(SAMPLE_ACCOUNTS);
|
|
112
|
+
render(<AccountsTab />);
|
|
113
|
+
|
|
114
|
+
await waitFor(() => {
|
|
115
|
+
expect(screen.getByText(/Bank Hapoalim \(src-a\)/)).toBeTruthy();
|
|
116
|
+
expect(screen.getByText(/Max \(src-b\)/)).toBeTruthy();
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('after status change the badge updates', async () => {
|
|
121
|
+
vi.unstubAllGlobals();
|
|
122
|
+
mockFetch([SAMPLE_ACCOUNTS[0]]);
|
|
123
|
+
render(<AccountsTab />);
|
|
124
|
+
|
|
125
|
+
await waitFor(() => screen.getAllByText('pending'));
|
|
126
|
+
const select = screen.getByRole('combobox') as HTMLSelectElement;
|
|
127
|
+
|
|
128
|
+
await userEvent.selectOptions(select, 'accepted');
|
|
129
|
+
|
|
130
|
+
await waitFor(() => {
|
|
131
|
+
expect(screen.getAllByText('accepted').length).toBeGreaterThan(0);
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
});
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
|
|
3
|
+
import { render, screen, waitFor } from '@testing-library/react';
|
|
4
|
+
import { userEvent } from '@testing-library/user-event';
|
|
5
|
+
import React from 'react';
|
|
6
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
7
|
+
import { Config } from '../screens/config/config.js';
|
|
8
|
+
|
|
9
|
+
function mockFetchEmpty() {
|
|
10
|
+
vi.stubGlobal(
|
|
11
|
+
'fetch',
|
|
12
|
+
vi.fn(async (url: string) => {
|
|
13
|
+
if (url === '/api/vault/sources') return { ok: true, json: async () => [] } as Response;
|
|
14
|
+
if (url === '/api/vault/accounts') return { ok: true, json: async () => [] } as Response;
|
|
15
|
+
if (url === '/api/vault/settings')
|
|
16
|
+
return {
|
|
17
|
+
ok: true,
|
|
18
|
+
json: async () => ({
|
|
19
|
+
showBrowser: false,
|
|
20
|
+
fetchBankOfIsraelRates: true,
|
|
21
|
+
concurrentScraping: true,
|
|
22
|
+
}),
|
|
23
|
+
} as Response;
|
|
24
|
+
return { ok: false, json: async () => ({}) } as Response;
|
|
25
|
+
}),
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
beforeEach(() => {
|
|
30
|
+
mockFetchEmpty();
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
describe('Config tab navigation', () => {
|
|
34
|
+
it('renders Credentials tab by default', async () => {
|
|
35
|
+
render(<Config />);
|
|
36
|
+
await waitFor(() => {
|
|
37
|
+
expect(screen.getByRole('tab', { name: 'Credentials' })).toBeTruthy();
|
|
38
|
+
});
|
|
39
|
+
expect(
|
|
40
|
+
screen.getByRole('tab', { name: 'Credentials' }).getAttribute('aria-selected'),
|
|
41
|
+
).toBe('true');
|
|
42
|
+
expect(screen.getByText(/no sources configured/i)).toBeTruthy();
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('shows server connection fields in Credentials tab', async () => {
|
|
46
|
+
render(<Config />);
|
|
47
|
+
await waitFor(() => screen.getByLabelText(/server url/i));
|
|
48
|
+
expect(screen.getByLabelText(/api key/i)).toBeTruthy();
|
|
49
|
+
expect(screen.getByRole('button', { name: /test connection/i })).toBeTruthy();
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('switches to Settings tab on click', async () => {
|
|
53
|
+
render(<Config />);
|
|
54
|
+
await waitFor(() => screen.getByRole('tab', { name: 'Settings' }));
|
|
55
|
+
|
|
56
|
+
await userEvent.click(screen.getByRole('tab', { name: 'Settings' }));
|
|
57
|
+
|
|
58
|
+
await waitFor(() => {
|
|
59
|
+
expect(screen.getByLabelText(/show browser/i)).toBeTruthy();
|
|
60
|
+
});
|
|
61
|
+
expect(screen.getByRole('tab', { name: 'Settings' }).getAttribute('aria-selected')).toBe('true');
|
|
62
|
+
expect(
|
|
63
|
+
screen.getByRole('tab', { name: 'Credentials' }).getAttribute('aria-selected'),
|
|
64
|
+
).toBe('false');
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('switches to Accounts tab on click', async () => {
|
|
68
|
+
render(<Config />);
|
|
69
|
+
await waitFor(() => screen.getByRole('tab', { name: 'Accounts' }));
|
|
70
|
+
|
|
71
|
+
await userEvent.click(screen.getByRole('tab', { name: 'Accounts' }));
|
|
72
|
+
|
|
73
|
+
await waitFor(() => {
|
|
74
|
+
expect(screen.getByText(/no account records discovered/i)).toBeTruthy();
|
|
75
|
+
});
|
|
76
|
+
expect(screen.getByRole('tab', { name: 'Accounts' }).getAttribute('aria-selected')).toBe('true');
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('only renders the active panel', async () => {
|
|
80
|
+
render(<Config />);
|
|
81
|
+
await waitFor(() => screen.getByText(/no sources/i));
|
|
82
|
+
|
|
83
|
+
expect(screen.queryByLabelText(/show browser/i)).toBeNull();
|
|
84
|
+
expect(screen.queryByText(/no account records/i)).toBeNull();
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('can navigate back to Credentials after switching away', async () => {
|
|
88
|
+
render(<Config />);
|
|
89
|
+
await waitFor(() => screen.getByText(/no sources/i));
|
|
90
|
+
|
|
91
|
+
await userEvent.click(screen.getByRole('tab', { name: 'Settings' }));
|
|
92
|
+
await waitFor(() => screen.getByLabelText(/show browser/i));
|
|
93
|
+
|
|
94
|
+
await userEvent.click(screen.getByRole('tab', { name: 'Credentials' }));
|
|
95
|
+
await waitFor(() => {
|
|
96
|
+
expect(screen.getByText(/no sources configured/i)).toBeTruthy();
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
});
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
|
|
3
|
+
import { render, screen, waitFor } from '@testing-library/react';
|
|
4
|
+
import { userEvent } from '@testing-library/user-event';
|
|
5
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
6
|
+
import { History, formatDuration } from '../screens/history.js';
|
|
7
|
+
import type { RunRecord } from '../../shared/types.js';
|
|
8
|
+
|
|
9
|
+
function mockFetch(records: RunRecord[]) {
|
|
10
|
+
vi.stubGlobal(
|
|
11
|
+
'fetch',
|
|
12
|
+
vi.fn(async () => ({ ok: true, json: async () => records }) as Response),
|
|
13
|
+
);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const BASE_TIME = '2024-06-01T10:00:00.000Z';
|
|
17
|
+
const LATER_TIME = '2024-06-01T10:02:30.000Z'; // +2m 30s
|
|
18
|
+
|
|
19
|
+
function makeRecord(overrides: Partial<RunRecord> = {}): RunRecord {
|
|
20
|
+
return {
|
|
21
|
+
id: 'run-1',
|
|
22
|
+
startedAt: BASE_TIME,
|
|
23
|
+
completedAt: LATER_TIME,
|
|
24
|
+
totalInserted: 10,
|
|
25
|
+
totalSkipped: 2,
|
|
26
|
+
errorCount: 0,
|
|
27
|
+
sources: [],
|
|
28
|
+
...overrides,
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
afterEach(() => {
|
|
33
|
+
vi.unstubAllGlobals();
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
describe('History screen', () => {
|
|
37
|
+
it('renders empty state when response is []', async () => {
|
|
38
|
+
mockFetch([]);
|
|
39
|
+
render(<History />);
|
|
40
|
+
await waitFor(() => {
|
|
41
|
+
expect(screen.getByText(/no scrape runs recorded yet/i)).toBeTruthy();
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('renders correct number of rows from mock API response', async () => {
|
|
46
|
+
mockFetch([
|
|
47
|
+
makeRecord({ id: 'run-1' }),
|
|
48
|
+
makeRecord({ id: 'run-2', totalInserted: 5 }),
|
|
49
|
+
makeRecord({ id: 'run-3', totalInserted: 1 }),
|
|
50
|
+
]);
|
|
51
|
+
render(<History />);
|
|
52
|
+
await waitFor(() => {
|
|
53
|
+
expect(
|
|
54
|
+
screen.getAllByRole('button').filter(el => el.hasAttribute('aria-expanded')).length,
|
|
55
|
+
).toBe(3);
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('expanding a row shows per-source detail', async () => {
|
|
60
|
+
const record = makeRecord({
|
|
61
|
+
sources: [{ sourceId: 'src-poalim', nickname: 'Poalim', sourceType: 'poalim', status: 'done', inserted: 7, skipped: 1 }],
|
|
62
|
+
});
|
|
63
|
+
mockFetch([record]);
|
|
64
|
+
render(<History />);
|
|
65
|
+
|
|
66
|
+
await waitFor(() => screen.getByRole('table', { name: /scrape history/i }));
|
|
67
|
+
const dataRow = screen.getAllByRole('button').find(el => el.hasAttribute('aria-expanded'));
|
|
68
|
+
expect(dataRow).toBeTruthy();
|
|
69
|
+
await userEvent.setup().click(dataRow!);
|
|
70
|
+
|
|
71
|
+
await waitFor(() => {
|
|
72
|
+
expect(screen.getByText('Poalim')).toBeTruthy();
|
|
73
|
+
expect(screen.getByText('poalim')).toBeTruthy();
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('duration is calculated and displayed as Xm Ys', () => {
|
|
78
|
+
expect(formatDuration(BASE_TIME, LATER_TIME)).toBe('2m 30s');
|
|
79
|
+
expect(formatDuration(BASE_TIME, BASE_TIME)).toBe('0m 0s');
|
|
80
|
+
expect(formatDuration('2024-01-01T00:00:00.000Z', '2024-01-01T00:01:00.000Z')).toBe('1m 0s');
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('Refresh button re-fetches data', async () => {
|
|
84
|
+
const fetchMock = vi.fn(async () => ({ ok: true, json: async () => [] }) as Response);
|
|
85
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
86
|
+
|
|
87
|
+
render(<History />);
|
|
88
|
+
await waitFor(() => screen.getByText(/no scrape runs recorded yet/i));
|
|
89
|
+
|
|
90
|
+
const callsBefore = fetchMock.mock.calls.length;
|
|
91
|
+
await userEvent.setup().click(screen.getByRole('button', { name: /refresh/i }));
|
|
92
|
+
await waitFor(() => expect(fetchMock.mock.calls.length).toBeGreaterThan(callsBefore));
|
|
93
|
+
});
|
|
94
|
+
});
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
|
|
3
|
+
import { render, screen, waitFor, act } from '@testing-library/react';
|
|
4
|
+
import { userEvent } from '@testing-library/user-event';
|
|
5
|
+
import React from 'react';
|
|
6
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
7
|
+
import { Run } from '../screens/run.js';
|
|
8
|
+
import { useRunSocket } from '../lib/ws.js';
|
|
9
|
+
|
|
10
|
+
function RunWithSocket(props: { onNavigateAccounts?: () => void }) {
|
|
11
|
+
const socket = useRunSocket();
|
|
12
|
+
return <Run {...socket} {...props} />;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// ── WebSocket mock ────────────────────────────────────────────────────────────
|
|
16
|
+
|
|
17
|
+
type MessageHandler = (event: { data: string }) => void;
|
|
18
|
+
|
|
19
|
+
let wsInstance: MockWs | null = null;
|
|
20
|
+
|
|
21
|
+
class MockWs {
|
|
22
|
+
readyState = WebSocket.OPEN;
|
|
23
|
+
onmessage: MessageHandler | null = null;
|
|
24
|
+
sent: unknown[] = [];
|
|
25
|
+
|
|
26
|
+
send(data: string) {
|
|
27
|
+
this.sent.push(JSON.parse(data));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
close() {}
|
|
31
|
+
|
|
32
|
+
/** Push a server message into the component */
|
|
33
|
+
push(msg: object) {
|
|
34
|
+
this.onmessage?.({ data: JSON.stringify(msg) });
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
beforeEach(() => {
|
|
39
|
+
wsInstance = new MockWs();
|
|
40
|
+
const MockWebSocketCtor = function MockWebSocketCtor() {
|
|
41
|
+
return wsInstance;
|
|
42
|
+
};
|
|
43
|
+
MockWebSocketCtor.OPEN = 1;
|
|
44
|
+
MockWebSocketCtor.CONNECTING = 0;
|
|
45
|
+
MockWebSocketCtor.CLOSING = 2;
|
|
46
|
+
MockWebSocketCtor.CLOSED = 3;
|
|
47
|
+
vi.stubGlobal('WebSocket', MockWebSocketCtor);
|
|
48
|
+
// Stub fetch → empty sources list
|
|
49
|
+
vi.stubGlobal(
|
|
50
|
+
'fetch',
|
|
51
|
+
vi.fn(async () => ({ ok: true, json: async () => [] }) as Response),
|
|
52
|
+
);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
afterEach(() => {
|
|
56
|
+
wsInstance = null;
|
|
57
|
+
vi.unstubAllGlobals();
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
function ws(): MockWs {
|
|
61
|
+
if (!wsInstance) throw new Error('no ws');
|
|
62
|
+
return wsInstance;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// ── Helpers ───────────────────────────────────────────────────────────────────
|
|
66
|
+
|
|
67
|
+
function push(msg: object) {
|
|
68
|
+
act(() => ws().push(msg));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// ── Tests ─────────────────────────────────────────────────────────────────────
|
|
72
|
+
|
|
73
|
+
describe('Run screen', () => {
|
|
74
|
+
it('"Run" button fires run-start message', async () => {
|
|
75
|
+
// provide one source so the button is not disabled due to no selection
|
|
76
|
+
vi.stubGlobal(
|
|
77
|
+
'fetch',
|
|
78
|
+
vi.fn(async () => ({
|
|
79
|
+
ok: true,
|
|
80
|
+
json: async () => [{ id: 'src-1', type: 'poalim', userCode: 'u', password: 'p' }],
|
|
81
|
+
}) as Response),
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
render(<RunWithSocket />);
|
|
85
|
+
await waitFor(() => expect(screen.queryByRole('button', { name: /^run$/i })).toBeTruthy());
|
|
86
|
+
|
|
87
|
+
await userEvent.click(screen.getByRole('button', { name: /^run$/i }));
|
|
88
|
+
|
|
89
|
+
expect(ws().sent).toContainEqual(
|
|
90
|
+
expect.objectContaining({ type: 'run-start', sourceIds: ['src-1'] }),
|
|
91
|
+
);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('task-pending → task-running → TaskRow shows correct states', async () => {
|
|
95
|
+
render(<RunWithSocket />);
|
|
96
|
+
await waitFor(() => screen.getByRole('button', { name: /^run$/i }));
|
|
97
|
+
|
|
98
|
+
push({ type: 'task-pending', sourceId: 'src-1' });
|
|
99
|
+
await waitFor(() => expect(screen.getAllByText(/pending/i).length).toBeGreaterThan(0));
|
|
100
|
+
|
|
101
|
+
push({ type: 'task-running', sourceId: 'src-1' });
|
|
102
|
+
// The badge text contains "Running…" (with ellipsis), distinct from the button "Running…"
|
|
103
|
+
await waitFor(() => expect(screen.getAllByText(/running/i).length).toBeGreaterThan(0));
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('task-done → TaskRow shows green with counts', async () => {
|
|
107
|
+
render(<RunWithSocket />);
|
|
108
|
+
await waitFor(() => screen.getByRole('button', { name: /^run$/i }));
|
|
109
|
+
|
|
110
|
+
push({ type: 'task-pending', sourceId: 'src-1' });
|
|
111
|
+
push({ type: 'task-done', sourceId: 'src-1', inserted: 5, skipped: 2, insertedIds: [] });
|
|
112
|
+
|
|
113
|
+
await waitFor(() => {
|
|
114
|
+
expect(screen.getByText(/done/i)).toBeTruthy();
|
|
115
|
+
expect(screen.getByText(/5 new/i)).toBeTruthy();
|
|
116
|
+
expect(screen.getByText(/2 skipped/i)).toBeTruthy();
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('task-error → TaskRow shows red with message', async () => {
|
|
121
|
+
render(<RunWithSocket />);
|
|
122
|
+
await waitFor(() => screen.getByRole('button', { name: /^run$/i }));
|
|
123
|
+
|
|
124
|
+
push({ type: 'task-pending', sourceId: 'src-1' });
|
|
125
|
+
push({ type: 'task-error', sourceId: 'src-1', message: 'Login failed' });
|
|
126
|
+
|
|
127
|
+
await waitFor(() => {
|
|
128
|
+
expect(screen.getByText(/error/i)).toBeTruthy();
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
// Expand details to see message
|
|
132
|
+
await userEvent.click(screen.getByRole('button', { name: /details/i }));
|
|
133
|
+
await waitFor(() => expect(screen.getByText(/Login failed/)).toBeTruthy());
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('task-blocked → TaskRow shows yellow with account IDs', async () => {
|
|
137
|
+
render(<RunWithSocket />);
|
|
138
|
+
await waitFor(() => screen.getByRole('button', { name: /^run$/i }));
|
|
139
|
+
|
|
140
|
+
push({
|
|
141
|
+
type: 'task-blocked',
|
|
142
|
+
sourceId: 'src-1',
|
|
143
|
+
sourceType: 'poalim',
|
|
144
|
+
unknownAccounts: ['ACC-001', 'ACC-002'],
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
await waitFor(() => {
|
|
148
|
+
expect(screen.getByText(/blocked/i)).toBeTruthy();
|
|
149
|
+
expect(screen.getByText(/ACC-001/)).toBeTruthy();
|
|
150
|
+
expect(screen.getByText(/ACC-002/)).toBeTruthy();
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it('otp-required → OtpModal appears; submit closes modal and sends otp-submit', async () => {
|
|
155
|
+
render(<RunWithSocket />);
|
|
156
|
+
await waitFor(() => screen.getByRole('button', { name: /^run$/i }));
|
|
157
|
+
|
|
158
|
+
push({ type: 'task-pending', sourceId: 'src-1' });
|
|
159
|
+
push({ type: 'otp-required', sourceId: 'src-1' });
|
|
160
|
+
|
|
161
|
+
await waitFor(() => expect(screen.getByRole('dialog')).toBeTruthy());
|
|
162
|
+
|
|
163
|
+
await userEvent.type(screen.getByLabelText(/otp code/i), '123456');
|
|
164
|
+
await userEvent.click(screen.getByRole('button', { name: /submit otp/i }));
|
|
165
|
+
|
|
166
|
+
expect(ws().sent).toContainEqual({ type: 'otp-submit', sourceId: 'src-1', otp: '123456' });
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it('run-complete → summary panel renders; Run button re-enabled', async () => {
|
|
170
|
+
vi.stubGlobal(
|
|
171
|
+
'fetch',
|
|
172
|
+
vi.fn(async () => ({
|
|
173
|
+
ok: true,
|
|
174
|
+
json: async () => [{ id: 'src-1', type: 'poalim', userCode: 'u', password: 'p' }],
|
|
175
|
+
}) as Response),
|
|
176
|
+
);
|
|
177
|
+
|
|
178
|
+
render(<RunWithSocket />);
|
|
179
|
+
await waitFor(() => screen.getByRole('button', { name: /^run$/i }));
|
|
180
|
+
|
|
181
|
+
// Simulate a running state then completion
|
|
182
|
+
push({ type: 'task-pending', sourceId: 'src-1' });
|
|
183
|
+
push({ type: 'task-running', sourceId: 'src-1' });
|
|
184
|
+
push({ type: 'task-done', sourceId: 'src-1', inserted: 3, skipped: 1, insertedIds: [] });
|
|
185
|
+
push({ type: 'run-complete', totalInserted: 3, totalSkipped: 1, errors: 0 });
|
|
186
|
+
|
|
187
|
+
await waitFor(() => {
|
|
188
|
+
expect(screen.getByRole('region', { name: /run summary/i })).toBeTruthy();
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
// Run button should be enabled again
|
|
192
|
+
const runBtn = screen.getByRole('button', { name: /^run$/i });
|
|
193
|
+
expect((runBtn as HTMLButtonElement).disabled).toBe(false);
|
|
194
|
+
});
|
|
195
|
+
});
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
|
|
3
|
+
import { render, screen, waitFor } from '@testing-library/react';
|
|
4
|
+
import { userEvent } from '@testing-library/user-event';
|
|
5
|
+
import React from 'react';
|
|
6
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
7
|
+
import { SettingsTab } from '../screens/config/settings-tab.js';
|
|
8
|
+
|
|
9
|
+
const DEFAULT_SETTINGS = {
|
|
10
|
+
showBrowser: false,
|
|
11
|
+
fetchBankOfIsraelRates: true,
|
|
12
|
+
concurrentScraping: true,
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
function mockFetch(settings = DEFAULT_SETTINGS) {
|
|
16
|
+
let current = { ...settings };
|
|
17
|
+
const fetchMock = vi.fn(async (url: string, options?: RequestInit) => {
|
|
18
|
+
if (url === '/api/vault/settings') {
|
|
19
|
+
if (!options || options.method !== 'PUT') {
|
|
20
|
+
return { ok: true, json: async () => ({ ...current }) } as Response;
|
|
21
|
+
}
|
|
22
|
+
const patch = JSON.parse(options.body as string) as typeof current;
|
|
23
|
+
current = { ...current, ...patch };
|
|
24
|
+
return { ok: true, json: async () => ({ ...current }) } as Response;
|
|
25
|
+
}
|
|
26
|
+
return { ok: false, json: async () => ({}) } as Response;
|
|
27
|
+
});
|
|
28
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
29
|
+
return fetchMock;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
beforeEach(() => {
|
|
33
|
+
mockFetch();
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
afterEach(() => {
|
|
37
|
+
vi.unstubAllGlobals();
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
describe('SettingsTab', () => {
|
|
41
|
+
it('renders boolean toggles', async () => {
|
|
42
|
+
render(<SettingsTab />);
|
|
43
|
+
await waitFor(() => screen.getByLabelText(/show browser/i));
|
|
44
|
+
expect(screen.getByLabelText(/show browser/i)).toBeTruthy();
|
|
45
|
+
expect(screen.getByLabelText(/fetch bank of israel/i)).toBeTruthy();
|
|
46
|
+
expect(screen.getByLabelText(/scrape sources concurrently/i)).toBeTruthy();
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('renders number and text fields', async () => {
|
|
50
|
+
render(<SettingsTab />);
|
|
51
|
+
await waitFor(() => screen.getByLabelText(/default date range/i));
|
|
52
|
+
expect(screen.getByLabelText(/history file path/i)).toBeTruthy();
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('auto-saves on toggle change', async () => {
|
|
56
|
+
const fetchMock = mockFetch();
|
|
57
|
+
render(<SettingsTab />);
|
|
58
|
+
await waitFor(() => screen.getByLabelText(/show browser/i));
|
|
59
|
+
|
|
60
|
+
await userEvent.click(screen.getByLabelText(/show browser/i));
|
|
61
|
+
|
|
62
|
+
await waitFor(() => {
|
|
63
|
+
const putCalls = fetchMock.mock.calls.filter(
|
|
64
|
+
([, opts]) => (opts as RequestInit | undefined)?.method === 'PUT',
|
|
65
|
+
);
|
|
66
|
+
expect(putCalls.length).toBeGreaterThan(0);
|
|
67
|
+
const body = JSON.parse(putCalls[0][1]!.body as string) as { showBrowser: boolean };
|
|
68
|
+
expect(body.showBrowser).toBe(true);
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('reflects initial values from the server', async () => {
|
|
73
|
+
mockFetch({ ...DEFAULT_SETTINGS, showBrowser: true });
|
|
74
|
+
render(<SettingsTab />);
|
|
75
|
+
await waitFor(() => {
|
|
76
|
+
expect((screen.getByLabelText(/show browser/i) as HTMLInputElement).checked).toBe(true);
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
});
|