@accounter/scraper-app
Version:
Scraper app with Fastify server and React UI
294 lines (267 loc) • 9.63 kB
text/typescript
import { describe, expect, it, afterEach } from 'vitest';
import { PayloadValidationError, validatePayload } from '../validate-payload.js';
import { _resetRunState, startRun, type ScrapeTask } from '../scrape-runner.js';
import type { ServerMessage } from '../../shared/ws-protocol.js';
describe('validatePayload — valid fixtures', () => {
it('accepts a minimal poalim-ils payload', () => {
const result = validatePayload('poalim-ils', {
transactions: [
{
activityDescription: 'Credit',
activityTypeCode: 1,
eventAmount: 100,
eventDate: 20240101,
serialNumber: 1,
transactionType: 'REGULAR',
currentBalance: 5000,
referenceNumber: 12345,
},
],
retrievalTransactionData: { accountNumber: 100000, branchNumber: 600, bankNumber: 12 },
});
expect(result.transactions).toHaveLength(1);
});
it('accepts a poalim-ils payload with extra fields (passthrough)', () => {
const result = validatePayload('poalim-ils', {
transactions: [],
retrievalTransactionData: { accountNumber: 1, branchNumber: 2, bankNumber: 12 },
unknownField: 'should be kept',
});
expect((result as Record<string, unknown>)['unknownField']).toBe('should be kept');
});
it('accepts a minimal poalim-foreign payload', () => {
const result = validatePayload('poalim-foreign', {
balancesAndLimitsDataList: [
{
currencySwiftCode: 'USD',
currencyCode: 1,
transactions: [],
},
],
});
expect(result.balancesAndLimitsDataList).toHaveLength(1);
});
it('accepts a minimal poalim-swift payload', () => {
const result = validatePayload('poalim-swift', { swiftsList: [] });
expect(result.swiftsList).toHaveLength(0);
});
it('accepts a minimal poalim-securities payload', () => {
const result = validatePayload('poalim-securities', {
View: { Meta: { '-AsOfDate': '2024-01-15T10:00:00.000+02:00', Security: [] } },
});
expect(result.View.Meta.Security).toHaveLength(0);
});
it('accepts a poalim-securities payload alongside unmodelled View siblings', () => {
const result = validatePayload('poalim-securities', {
View: {
Account: { OnlineValue: 1 },
Orders: { Rezef: { Order: [] } },
Meta: {
'-AsOfDate': '2024-01-15T10:00:00.000+02:00',
Security: [
{
'-Key': '1234567',
EngName: 'Example Corp',
HebName: 'אקזמפל',
ItemType: 'Equity',
IsEtf: false,
IsForeign: true,
CurrencyCode: 'USD',
Exchange: 'NYQ',
EquityType: 1,
AllowedOrderDirection: 'BuyAndSell',
EquitySubType: 1,
EngSymbol: 'EXMP',
HebSymbol: 'EXMP',
Symbol: 'EXMP',
ExpirationDate: null,
StockType: 'Equity',
CreationEquityNum: null,
ContractType: null,
},
],
},
},
});
expect(result.View.Meta.Security[0]?.['-Key']).toBe('1234567');
});
it('defaults a missing Security array to [] (account with no portfolio)', () => {
const result = validatePayload('poalim-securities', {
View: { Meta: { '-AsOfDate': '2024-01-15T10:00:00.000+02:00' } },
});
expect(result.View.Meta.Security).toEqual([]);
});
it('rejects a poalim-securities payload with no Meta', () => {
expect(() => validatePayload('poalim-securities', { View: { Account: {} } })).toThrow();
});
// Every field the vars mapper reads must be asserted; a `.loose()` gap would
// type it `unknown` and ship it to the mutation unchecked.
it.each([
'IsEtf',
'AllowedOrderDirection',
'EngSymbol',
'HebSymbol',
'Symbol',
'ExpirationDate',
'StockType',
'CreationEquityNum',
'ContractType',
])('rejects a poalim-securities security missing %s', field => {
const security: Record<string, unknown> = {
'-Key': '1234567',
EngName: 'Example Corp',
HebName: 'אקזמפל',
ItemType: 'Equity',
IsEtf: false,
IsForeign: true,
CurrencyCode: 'USD',
Exchange: 'NYQ',
EquityType: 1,
AllowedOrderDirection: 'BuyAndSell',
EquitySubType: 1,
EngSymbol: 'EXMP',
HebSymbol: 'EXMP',
Symbol: 'EXMP',
ExpirationDate: null,
StockType: 'Equity',
CreationEquityNum: null,
ContractType: null,
};
delete security[field];
expect(() =>
validatePayload('poalim-securities', {
View: { Meta: { '-AsOfDate': '2024-01-15T10:00:00.000+02:00', Security: [security] } },
}),
).toThrow();
});
it('accepts a minimal isracard payload', () => {
const result = validatePayload('isracard', {
Header: { Status: '1', Message: null },
CardsTransactionsListBean: {
cardNumberList: ['012345'],
Index0: { '@AllCards': 'AllCards', CurrentCardTransactions: [] },
},
});
expect(result.Header.Status).toBe('1');
});
it('accepts amex with the same shape as isracard', () => {
const result = validatePayload('amex', {
Header: { Status: '1', Message: null },
CardsTransactionsListBean: {
cardNumberList: ['343434'],
Index0: { '@AllCards': 'AllCards', CurrentCardTransactions: [] },
},
});
expect(result.Header.Status).toBe('1');
});
it('accepts a minimal cal payload', () => {
const result = validatePayload('cal', [
{ card: '1234', month: '2024-01', transactions: [] },
]);
expect(result).toHaveLength(1);
expect(result[0]!.card).toBe('1234');
});
it('accepts a minimal discount payload', () => {
const result = validatePayload('discount', [
{ accountNumber: 'ACC-001', month: '2024-01', balance: 5000, transactions: [] },
]);
expect(result).toHaveLength(1);
expect(result[0]!.accountNumber).toBe('ACC-001');
expect(result[0]!.balance).toBe(5000);
});
it('accepts a minimal max payload', () => {
const result = validatePayload('max', [{ accountNumber: '1234', txns: [] }]);
expect(result).toHaveLength(1);
expect(result[0]!.accountNumber).toBe('1234');
});
it('accepts an empty max payload', () => {
const result = validatePayload('max', []);
expect(result).toEqual([]);
});
it('accepts a currency-rates payload', () => {
const result = validatePayload('currency-rates', [
{ date: '2024-01-01', currency: 'USD', rate: 3.712 },
]);
expect(result).toHaveLength(1);
expect(result[0]!.currency).toBe('USD');
});
});
describe('validatePayload — invalid fixtures', () => {
it('throws PayloadValidationError for wrong transactions type in poalim-ils', () => {
expect(() =>
validatePayload('poalim-ils', {
transactions: 'not-an-array',
retrievalTransactionData: { accountNumber: 1, branchNumber: 2, bankNumber: 12 },
}),
).toThrow(PayloadValidationError);
});
it('error message includes the payload type', () => {
try {
validatePayload('poalim-ils', { transactions: 42 });
} catch (e) {
expect(e).toBeInstanceOf(PayloadValidationError);
expect((e as PayloadValidationError).message).toContain('poalim-ils');
expect((e as PayloadValidationError).payloadType).toBe('poalim-ils');
}
});
it('throws for missing required field in discount payload', () => {
expect(() =>
validatePayload('discount', [
{ accountNumber: 'ACC-001', month: '2024-01' }, // missing balance and transactions
]),
).toThrow(PayloadValidationError);
});
it('throws for invalid transaction type in poalim-ils', () => {
expect(() =>
validatePayload('poalim-ils', {
transactions: [
{
activityDescription: 'X',
activityTypeCode: 1,
eventAmount: 100,
eventDate: 20240101,
serialNumber: 1,
transactionType: 'INVALID_TYPE',
currentBalance: 5000,
referenceNumber: 1,
},
],
retrievalTransactionData: { accountNumber: 1, branchNumber: 2, bankNumber: 12 },
}),
).toThrow(PayloadValidationError);
});
it('throws for wrong currency in currency-rates', () => {
expect(() =>
validatePayload('currency-rates', [{ date: '2024-01-01', currency: 'XYZ', rate: 1.0 }]),
).toThrow(PayloadValidationError);
});
});
describe('runner integration — task-error on PayloadValidationError', () => {
afterEach(() => {
_resetRunState();
});
it('emits task-error (not a crash) when validatePayload throws inside run()', async () => {
const events: ServerMessage[] = [];
const task: ScrapeTask = {
sourceId: 'bad-src',
nickname: 'bad-src',
type: 'poalim',
run: async () => {
// Deliberately pass invalid data to trigger PayloadValidationError
validatePayload('poalim-ils', { transactions: 'not-an-array' });
return {
inserted: 0,
skipped: 0,
insertedIds: [],
insertedTransactions: [],
changedTransactions: [],
};
},
};
await startRun([task], false, msg => events.push(msg));
const taskError = events.find(e => e.type === 'task-error');
expect(taskError).toBeTruthy();
expect((taskError as { sourceId: string }).sourceId).toBe('bad-src');
expect(events.at(-1)).toMatchObject({ type: 'run-complete', totalInserted: 0, totalSkipped: 0 });
});
});