UNPKG

@accounter/scraper-app

Version:
307 lines (271 loc) 11 kB
import { afterEach, describe, expect, it } from 'vitest'; import { checkAccounts } from '../check-accounts.js'; import type { AccountRecord } from '../check-accounts.js'; import { _resetRunState, startRun, type ScrapeTask } from '../scrape-runner.js'; import type { ServerMessage } from '../../shared/ws-protocol.js'; const poalimPayload = { accountNumber: 100000, branchNumber: 600, bankNumber: 12, }; const isracardPayload = { Header: { Status: '1', Message: null }, CardsTransactionsListBean: { cardNumberList: ['CARD 9876', 'CARD 4545'], Index0: { '@AllCards': 'AllCards', CurrentCardTransactions: [ { txnIsrael: null, txnAbroad: null }, { txnIsrael: null, txnAbroad: null }, ], }, }, }; const calPayload = [ { card: 'ACC-1', month: '2024-01', transactions: [] }, { card: 'ACC-2', month: '2024-01', transactions: [] }, ]; const maxPayload = [ { accountNumber: '7', txns: [] }, { accountNumber: '8', txns: [] }, ]; const discountPayload = [ { accountNumber: 'ACC-001', month: '2024-01', balance: 5000, transactions: [] }, { accountNumber: 'ACC-002', month: '2024-01', balance: 3000, transactions: [] }, ]; function makeRecord( sourceType: AccountRecord['sourceType'], accountNumber: string, status: AccountRecord['status'] = 'accepted', ): AccountRecord { return { id: `${sourceType}-${accountNumber}`, sourceId: 'src-1', sourceType, accountNumber, status, }; } describe('checkAccounts — poalim', () => { it('accepted when account is in known list with accepted status', () => { const known = [makeRecord('poalim', '600-100000', 'accepted')]; const result = checkAccounts('poalim', poalimPayload, known); expect(result.accepted).toEqual(['600-100000']); expect(result.ignored).toEqual([]); expect(result.unknown).toEqual([]); }); it('unknown when account has pending status', () => { const known = [makeRecord('poalim', '600-100000', 'pending')]; const result = checkAccounts('poalim', poalimPayload, known); expect(result.accepted).toEqual([]); expect(result.unknown).toEqual(['600-100000']); }); it('unknown when account is not in known list', () => { const result = checkAccounts('poalim', poalimPayload, []); expect(result.unknown).toEqual(['600-100000']); expect(result.accepted).toEqual([]); }); it('ignored when account status is ignored', () => { const known = [makeRecord('poalim', '600-100000', 'ignored')]; const result = checkAccounts('poalim', poalimPayload, known); expect(result.ignored).toEqual(['600-100000']); expect(result.accepted).toEqual([]); expect(result.unknown).toEqual([]); }); it('does not match account from a different sourceType', () => { const known = [makeRecord('discount', '600-100000', 'accepted')]; const result = checkAccounts('poalim', poalimPayload, known); expect(result.unknown).toEqual(['600-100000']); }); }); describe('checkAccounts — discount', () => { it('classifies accountNumber identifiers from payload', () => { const known = [ makeRecord('discount', 'ACC-001', 'accepted'), makeRecord('discount', 'ACC-002', 'ignored'), ]; const result = checkAccounts('discount', discountPayload, known); expect(result.accepted).toEqual(['ACC-001']); expect(result.ignored).toEqual(['ACC-002']); expect(result.unknown).toEqual([]); }); it('returns unknown for unrecognised accountNumber', () => { const result = checkAccounts('discount', discountPayload, []); expect(result.unknown).toEqual(['ACC-001', 'ACC-002']); expect(result.accepted).toEqual([]); }); it('deduplicates accountNumber across multiple months for the same account', () => { const sameAccountTwoMonths = [ { accountNumber: 'ACC-001', month: '2024-01', balance: 5000, transactions: [] }, { accountNumber: 'ACC-001', month: '2024-02', balance: 4800, transactions: [] }, ]; const result = checkAccounts('discount', sameAccountTwoMonths, []); expect(result.unknown).toEqual(['ACC-001']); }); }); describe('checkAccounts — isracard / amex', () => { it('classifies each card from CurrentCardTransactions', () => { const known = [ makeRecord('isracard', '4545', 'accepted'), makeRecord('isracard', '9876', 'ignored'), ]; const result = checkAccounts('isracard', isracardPayload, known); expect(result.accepted).toEqual(['4545']); expect(result.ignored).toEqual(['9876']); expect(result.unknown).toEqual([]); }); it('returns unknown cards not in known list', () => { const known = [makeRecord('isracard', '9876', 'accepted')]; const result = checkAccounts('isracard', isracardPayload, known); expect(result.unknown).toEqual(['4545']); }); it('works the same for amex payload type', () => { const known = [makeRecord('amex', '4545', 'ignored')]; const result = checkAccounts('amex', isracardPayload, known); expect(result.ignored).toEqual(['4545']); expect(result.unknown).toEqual(['9876']); }); it('returns empty when there are no cards in payload', () => { const emptyPayload = { Header: { Status: '1', Message: null }, CardsTransactionsListBean: { cardNumberList: [], Index0: { '@AllCards': 'AllCards', CurrentCardTransactions: [] }, }, }; const result = checkAccounts('isracard', emptyPayload, []); expect(result).toEqual({ accepted: [], ignored: [], unknown: [] }); }); it('collects identifiers from cardNumberList', () => { const multiIndexPayload = { Header: { Status: '1', Message: null }, CardsTransactionsListBean: { cardNumberList: ['CARD 1111', 'CARD 2222', 'CARD 3333'], Index0: { '@AllCards': 'AllCards', CurrentCardTransactions: [{ '@cardTransactions': 'CARD-A' }], }, Index1: { '@AllCards': 'AllCards', CurrentCardTransactions: [{ '@cardTransactions': 'CARD-B' }], }, Index2: { '@AllCards': 'AllCards', CurrentCardTransactions: [{ '@cardTransactions': 'CARD-C' }], }, }, }; const result = checkAccounts('isracard', multiIndexPayload, []); expect(result.unknown.sort()).toEqual(['1111', '2222', '3333']); }); it('ignores non-Index* keys in CardsTransactionsListBean', () => { const payloadWithExtraKeys = { Header: { Status: '1', Message: null }, CardsTransactionsListBean: { cardNumberList: ['CARD 0101'], Index0: { '@AllCards': 'AllCards', CurrentCardTransactions: [], }, cardIdx: '0', card0: { some: 'data' }, }, }; const result = checkAccounts('isracard', payloadWithExtraKeys, []); expect(result.unknown).toEqual(['0101']); }); }); describe('checkAccounts — cal', () => { it('classifies card identifiers', () => { const known = [ makeRecord('cal', 'ACC-1', 'accepted'), makeRecord('cal', 'ACC-2', 'ignored'), ]; const result = checkAccounts('cal', calPayload, known); expect(result.accepted).toEqual(['ACC-1']); expect(result.ignored).toEqual(['ACC-2']); expect(result.unknown).toEqual([]); }); it('returns unknown for unrecognised card', () => { const result = checkAccounts('cal', calPayload, []); expect(result.unknown).toEqual(['ACC-1', 'ACC-2']); }); }); describe('checkAccounts — max', () => { it('classifies unique cardIndex values from transactions', () => { const known = [ makeRecord('max', '7', 'accepted'), makeRecord('max', '8', 'ignored'), ]; const result = checkAccounts('max', maxPayload, known); expect(result.accepted).toEqual(['7']); expect(result.ignored).toEqual(['8']); expect(result.unknown).toEqual([]); }); it('deduplicates accountNumber across multiple entries for the same card', () => { const twoEntriesSameCard = [ { accountNumber: '7', txns: [] }, { accountNumber: '7', txns: [] }, ]; const result = checkAccounts('max', twoEntriesSameCard, []); expect(result.unknown).toEqual(['7']); }); it('returns empty arrays when there are no accounts', () => { const result = checkAccounts('max', [], []); expect(result).toEqual({ accepted: [], ignored: [], unknown: [] }); }); }); describe('runner integration — task-blocked on unknown accounts', () => { afterEach(() => { _resetRunState(); }); it('emits task-blocked (not a crash) when checkAccounts finds unknown accounts', async () => { const events: ServerMessage[] = []; const emit = (msg: ServerMessage) => events.push(msg); const task: ScrapeTask = { sourceId: 'poalim-src', nickname: 'poalim-src', type: 'poalim', run: async () => { const check = checkAccounts('poalim', poalimPayload, []); if (check.unknown.length > 0) { emit({ type: 'task-blocked', sourceId: 'poalim-src', sourceType: 'poalim', unknownAccounts: check.unknown }); return { inserted: 0, skipped: 0, insertedIds: [], changedTransactions: [], insertedTransactions: [] }; } return { inserted: 1, skipped: 0, insertedIds: ['x'], changedTransactions: [], insertedTransactions: [] }; }, }; await startRun([task], false, emit); const blocked = events.find(e => e.type === 'task-blocked'); expect(blocked).toBeTruthy(); expect((blocked as { unknownAccounts: string[] }).unknownAccounts).toContain('600-100000'); expect(events.at(-1)).toMatchObject({ type: 'run-complete', totalInserted: 0, totalSkipped: 0 }); }); it('continues remaining tasks after a blocked task', async () => { const events: ServerMessage[] = []; const emit = (msg: ServerMessage) => events.push(msg); const blockedTask: ScrapeTask = { sourceId: 'src-1', nickname: 'src-1', type: 'poalim', run: async () => { const check = checkAccounts('poalim', poalimPayload, []); if (check.unknown.length > 0) { emit({ type: 'task-blocked', sourceId: 'src-1', sourceType: 'poalim', unknownAccounts: check.unknown }); return { inserted: 0, skipped: 0, insertedIds: [], changedTransactions: [], insertedTransactions: [] }; } return { inserted: 1, skipped: 0, insertedIds: ['x'], changedTransactions: [], insertedTransactions: [] }; }, }; const normalTask: ScrapeTask = { sourceId: 'src-2', nickname: 'src-2', type: 'poalim', run: async () => ({ inserted: 2, skipped: 1, insertedIds: ['a', 'b'], changedTransactions: [], insertedTransactions: [] }), }; await startRun([blockedTask, normalTask], false, emit); expect(events.some(e => e.type === 'task-blocked')).toBe(true); expect(events.some(e => e.type === 'task-done' && 'sourceId' in e && e.sourceId === 'src-2')).toBe(true); expect(events.at(-1)).toMatchObject({ type: 'run-complete', totalInserted: 2, totalSkipped: 1 }); }); });