165 lines
6.3 KiB
JavaScript
165 lines
6.3 KiB
JavaScript
const test = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const fs = require('node:fs');
|
|
const os = require('node:os');
|
|
const path = require('node:path');
|
|
|
|
const dbPath = path.join(os.tmpdir(), `bill-tracker-bank-sync-test-${process.pid}.sqlite`);
|
|
process.env.DB_PATH = dbPath;
|
|
|
|
const { getDb, closeDb } = require('../db/database');
|
|
const { encryptSecret } = require('../services/encryptionService');
|
|
const { syncDataSource } = require('../services/bankSyncService');
|
|
|
|
function createUser(db, suffix) {
|
|
return db.prepare(`
|
|
INSERT INTO users (username, password_hash, role, active, email, created_at, updated_at)
|
|
VALUES (?, 'x', 'user', 1, ?, datetime('now'), datetime('now'))
|
|
`).run(`bank-sync-${suffix}`, `bank-sync-${suffix}@local`).lastInsertRowid;
|
|
}
|
|
|
|
function createSource(db, userId) {
|
|
return db.prepare(`
|
|
INSERT INTO data_sources (user_id, type, provider, name, status, encrypted_secret)
|
|
VALUES (?, 'provider_sync', 'simplefin', 'SimpleFIN', 'active', ?)
|
|
`).run(userId, encryptSecret('https://user:pass@example.com/simplefin')).lastInsertRowid;
|
|
}
|
|
|
|
function createAccount(db, userId, dataSourceId, providerAccountId, monitored) {
|
|
return db.prepare(`
|
|
INSERT INTO financial_accounts
|
|
(user_id, data_source_id, provider_account_id, name, currency, monitored)
|
|
VALUES (?, ?, ?, ?, 'USD', ?)
|
|
`).run(userId, dataSourceId, providerAccountId, providerAccountId, monitored ? 1 : 0).lastInsertRowid;
|
|
}
|
|
|
|
test.after(() => {
|
|
closeDb();
|
|
for (const suffix of ['', '-wal', '-shm']) {
|
|
fs.rmSync(`${dbPath}${suffix}`, { force: true });
|
|
}
|
|
});
|
|
|
|
test('SimpleFIN sync skips storing transactions for unmonitored accounts', async () => {
|
|
const db = getDb();
|
|
const userId = createUser(db, 'skip-unmonitored');
|
|
const dataSourceId = createSource(db, userId);
|
|
const mutedAccountId = createAccount(db, userId, dataSourceId, 'muted-account', false);
|
|
|
|
const originalFetch = global.fetch;
|
|
global.fetch = async () => ({
|
|
ok: true,
|
|
status: 200,
|
|
json: async () => ({
|
|
accounts: [
|
|
{
|
|
id: 'muted-account',
|
|
name: 'Muted Account',
|
|
currency: 'USD',
|
|
balance: '100.00',
|
|
transactions: [
|
|
{ id: 'muted-tx-1', amount: '-12.34', posted: 1772323200, description: 'Muted charge' },
|
|
],
|
|
},
|
|
{
|
|
id: 'tracked-account',
|
|
name: 'Tracked Account',
|
|
currency: 'USD',
|
|
balance: '200.00',
|
|
transactions: [
|
|
{ id: 'tracked-tx-1', amount: '-56.78', posted: 1772323200, description: 'Tracked charge' },
|
|
],
|
|
},
|
|
],
|
|
}),
|
|
});
|
|
|
|
try {
|
|
const result = await syncDataSource(db, userId, dataSourceId);
|
|
assert.equal(result.transactionsNew, 1);
|
|
} finally {
|
|
global.fetch = originalFetch;
|
|
}
|
|
|
|
const mutedTransactions = db.prepare('SELECT COUNT(*) AS count FROM transactions WHERE account_id = ?').get(mutedAccountId).count;
|
|
assert.equal(mutedTransactions, 0);
|
|
|
|
const trackedAccount = db.prepare(`
|
|
SELECT id, monitored
|
|
FROM financial_accounts
|
|
WHERE user_id = ? AND data_source_id = ? AND provider_account_id = 'tracked-account'
|
|
`).get(userId, dataSourceId);
|
|
assert.equal(trackedAccount.monitored, 1);
|
|
|
|
const trackedTransactions = db.prepare('SELECT COUNT(*) AS count FROM transactions WHERE account_id = ?').get(trackedAccount.id).count;
|
|
assert.equal(trackedTransactions, 1);
|
|
});
|
|
|
|
test('SimpleFIN pending transactions: insert, settle in place, then prune orphans', async () => {
|
|
const db = getDb();
|
|
const userId = createUser(db, 'pending-lifecycle');
|
|
const dataSourceId = createSource(db, userId);
|
|
const accountId = createAccount(db, userId, dataSourceId, 'pending-acct', true);
|
|
|
|
const originalFetch = global.fetch;
|
|
const mockAccounts = (transactions) => {
|
|
global.fetch = async () => ({
|
|
ok: true, status: 200,
|
|
json: async () => ({
|
|
accounts: [{ id: 'pending-acct', name: 'Pending Acct', currency: 'USD', balance: '500.00', transactions }],
|
|
}),
|
|
});
|
|
};
|
|
|
|
try {
|
|
// 1. First sync — one pending charge (posted=0, pending:true)
|
|
mockAccounts([
|
|
{ id: 'tx-1', amount: '-42.00', posted: 0, pending: true, description: 'Coffee (pending)' },
|
|
]);
|
|
let result = await syncDataSource(db, userId, dataSourceId);
|
|
assert.equal(result.transactionsNew, 1);
|
|
|
|
let row = db.prepare('SELECT pending, posted_date FROM transactions WHERE provider_transaction_id = ?')
|
|
.get('simplefin:pending-acct:tx-1');
|
|
assert.equal(row.pending, 1, 'stored as pending');
|
|
assert.equal(row.posted_date, null, 'pending has no posted_date');
|
|
|
|
// 2. Second sync — same id now settled (posted timestamp, pending gone)
|
|
mockAccounts([
|
|
{ id: 'tx-1', amount: '-42.50', posted: 1772323200, description: 'Coffee' },
|
|
]);
|
|
result = await syncDataSource(db, userId, dataSourceId);
|
|
assert.equal(result.transactionsNew, 0, 'no new row — updated in place');
|
|
assert.equal(result.transactionsPosted, 1, 'counted as settled');
|
|
|
|
const rows = db.prepare('SELECT pending, posted_date, amount FROM transactions WHERE account_id = ?').all(accountId);
|
|
assert.equal(rows.length, 1, 'no duplicate row created on settle');
|
|
assert.equal(rows[0].pending, 0, 'flipped to posted');
|
|
assert.equal(rows[0].posted_date, '2026-03-01', 'gained posted_date');
|
|
assert.equal(rows[0].amount, -4250, 'amount refreshed to settled value');
|
|
|
|
// 3. Add a fresh pending charge, then a sync where it vanishes (re-posted under a new id)
|
|
mockAccounts([
|
|
{ id: 'tx-2', amount: '-9.99', posted: 0, pending: true, description: 'Snack (pending)' },
|
|
]);
|
|
await syncDataSource(db, userId, dataSourceId);
|
|
assert.equal(
|
|
db.prepare("SELECT COUNT(*) AS n FROM transactions WHERE account_id = ? AND pending = 1").get(accountId).n,
|
|
1, 'pending tx-2 stored',
|
|
);
|
|
|
|
// tx-2 disappears from the feed (settled under a new id); orphan prune should clear it
|
|
mockAccounts([
|
|
{ id: 'tx-3', amount: '-9.99', posted: 1772323200, description: 'Snack' },
|
|
]);
|
|
result = await syncDataSource(db, userId, dataSourceId);
|
|
assert.equal(result.pendingCleared, 1, 'orphaned pending row pruned');
|
|
assert.equal(
|
|
db.prepare("SELECT COUNT(*) AS n FROM transactions WHERE account_id = ? AND pending = 1").get(accountId).n,
|
|
0, 'no stale pending rows remain',
|
|
);
|
|
} finally {
|
|
global.fetch = originalFetch;
|
|
}
|
|
});
|