A self-hosted household ledger.
0

Configure Feed

Select the types of activity you want to include in your feed.

quantum / src / lib / server / services / sync.test.ts
11 kB 286 lines
1/// <reference lib="deno.ns" /> 2import { openDatabase } from '../db.ts'; 3import { runSync } from './sync.ts'; 4import { categorizeManually } from './categorization.ts'; 5import { commitImport, createDraftImport, saveMapping } from './imports.ts'; 6import type { DatabaseSync } from 'node:sqlite'; 7 8const MIGRATIONS_DIR = new URL('../../../../migrations', import.meta.url).pathname.replace( 9 /^\/([A-Za-z]:)/, 10 '$1' 11); 12 13function testDb(): DatabaseSync { 14 const db = openDatabase(`${Deno.makeTempDirSync()}/test.db`, MIGRATIONS_DIR); 15 db.prepare( 16 "INSERT INTO connections (access_url, claimed_at) VALUES ('https://u:p@bridge.test/simplefin', ?)" 17 ).run(new Date().toISOString()); 18 db.prepare("INSERT INTO users (did, handle, created_at) VALUES ('did:plc:t', 'tester', ?)").run( 19 new Date().toISOString() 20 ); 21 return db; 22} 23 24function fakeFetch(payload: unknown): typeof fetch { 25 return (() => 26 Promise.resolve(new Response(JSON.stringify(payload), { status: 200 }))) as typeof fetch; 27} 28 29const NOW_S = Math.floor(Date.now() / 1000); 30 31function payloadWith(transactions: unknown[], accountOverrides: Record<string, unknown> = {}) { 32 return { 33 errors: [], 34 accounts: [ 35 { 36 org: { name: 'Test Bank', domain: 'bank.test' }, 37 id: 'act-1', 38 name: 'Checking', 39 currency: 'USD', 40 balance: '100.00', 41 'balance-date': NOW_S, 42 transactions, 43 ...accountOverrides 44 } 45 ] 46 }; 47} 48 49function count(db: DatabaseSync, sql: string): number { 50 return (db.prepare(sql).get() as { n: number }).n; 51} 52 53Deno.test('a sync after a backfill leaves the imported rows alone', async () => { 54 // The hazard this guards: ingestTransactions treats its feed as authoritative 55 // for the account and soft-removes anything pending that the feed omits. A 56 // backfill CSV appears in no feed, ever. Imported rows are posted (pending = 0), 57 // which is what keeps them outside every branch of sync's authority — a 58 // structural property worth pinning down rather than rediscovering. 59 const db = testDb(); 60 const pendingTxn = { id: 'p1', posted: null, amount: '-5.00', description: 'PENDING COFFEE' }; 61 await runSync(db, fakeFetch(payloadWith([pendingTxn]))); 62 63 // Backfill straight into the discovered account, well before the feed's reach. 64 const importId = createDraftImport(db, 'act-1', 'old.csv', 'Date,Description,Amount\n2023-02-01,ANCIENT LUNCH,-9.99\n'); 65 saveMapping(db, importId, { 66 date: 'Date', 67 dateFormat: 'iso', 68 amountMode: 'signed', 69 amount: 'Amount', 70 description: 'Description' 71 }); 72 commitImport(db, importId); 73 74 // A later sync of the same connection: the feed still knows nothing of 2023. 75 await runSync(db, fakeFetch(payloadWith([pendingTxn, { id: 't9', posted: NOW_S, amount: '-1.00', description: 'NEW THING' }]))); 76 77 const imported = db 78 .prepare("SELECT removed_at, pending FROM transactions WHERE description = 'ANCIENT LUNCH'") 79 .get() as { removed_at: string | null; pending: number }; 80 if (imported.removed_at !== null) throw new Error('a sync must not remove backfilled rows'); 81 if (imported.pending !== 0) throw new Error('imported rows must stay posted'); 82 83 if (count(db, "SELECT COUNT(*) n FROM transactions WHERE description = 'ANCIENT LUNCH'") !== 1) { 84 throw new Error('a sync must not duplicate a backfilled row'); 85 } 86 // And the account is still healthy: the backfill did not make it look absent. 87 const account = db.prepare("SELECT state FROM accounts WHERE id = 'act-1'").get() as { 88 state: string; 89 }; 90 if (account.state === 'INACTIVE') throw new Error('the account should not have gone inactive'); 91 db.close(); 92}); 93 94Deno.test('sync archives raw payload, discovers account as NEW, snapshots balance', async () => { 95 const db = testDb(); 96 const payload = payloadWith([ 97 { id: 't1', posted: NOW_S - 1000, amount: '-10.00', description: 'STORE A' } 98 ]); 99 const [outcome] = await runSync(db, fakeFetch(payload)); 100 if (!outcome.ok) throw new Error(`sync failed: ${outcome.error}`); 101 if (outcome.newTransactions !== 1) throw new Error('expected 1 new transaction'); 102 // first sync = routine pass + deep backfill pass (new account discovered) 103 if (count(db, 'SELECT COUNT(*) n FROM raw_syncs WHERE ok = 1') !== 2) 104 throw new Error('expected both passes archived'); 105 const account = db.prepare("SELECT state FROM accounts WHERE id = 'act-1'").get() as { 106 state: string; 107 }; 108 if (account.state !== 'NEW') throw new Error(`expected NEW, got ${account.state}`); 109 if (count(db, 'SELECT COUNT(*) n FROM balance_snapshots') !== 2) 110 throw new Error('expected a snapshot per pass'); 111 db.close(); 112}); 113 114Deno.test('new accounts trigger one deep backfill pass; established syncs fetch once', async () => { 115 const db = testDb(); 116 const startDates: number[] = []; 117 const countingFetch = ((input: RequestInfo | URL) => { 118 startDates.push(Number(new URL(String(input)).searchParams.get('start-date'))); 119 return Promise.resolve( 120 new Response(JSON.stringify(payloadWith([])), { status: 200 }) 121 ); 122 }) as typeof fetch; 123 124 await runSync(db, countingFetch); // discovers act-1 125 if (startDates.length !== 2) throw new Error(`expected 2 fetches on first sync, got ${startDates.length}`); 126 const daysBack = (ts: number) => Math.round((Date.now() / 1000 - ts) / 86400); 127 if (daysBack(startDates[0]) > 45) throw new Error('routine pass should stay under 45 days'); 128 if (daysBack(startDates[1]) < 60) throw new Error('backfill pass should reach deep'); 129 130 await runSync(db, countingFetch); // no new accounts now 131 const totalFetches: number = startDates.length; 132 if (totalFetches !== 3) throw new Error('established sync should fetch exactly once'); 133 db.close(); 134}); 135 136Deno.test('re-syncing the same payload is a no-op for transactions', async () => { 137 const db = testDb(); 138 const payload = payloadWith([ 139 { id: 't1', posted: NOW_S - 1000, amount: '-10.00', description: 'STORE A' }, 140 { id: 't2', posted: NOW_S - 2000, amount: '2500.00', description: 'PAYROLL' } 141 ]); 142 await runSync(db, fakeFetch(payload)); 143 const [second] = await runSync(db, fakeFetch(payload)); 144 if (second.newTransactions !== 0) throw new Error('second sync inserted duplicates'); 145 if (count(db, 'SELECT COUNT(*) n FROM transactions') !== 2) 146 throw new Error('expected exactly 2 transactions'); 147 // snapshots accumulate per pass: first sync (2 passes) + second sync (1) 148 if (count(db, 'SELECT COUNT(*) n FROM balance_snapshots') !== 3) 149 throw new Error('expected 3 snapshots'); 150 db.close(); 151}); 152 153Deno.test('range advisories are excluded from connection-error banners', async () => { 154 const db = testDb(); 155 const payload = { 156 errors: [ 157 'Requested date range exceeds recommended range of 45 days. In the future, this may be capped.', 158 'Connection to Chase needs attention' 159 ], 160 accounts: [] 161 }; 162 await runSync(db, fakeFetch(payload)); 163 const { getConnectionErrors } = await import('./sync-status.ts'); 164 const errors = getConnectionErrors(db); 165 if (errors.length !== 1 || !errors[0].includes('Chase')) { 166 throw new Error(`advisory should be filtered, got: ${JSON.stringify(errors)}`); 167 } 168 db.close(); 169}); 170 171Deno.test('categorized pending transaction posting under a new id carries category via reconciliation event', async () => { 172 const db = testDb(); 173 await runSync( 174 db, 175 fakeFetch( 176 payloadWith([ 177 { 178 id: 'pend-1', 179 posted: 0, 180 pending: true, 181 transacted_at: NOW_S - 3600, 182 amount: '-25.00', 183 description: 'COFFEE SHOP (PENDING)' 184 } 185 ]) 186 ) 187 ); 188 const pendingRow = db.prepare('SELECT id FROM transactions').get() as { id: number }; 189 db.prepare("INSERT INTO categories (name, kind, created_at) VALUES ('Coffee','expense',?)").run( 190 new Date().toISOString() 191 ); 192 const categoryId = Number( 193 (db.prepare("SELECT id FROM categories WHERE name='Coffee'").get() as { id: number }).id 194 ); 195 categorizeManually(db, pendingRow.id, categoryId, 'did:plc:t'); 196 197 // Next sync: pending vanished, posted appears under a different id. 198 const [outcome] = await runSync( 199 db, 200 fakeFetch( 201 payloadWith([ 202 { id: 'post-9', posted: NOW_S, amount: '-25.00', description: 'COFFEE SHOP' } 203 ]) 204 ) 205 ); 206 if (outcome.reconciled !== 1) throw new Error(`expected 1 reconciled, got ${outcome.reconciled}`); 207 const txn = db 208 .prepare("SELECT id, sfin_id, pending, category_id, removed_at FROM transactions") 209 .get() as Record<string, unknown>; 210 if (txn.sfin_id !== 'post-9') throw new Error('row not replaced in place'); 211 if (txn.pending !== 0) throw new Error('still pending'); 212 if (txn.category_id !== categoryId) throw new Error('category not carried'); 213 if (txn.removed_at !== null) throw new Error('should not be removed'); 214 const events = db 215 .prepare('SELECT source FROM categorization_events ORDER BY id') 216 .all() as { source: string }[]; 217 if (events.map((e) => e.source).join(',') !== 'manual,reconciliation') 218 throw new Error(`unexpected event trail: ${events.map((e) => e.source)}`); 219 db.close(); 220}); 221 222Deno.test('stale pending transaction with no posted match is soft-removed', async () => { 223 const db = testDb(); 224 await runSync( 225 db, 226 fakeFetch( 227 payloadWith([ 228 { id: 'pend-2', posted: 0, pending: true, amount: '-5.00', description: 'GHOST' } 229 ]) 230 ) 231 ); 232 const [outcome] = await runSync(db, fakeFetch(payloadWith([]))); 233 if (outcome.removedPending !== 1) throw new Error('expected 1 removed pending'); 234 const row = db.prepare('SELECT removed_at FROM transactions').get() as { 235 removed_at: string | null; 236 }; 237 if (!row.removed_at) throw new Error('pending row not soft-removed'); 238 db.close(); 239}); 240 241Deno.test('ACTIVE account absent from feed goes INACTIVE and returns on reappearance', async () => { 242 const db = testDb(); 243 await runSync(db, fakeFetch(payloadWith([]))); 244 db.prepare("UPDATE accounts SET state='ACTIVE', account_type='checking' WHERE id='act-1'").run(); 245 246 // feed with a different account only 247 const otherAccount = { 248 errors: [], 249 accounts: [ 250 { 251 org: { name: 'Other' }, 252 id: 'act-2', 253 name: 'Savings', 254 currency: 'USD', 255 balance: '1.00', 256 transactions: [] 257 } 258 ] 259 }; 260 await runSync(db, fakeFetch(otherAccount)); 261 let state = (db.prepare("SELECT state FROM accounts WHERE id='act-1'").get() as { state: string }) 262 .state; 263 if (state !== 'INACTIVE') throw new Error(`expected INACTIVE, got ${state}`); 264 265 await runSync(db, fakeFetch(payloadWith([]))); 266 state = (db.prepare("SELECT state FROM accounts WHERE id='act-1'").get() as { state: string }) 267 .state; 268 if (state !== 'ACTIVE') throw new Error(`expected ACTIVE after reappearing, got ${state}`); 269 db.close(); 270}); 271 272Deno.test('failed fetch archives a failure row and touches nothing else', async () => { 273 const db = testDb(); 274 const failingFetch = (() => Promise.reject(new Error('network down'))) as unknown as typeof fetch; 275 const [outcome] = await runSync(db, failingFetch); 276 if (outcome.ok) throw new Error('expected failure'); 277 const raw = db.prepare('SELECT ok, error FROM raw_syncs').get() as { 278 ok: number; 279 error: string; 280 }; 281 if (raw.ok !== 0 || !raw.error.includes('network down')) 282 throw new Error('failure not recorded'); 283 if (count(db, 'SELECT COUNT(*) n FROM transactions') !== 0) 284 throw new Error('transactions should be untouched'); 285 db.close(); 286});