@accounter/server
Version:
Accounter GraphQL server
478 lines (415 loc) • 17.7 kB
text/typescript
import { Pool } from 'pg';
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
import { connectTestDb } from '../../../../__tests__/helpers/db-connection.js';
import { runMigrationsIfNeeded } from '../../../../__tests__/helpers/db-migrations.js';
import { dropRlsRole, ensureRlsRole, runAsRlsRole } from '../../../../__tests__/helpers/rls-role.js';
import type { AuthContextProvider } from '../../../auth/providers/auth-context.provider.js';
import { DBProvider } from '../../../app-providers/db.provider.js';
import { TenantAwareDBClient } from '../../../app-providers/tenant-db-client.js';
import type { FinancialAccountsProvider } from '../../../financial-accounts/providers/financial-accounts.provider.js';
import type { FinancialBankAccountsProvider } from '../../../financial-accounts/providers/financial-bank-accounts.provider.js';
import type { TransactionsProvider } from '../../../transactions/providers/transactions.provider.js';
import { ForeignSecuritiesProvider } from '../foreign-securities.provider.js';
let pool: Pool;
// poalim_securities.owner_id is a NOT NULL FK, so the tenants this suite acts as must
// exist in accounter_schema.businesses. Seeded in beforeAll, removed in afterAll.
const TEST_OWNER_ID = '00000000-0000-0000-0000-0000000006ec';
const OTHER_OWNER_ID = '00000000-0000-0000-0000-0000000006ed';
const CHARGE_ID = '00000000-0000-0000-0000-00000000c001';
const ACCOUNT_ID = '00000000-0000-0000-0000-00000000a001';
const BANK_NUMBER = 12;
const BRANCH_NUMBER = 615;
const ACCOUNT_NUMBER = 100000;
function createMockAuthContextProvider(businessId: string): AuthContextProvider {
return {
getAuthContext: () =>
Promise.resolve({
authType: 'apiKey' as const,
token: 'test-token',
tenant: { businessId },
user: {
userId: 'api-key:test',
auth0UserId: null,
email: '',
roleId: 'admin',
permissions: [],
emailVerified: true,
permissionsVersion: 0,
},
}),
} as unknown as AuthContextProvider;
}
type StubTransaction = {
id: string;
source_description: string | null;
amount?: string;
event_date?: Date;
debit_date?: Date | null;
debit_date_override?: Date | null;
account_id?: string;
};
/**
* The provider reads only the identity, description, account and date/amount fields off each
* transaction. Stubbing the loader keeps these cases focused on the DB-dependent behaviour
* (RLS scoping, the freshest-row pick, execution matching) instead of seeding the
* financial_accounts → transactions_raw_list → transactions FK chain.
*/
function createStubTransactionsProvider(transactions: StubTransaction[]): TransactionsProvider {
return {
transactionsByChargeIDLoader: {
load: (_chargeId: string) =>
Promise.resolve(
transactions.map(transaction => ({
amount: '-1000.00',
event_date: new Date('2024-03-10T00:00:00'),
debit_date: null,
debit_date_override: null,
account_id: ACCOUNT_ID,
...transaction,
})),
),
},
} as unknown as TransactionsProvider;
}
/**
* The account lookups are pure id → row maps in the real providers; stubbing them keeps this
* suite off the financial_accounts fixtures while still exercising the tuple resolution
* (text account_number → integer) the provider does.
*/
function createStubFinancialAccountsProvider(accountNumber = String(ACCOUNT_NUMBER)) {
return {
getFinancialAccountByAccountIDLoader: {
load: (id: string) =>
Promise.resolve(id === ACCOUNT_ID ? { id, account_number: accountNumber } : undefined),
},
} as unknown as FinancialAccountsProvider;
}
function createStubFinancialBankAccountsProvider() {
return {
getFinancialBankAccountByIdLoader: {
load: (id: string) =>
Promise.resolve(
id === ACCOUNT_ID
? { id, bank_number: BANK_NUMBER, branch_number: BRANCH_NUMBER }
: undefined,
),
},
} as unknown as FinancialBankAccountsProvider;
}
function createProvider(
transactions: StubTransaction[],
businessId = TEST_OWNER_ID,
accountNumber?: string,
) {
const authContextProvider = createMockAuthContextProvider(businessId);
const dbClient = new TenantAwareDBClient(new DBProvider(pool), authContextProvider);
return new ForeignSecuritiesProvider(
dbClient,
createStubTransactionsProvider(transactions),
createStubFinancialAccountsProvider(accountNumber),
createStubFinancialBankAccountsProvider(),
);
}
type SecurityFixture = {
ownerId?: string;
branchNumber?: number;
accountNumber?: number;
securityKey: string;
engName?: string;
asOfDate?: string;
};
// Synthetic values only — never lifted from a real bank capture.
async function insertSecurity({
ownerId = TEST_OWNER_ID,
branchNumber = 615,
accountNumber = 100000,
securityKey,
engName = 'Example Corp',
asOfDate = '2024-01-15T10:00:00.000+02:00',
}: SecurityFixture) {
await pool.query(
`INSERT INTO accounter_schema.poalim_securities (
owner_id, bank_number, branch_number, account_number, as_of_date, security_key,
eng_name, heb_name, item_type, is_etf, is_foreign, currency_code, exchange,
equity_type, equity_sub_type, eng_symbol, heb_symbol, symbol, stock_type
) VALUES ($1, 12, $2, $3, $4, $5, $6, 'אקזמפל', 'Equity', false, true, 'USD', 'NYQ',
1, 1, 'EXMP', 'EXMP', 'EXMP', 'Equity')`,
[ownerId, branchNumber, accountNumber, asOfDate, securityKey, engName],
);
}
type ExecutionFixture = {
ownerId?: string;
branchNumber?: number;
accountNumber?: number;
security: string;
tradeDate?: string;
tradeType?: string;
netValueTradeCurrency?: string;
};
// Synthetic values only — never lifted from a real bank capture.
async function insertExecution({
ownerId = TEST_OWNER_ID,
branchNumber = BRANCH_NUMBER,
accountNumber = ACCOUNT_NUMBER,
security,
tradeDate = '2024-03-10',
tradeType = 'קניה',
netValueTradeCurrency = '1000.00',
}: ExecutionFixture) {
// trade_type/transaction_type carry the bank's own Hebrew vocabulary; on a plain buy or sale
// the two agree (an invariant the scraper's zod schema enforces).
await pool.query(
`INSERT INTO accounter_schema.poalim_securities_transactions (
owner_id, bank_number, branch_number, account_number, security, trade_date,
trade_type, transaction_type, nv, trade_price, net_value_trade_currency, trade_currency
) VALUES ($1, $2, $3, $4, $5, $6, $7, $7, 10, 100, $8, 'דולר ארה"ב')`,
[
ownerId,
BANK_NUMBER,
branchNumber,
accountNumber,
security,
tradeDate,
tradeType,
netValueTradeCurrency,
],
);
}
beforeAll(async () => {
pool = await connectTestDb();
await runMigrationsIfNeeded(pool);
for (const [index, ownerId] of [TEST_OWNER_ID, OTHER_OWNER_ID].entries()) {
await pool.query(
`INSERT INTO accounter_schema.financial_entities (id, name)
VALUES ($1, $2)
ON CONFLICT (id) DO NOTHING`,
[ownerId, `foreign-securities-test-owner-${index}`],
);
await pool.query(
`INSERT INTO accounter_schema.businesses (id, owner_id)
VALUES ($1, $1)
ON CONFLICT (id) DO NOTHING`,
[ownerId],
);
}
await ensureRlsRole(pool, {
grants: [
{ table: 'poalim_securities', privileges: 'SELECT' },
{ table: 'poalim_securities_transactions', privileges: 'SELECT' },
],
});
});
afterAll(async () => {
await dropRlsRole(pool);
// poalim_securities and poalim_securities_transactions rows cascade with the owning business.
for (const ownerId of [TEST_OWNER_ID, OTHER_OWNER_ID]) {
await pool.query('DELETE FROM accounter_schema.businesses WHERE id = $1', [ownerId]);
await pool.query('DELETE FROM accounter_schema.financial_entities WHERE id = $1', [ownerId]);
}
// Do NOT close the pool here — it is shared with other concurrently-running suites and
// torn down by vitest-global-setup.
});
// DELETE rather than TRUNCATE CASCADE: TRUNCATE takes ACCESS EXCLUSIVE locks that deadlock
// against concurrently-running integration suites.
// Scoped to this suite's owners: the poalim_* tables are shared with the concurrently-running
// scraper-ingestion suite, and an unqualified DELETE wipes its fixtures mid-test.
beforeEach(async () => {
const owners = [TEST_OWNER_ID, OTHER_OWNER_ID];
await pool.query('DELETE FROM accounter_schema.poalim_securities WHERE owner_id = ANY($1)', [
owners,
]);
await pool.query(
'DELETE FROM accounter_schema.poalim_securities_transactions WHERE owner_id = ANY($1)',
[owners],
);
});
describe('getChargeSecurities', () => {
it('resolves a key from a transaction description to its ingested security', async () => {
await insertSecurity({ securityKey: '5129523', engName: 'Example Corp' });
const provider = createProvider([
{ id: 't1', source_description: 'ניע"ז מכירה 0005129523' },
]);
const securities = await provider.getChargeSecurities(CHARGE_ID);
expect(securities).toHaveLength(1);
expect(securities[0].securityKey).toBe('5129523');
expect(securities[0].id).toBe(`${CHARGE_ID}-5129523`);
expect(securities[0].details?.eng_name).toBe('Example Corp');
expect(securities[0].transactionIds).toEqual(['t1']);
});
it('returns an unresolved entry when the key has no ingested row', async () => {
const provider = createProvider([
{ id: 't1', source_description: 'ניע"ז קניה 0077774297' },
]);
const securities = await provider.getChargeSecurities(CHARGE_ID);
expect(securities).toHaveLength(1);
expect(securities[0].securityKey).toBe('77774297');
expect(securities[0].details).toBeNull();
expect(securities[0].transactionIds).toEqual(['t1']);
});
it('groups every transaction carrying the same key under one entry', async () => {
await insertSecurity({ securityKey: '5129523' });
const provider = createProvider([
{ id: 'trade', source_description: 'ניע"ז קניה 0005129523' },
{ id: 'fee', source_description: 'ניע"ז עמ קניה 0005129523' },
]);
const securities = await provider.getChargeSecurities(CHARGE_ID);
expect(securities).toHaveLength(1);
expect(securities[0].transactionIds).toEqual(['trade', 'fee']);
});
it('returns an empty array when no description carries a key', async () => {
await insertSecurity({ securityKey: '5129523' });
const provider = createProvider([
{ id: 't1', source_description: 'ניעז עמ תשלום fsec pymnt fee' },
{ id: 't2', source_description: null },
]);
expect(await provider.getChargeSecurities(CHARGE_ID)).toEqual([]);
});
it('returns one entry per distinct key on a merged charge', async () => {
await insertSecurity({ securityKey: '5129523', engName: 'Example Corp' });
await insertSecurity({
securityKey: '77774297',
engName: 'Other Corp',
accountNumber: 100001,
});
const provider = createProvider([
{ id: 't1', source_description: 'ניע"ז מכירה 0005129523' },
{ id: 't2', source_description: 'ניע"ז קניה 0077774297' },
]);
const securities = await provider.getChargeSecurities(CHARGE_ID);
expect(securities.map(s => s.securityKey)).toEqual(['5129523', '77774297']);
expect(securities.map(s => s.details?.eng_name)).toEqual(['Example Corp', 'Other Corp']);
});
it('picks the freshest row when one tenant holds the key in several accounts', async () => {
// The dedup index is (owner_id, bank, branch, account, key), so this is reachable.
await insertSecurity({
securityKey: '5129523',
accountNumber: 100000,
engName: 'Stale Name',
asOfDate: '2024-01-15T10:00:00.000+02:00',
});
await insertSecurity({
securityKey: '5129523',
accountNumber: 100001,
engName: 'Fresh Name',
asOfDate: '2024-06-30T10:00:00.000+02:00',
});
const provider = createProvider([
{ id: 't1', source_description: 'ניע"ז מכירה 0005129523' },
]);
const securities = await provider.getChargeSecurities(CHARGE_ID);
expect(securities).toHaveLength(1);
expect(securities[0].details?.eng_name).toBe('Fresh Name');
});
/**
* The provider's query carries no owner_id predicate — RLS is what scopes it. The test
* pool connects as postgres (BYPASSRLS), so asserting that through the provider would
* prove nothing; this runs the same query shape under a non-superuser role, which is
* the boundary the server actually operates behind.
*/
it("does not expose another tenant's securities under the tenant_isolation policy", async () => {
await insertSecurity({ ownerId: OTHER_OWNER_ID, securityKey: '5129523' });
const client = await pool.connect();
try {
await client.query('BEGIN');
// Session variables must be set as superuser, before privileges are dropped.
await client.query(`SELECT set_config('app.current_business_id', $1, true)`, [TEST_OWNER_ID]);
const rows = await runAsRlsRole(client, async () => {
const result = await client.query(
`SELECT security_key FROM accounter_schema.poalim_securities
WHERE security_key = ANY($1)`,
[['5129523']],
);
return result.rows;
});
expect(rows).toEqual([]);
} finally {
await client.query('ROLLBACK');
client.release();
}
});
});
describe('getChargeSecurities — matched executions', () => {
it('attaches the execution behind the charge transaction', async () => {
await insertSecurity({ securityKey: '5129523' });
await insertExecution({ security: '5129523', netValueTradeCurrency: '1000.00' });
const provider = createProvider([
{ id: 't1', source_description: 'ניע"ז קניה 0005129523', amount: '-1000.00' },
]);
const securities = await provider.getChargeSecurities(CHARGE_ID);
expect(securities[0].executions).toHaveLength(1);
expect(securities[0].executions[0].trade_type).toBe('קניה');
expect(securities[0].executions[0].net_value_trade_currency).toBe('1000.00');
});
it('picks the same-day execution whose amount matches the transaction', async () => {
await insertSecurity({ securityKey: '5129523' });
await insertExecution({ security: '5129523', netValueTradeCurrency: '1000.00' });
await insertExecution({
security: '5129523',
netValueTradeCurrency: '2500.50',
tradeType: 'מכירה',
});
const provider = createProvider([
{ id: 't1', source_description: 'ניע"ז מכירה 0005129523', amount: '2500.50' },
]);
const securities = await provider.getChargeSecurities(CHARGE_ID);
expect(securities[0].executions.map(e => e.net_value_trade_currency)).toEqual(['2500.50']);
});
it('excludes an execution booked in another account', async () => {
await insertSecurity({ securityKey: '5129523' });
await insertExecution({ security: '5129523', accountNumber: 100001 });
const provider = createProvider([
{ id: 't1', source_description: 'ניע"ז קניה 0005129523', amount: '-1000.00' },
]);
const securities = await provider.getChargeSecurities(CHARGE_ID);
expect(securities[0].executions).toEqual([]);
});
it('excludes an execution whose amount does not match', async () => {
await insertSecurity({ securityKey: '5129523' });
await insertExecution({ security: '5129523', netValueTradeCurrency: '4321.00' });
const provider = createProvider([
{ id: 't1', source_description: 'ניע"ז קניה 0005129523', amount: '-1000.00' },
]);
const securities = await provider.getChargeSecurities(CHARGE_ID);
expect(securities[0].executions).toEqual([]);
});
it('excludes an execution far outside the transaction date window', async () => {
await insertSecurity({ securityKey: '5129523' });
await insertExecution({ security: '5129523', tradeDate: '2024-01-05' });
const provider = createProvider([
{ id: 't1', source_description: 'ניע"ז קניה 0005129523', amount: '-1000.00' },
]);
const securities = await provider.getChargeSecurities(CHARGE_ID);
expect(securities[0].executions).toEqual([]);
});
it('returns no executions when the account number is not a Poalim integer', async () => {
await insertSecurity({ securityKey: '5129523' });
await insertExecution({ security: '5129523' });
const provider = createProvider(
[{ id: 't1', source_description: 'ניע"ז קניה 0005129523', amount: '-1000.00' }],
TEST_OWNER_ID,
'IL12-3456',
);
const securities = await provider.getChargeSecurities(CHARGE_ID);
expect(securities[0].executions).toEqual([]);
});
it("does not expose another tenant's executions under the tenant_isolation policy", async () => {
await insertExecution({ ownerId: OTHER_OWNER_ID, security: '5129523' });
const client = await pool.connect();
try {
await client.query('BEGIN');
await client.query(`SELECT set_config('app.current_business_id', $1, true)`, [TEST_OWNER_ID]);
const rows = await runAsRlsRole(client, async () => {
const result = await client.query(
`SELECT security FROM accounter_schema.poalim_securities_transactions
WHERE security = ANY($1)`,
[['5129523']],
);
return result.rows;
});
expect(rows).toEqual([]);
} finally {
await client.query('ROLLBACK');
client.release();
}
});
});