diff --git a/server/tests/unit/db/repositories/Addons.repository.test.ts b/server/tests/unit/db/repositories/Addons.repository.test.ts index aee0a4c59..defa86967 100644 --- a/server/tests/unit/db/repositories/Addons.repository.test.ts +++ b/server/tests/unit/db/repositories/Addons.repository.test.ts @@ -3,6 +3,7 @@ import { createSnapshotTestDb } from '../../../helpers/db-mock'; import { resetTestDb } from '../../../helpers/test-db'; import { createTestOrm, type TestOrm } from '../../../helpers/test-orm'; import { Addons } from '../../../../src/db/entities/Addons.entity'; +import { deleteRows, insertRow, updateRows } from '../../../helpers/factories/rows'; import type { AddonsRepository } from '../../../../src/db/repositories/Addons.repository'; const testDb = createSnapshotTestDb(); @@ -13,12 +14,12 @@ beforeAll(async () => { t = await createTestOrm(testDb); addons = t.repo(Addons); }); -beforeEach(() => { +beforeEach(async () => { resetTestDb(testDb); // resetTestDb deliberately keeps the seeded addons catalogue (test-db.ts's // KEEP_TABLES) — these tests want a known, empty table so every case // controls its own rows. - testDb.exec('DELETE FROM addons'); + await deleteRows(t, Addons); t.clear(); }); afterAll(async () => { @@ -27,10 +28,11 @@ afterAll(async () => { }); function rawRow(id: string): unknown { + // test-sql-allow: the stored integer is under test, which the entity reads back as a boolean. return testDb.prepare('SELECT * FROM addons WHERE id = ?').get(id); } -function insertAddon(row: { +async function insertAddon(row: { id: string; name: string; description?: string | null; @@ -38,21 +40,27 @@ function insertAddon(row: { icon?: string | null; enabled: 0 | 1; sort_order?: number; -}): void { - testDb - .prepare('INSERT INTO addons (id, name, description, type, icon, enabled, sort_order) VALUES (?, ?, ?, ?, ?, ?, ?)') - .run(row.id, row.name, row.description ?? null, row.type ?? 'global', row.icon ?? null, row.enabled, row.sort_order ?? 0); +}): Promise { + await insertRow(t, Addons, { + id: row.id, + name: row.name, + description: row.description ?? null, + type: row.type ?? 'global', + icon: row.icon ?? null, + enabled: row.enabled === 1, + sort_order: row.sort_order ?? 0, + }); } describe('AddonsRepository', () => { describe('isEnabled', () => { it('ADDONSREPO-001: reads a stored 1 as true', async () => { - insertAddon({ id: 'budget', name: 'Budget', enabled: 1 }); + await insertAddon({ id: 'budget', name: 'Budget', enabled: 1 }); expect(await addons.isEnabled('budget')).toBe(true); }); it('ADDONSREPO-002: reads a stored 0 as false', async () => { - insertAddon({ id: 'budget', name: 'Budget', enabled: 0 }); + await insertAddon({ id: 'budget', name: 'Budget', enabled: 0 }); expect(await addons.isEnabled('budget')).toBe(false); }); @@ -64,10 +72,10 @@ describe('AddonsRepository', () => { // repeat call from the identity map unless refresh: true is set — // invisible to a write on the same id in the same request. describe('sees a write on the same id in the same request (I1, identity-map regression)', () => { - it('ADDONSREPO-005: a raw UPDATE on the same id then isEnabled reads the new value', async () => { - insertAddon({ id: 'budget', name: 'Budget', enabled: 0 }); + it('ADDONSREPO-005: an UPDATE from another context on the same id then isEnabled reads the new value', async () => { + await insertAddon({ id: 'budget', name: 'Budget', enabled: 0 }); expect(await addons.isEnabled('budget')).toBe(false); // populate the identity map - testDb.prepare('UPDATE addons SET enabled = 1 WHERE id = ?').run('budget'); + await updateRows(t, Addons, { id: 'budget' }, { enabled: true }); expect(await addons.isEnabled('budget')).toBe(true); }); }); @@ -75,16 +83,16 @@ describe('AddonsRepository', () => { describe('listEnabled', () => { it('ADDONSREPO-006: returns only enabled addons, ordered by sort_order', async () => { - insertAddon({ id: 'atlas', name: 'Atlas', type: 'page', icon: 'globe', enabled: 1, sort_order: 2 }); - insertAddon({ id: 'budget', name: 'Costs', type: 'trip', icon: 'wallet', enabled: 1, sort_order: 1 }); - insertAddon({ id: 'vacay', name: 'Vacay', type: 'page', icon: 'sun', enabled: 0, sort_order: 0 }); + await insertAddon({ id: 'atlas', name: 'Atlas', type: 'page', icon: 'globe', enabled: 1, sort_order: 2 }); + await insertAddon({ id: 'budget', name: 'Costs', type: 'trip', icon: 'wallet', enabled: 1, sort_order: 1 }); + await insertAddon({ id: 'vacay', name: 'Vacay', type: 'page', icon: 'sun', enabled: 0, sort_order: 0 }); const rows = await addons.listEnabled(); expect(rows.map((r) => r.id)).toEqual(['budget', 'atlas']); }); it('ADDONSREPO-007: the enabled column comes back as a JS boolean, not the stored int', async () => { - insertAddon({ id: 'atlas', name: 'Atlas', enabled: 1 }); + await insertAddon({ id: 'atlas', name: 'Atlas', enabled: 1 }); const [row] = await addons.listEnabled(); expect(row.enabled).toBe(true); expect(rawRow('atlas')).toMatchObject({ enabled: 1 }); @@ -95,7 +103,7 @@ describe('AddonsRepository', () => { }); it('ADDONSREPO-009: carries the full row shape (id, name, description, type, icon, enabled, config, sort_order)', async () => { - insertAddon({ id: 'atlas', name: 'Atlas', description: 'Visited countries map', type: 'page', icon: 'globe', enabled: 1, sort_order: 3 }); + await insertAddon({ id: 'atlas', name: 'Atlas', description: 'Visited countries map', type: 'page', icon: 'globe', enabled: 1, sort_order: 3 }); const [row] = await addons.listEnabled(); expect(row).toEqual({ id: 'atlas', @@ -116,8 +124,9 @@ describe('AddonsRepository', () => { // names. describe('listAllOrdered (AD26)', () => { it('ADDONSREPO-010: matches SELECT * FROM addons ORDER BY sort_order, id — unfiltered, including a disabled addon', async () => { - insertAddon({ id: 'vacay', name: 'Vacay', type: 'page', icon: 'sun', enabled: 0, sort_order: 1 }); - insertAddon({ id: 'budget', name: 'Costs', type: 'trip', icon: 'wallet', enabled: 1, sort_order: 0 }); + await insertAddon({ id: 'vacay', name: 'Vacay', type: 'page', icon: 'sun', enabled: 0, sort_order: 1 }); + await insertAddon({ id: 'budget', name: 'Costs', type: 'trip', icon: 'wallet', enabled: 1, sort_order: 0 }); + // test-sql-allow: the legacy statement is the oracle the repository read is held to. const legacy = testDb.prepare('SELECT * FROM addons ORDER BY sort_order, id').all(); const rows = await addons.listAllOrdered(); expect(rows.map((r) => r.id)).toEqual(['budget', 'vacay']); // includes the disabled one, unlike listEnabled @@ -133,11 +142,11 @@ describe('AddonsRepository', () => { describe('findById (AD30/AD39)', () => { it('ADDONSREPO-012: matches SELECT * FROM addons WHERE id = ?, on both a pre-write read and a post-write re-select (byte-identical text at both call sites)', async () => { - insertAddon({ id: 'budget', name: 'Costs', description: 'Track spend', type: 'trip', icon: 'wallet', enabled: 0, sort_order: 4 }); + await insertAddon({ id: 'budget', name: 'Costs', description: 'Track spend', type: 'trip', icon: 'wallet', enabled: 0, sort_order: 4 }); const preWrite = await addons.findById('budget'); expect(preWrite).toEqual({ id: 'budget', name: 'Costs', description: 'Track spend', type: 'trip', icon: 'wallet', enabled: false, config: {}, sort_order: 4 }); - testDb.prepare('UPDATE addons SET enabled = 1 WHERE id = ?').run('budget'); + await updateRows(t, Addons, { id: 'budget' }, { enabled: true }); const postWrite = await addons.findById('budget'); expect(postWrite?.enabled).toBe(true); }); diff --git a/server/tests/unit/db/repositories/AppSettings.repository.test.ts b/server/tests/unit/db/repositories/AppSettings.repository.test.ts index 2e7086c4f..16e1455f1 100644 --- a/server/tests/unit/db/repositories/AppSettings.repository.test.ts +++ b/server/tests/unit/db/repositories/AppSettings.repository.test.ts @@ -3,6 +3,7 @@ import { createSnapshotTestDb } from '../../../helpers/db-mock'; import { resetTestDb } from '../../../helpers/test-db'; import { createTestOrm, type TestOrm } from '../../../helpers/test-orm'; import { AppSettings } from '../../../../src/db/entities/AppSettings.entity'; +import { countRows, insertRow, updateRows } from '../../../helpers/factories/rows'; import type { AppSettingsRepository } from '../../../../src/db/repositories/AppSettings.repository'; const testDb = createSnapshotTestDb(); @@ -17,11 +18,12 @@ beforeEach(() => { resetTestDb(testDb); t.clear(); }); afterAll(async () => { await t.close(); testDb.close(); }); function rawRow(key: string): unknown { + // test-sql-allow: the row as SELECT * returns it is the oracle the repository writes are held to. return testDb.prepare('SELECT * FROM app_settings WHERE key = ?').get(key); } -function insertRaw(key: string, value: string | null): void { - testDb.prepare('INSERT INTO app_settings (key, value) VALUES (?, ?)').run(key, value); +async function insertRaw(key: string, value: string | null): Promise { + await insertRow(t, AppSettings, { key, value }); } /** @@ -45,7 +47,7 @@ async function withQueryCount(fn: () => Promise): Promise<{ value: T; quer describe('AppSettingsRepository', () => { it('APPSETREPO-001: getValue reads the stored value', async () => { - insertRaw('bag_tracking_enabled', 'true'); + await insertRaw('bag_tracking_enabled', 'true'); expect(await appSettings.getValue('bag_tracking_enabled')).toBe('true'); }); @@ -54,15 +56,15 @@ describe('AppSettingsRepository', () => { }); it('APPSETREPO-003: getValues reads several keys into a Map, silently dropping keys with no row', async () => { - insertRaw('collab_chat_enabled', 'false'); - insertRaw('collab_notes_enabled', 'true'); + await insertRaw('collab_chat_enabled', 'false'); + await insertRaw('collab_notes_enabled', 'true'); const values = await appSettings.getValues(['collab_chat_enabled', 'collab_notes_enabled', 'collab_links_enabled']); expect(values).toEqual(new Map([['collab_chat_enabled', 'false'], ['collab_notes_enabled', 'true']])); }); it('APPSETREPO-012 (M1): getValues also drops a key whose row exists but whose value is NULL, matching the "row present with a NULL value" case as absent, same as a missing row', async () => { - insertRaw('collab_chat_enabled', 'false'); - insertRaw('collab_links_enabled', null); // present row, NULL value — legacy treated this as absent too + await insertRaw('collab_chat_enabled', 'false'); + await insertRaw('collab_links_enabled', null); // present row, NULL value — legacy treated this as absent too const values = await appSettings.getValues(['collab_chat_enabled', 'collab_links_enabled', 'collab_notes_enabled']); expect(values).toEqual(new Map([['collab_chat_enabled', 'false']])); expect(values.has('collab_links_enabled')).toBe(false); @@ -75,7 +77,7 @@ describe('AppSettingsRepository', () => { // fix at one query each. describe('getValue sees a raw write/delete on the same key in the same request (disableIdentityMap regression)', () => { it('APPSETREPO-013: deleteValue then getValue reads null, not the deleted row, in one query', async () => { - insertRaw('bag_tracking_enabled', 'v'); + await insertRaw('bag_tracking_enabled', 'v'); expect(await appSettings.getValue('bag_tracking_enabled')).toBe('v'); // populate the identity map await appSettings.deleteValue('bag_tracking_enabled'); const { value, queries } = await withQueryCount(() => appSettings.getValue('bag_tracking_enabled')); @@ -83,10 +85,10 @@ describe('AppSettingsRepository', () => { expect(queries).toBe(1); }); - it('APPSETREPO-014: a raw UPDATE on the same key then getValue reads the new value, in one query', async () => { - insertRaw('bag_tracking_enabled', 'old'); + it('APPSETREPO-014: an UPDATE from another context on the same key then getValue reads the new value, in one query', async () => { + await insertRaw('bag_tracking_enabled', 'old'); expect(await appSettings.getValue('bag_tracking_enabled')).toBe('old'); // populate the identity map - testDb.prepare('UPDATE app_settings SET value = ? WHERE key = ?').run('new', 'bag_tracking_enabled'); + await updateRows(t, AppSettings, { key: 'bag_tracking_enabled' }, { value: 'new' }); const { value, queries } = await withQueryCount(() => appSettings.getValue('bag_tracking_enabled')); expect(value).toBe('new'); expect(queries).toBe(1); @@ -99,15 +101,15 @@ describe('AppSettingsRepository', () => { }); it('APPSETREPO-005: setValue on an existing key replaces the value only — the row (key, value) is byte-identical to INSERT OR REPLACE/ON CONFLICT DO UPDATE, because app_settings has no third column for either to reset', async () => { - insertRaw('bag_tracking_enabled', 'false'); + await insertRaw('bag_tracking_enabled', 'false'); await appSettings.setValue('bag_tracking_enabled', 'true'); expect(rawRow('bag_tracking_enabled')).toStrictEqual({ key: 'bag_tracking_enabled', value: 'true' }); // Only the one row exists for that key — no duplicate/ghost row from upsert. - expect(testDb.prepare('SELECT COUNT(*) as c FROM app_settings WHERE key = ?').get('bag_tracking_enabled')).toEqual({ c: 1 }); + expect(await countRows(t, AppSettings, { key: 'bag_tracking_enabled' })).toBe(1); }); it('APPSETREPO-006: deleteValue removes the row and returns the legacy DELETE-affected-row count', async () => { - insertRaw('whitespace_migration_collision', 'true'); + await insertRaw('whitespace_migration_collision', 'true'); expect(await appSettings.deleteValue('whitespace_migration_collision')).toBe(1); expect(rawRow('whitespace_migration_collision')).toBeUndefined(); }); @@ -117,9 +119,9 @@ describe('AppSettingsRepository', () => { }); it('APPSETREPO-008: findByKeyPrefix matches every key sharing the prefix, in whatever order the table returns them', async () => { - insertRaw('perm_view_days', '1'); - insertRaw('perm_edit_budget', '2'); - insertRaw('unrelated_key', '3'); + await insertRaw('perm_view_days', '1'); + await insertRaw('perm_edit_budget', '2'); + await insertRaw('unrelated_key', '3'); const rows = await appSettings.findByKeyPrefix('perm_'); expect(rows.map((r) => r.key).sort()).toEqual(['perm_edit_budget', 'perm_view_days']); expect(rows).toEqual(expect.arrayContaining([ @@ -129,7 +131,7 @@ describe('AppSettingsRepository', () => { }); it('APPSETREPO-009: findByKeyPrefix with no matches returns an empty array', async () => { - insertRaw('unrelated_key', 'x'); + await insertRaw('unrelated_key', 'x'); expect(await appSettings.findByKeyPrefix('perm_')).toEqual([]); }); @@ -140,8 +142,8 @@ describe('AppSettingsRepository', () => { // observable behavior on this table is identical (setValue's own // docstring explains why). it('APPSETREPO-015: countKeysPresent counts every matching ROW, including one whose value is NULL — unlike getValues, which drops it', async () => { - insertRaw('storage.backends', '[]'); - insertRaw('storage.categories', null); // present row, NULL value + await insertRaw('storage.backends', '[]'); + await insertRaw('storage.categories', null); // present row, NULL value expect(await appSettings.countKeysPresent(['storage.backends', 'storage.categories'])).toBe(2); expect(await appSettings.countKeysPresent(['storage.backends', 'storage.does_not_exist'])).toBe(1); expect(await appSettings.countKeysPresent(['storage.nope_a', 'storage.nope_b'])).toBe(0); @@ -153,10 +155,10 @@ describe('AppSettingsRepository', () => { }); it('APPSETREPO-017: upsertOrReplace on an existing key replaces the value only, with no duplicate/ghost row', async () => { - insertRaw('storage.usage', '{"computedAt":1}'); + await insertRaw('storage.usage', '{"computedAt":1}'); await appSettings.upsertOrReplace('storage.usage', '{"computedAt":2}'); expect(rawRow('storage.usage')).toStrictEqual({ key: 'storage.usage', value: '{"computedAt":2}' }); - expect(testDb.prepare('SELECT COUNT(*) as c FROM app_settings WHERE key = ?').get('storage.usage')).toEqual({ c: 1 }); + expect(await countRows(t, AppSettings, { key: 'storage.usage' })).toBe(1); }); // LIKE-escaping finding: neither the legacy `LIKE 'prefix%'` literal nor @@ -168,6 +170,7 @@ describe('AppSettingsRepository', () => { // legacy raw-SQL statement directly, not against a hand-picked expectation. describe('LIKE escaping — % and _ in the prefix are wildcards, not escaped, exactly as the legacy statement behaved', () => { function legacyPrefixMatch(prefix: string): { key: string; value: string }[] { + // test-sql-allow: the legacy LIKE statement is the oracle the repository read is held to. return testDb.prepare('SELECT key, value FROM app_settings WHERE key LIKE ?').all(`${prefix}%`) as { key: string; value: string; @@ -177,9 +180,9 @@ describe('AppSettingsRepository', () => { it('APPSETREPO-010: an underscore in the prefix matches any single character there, like a raw LIKE pattern would', async () => { // 'ab_c%' as a LIKE pattern: '_' matches any one character, so both // 'ab_cd' (literal underscore) and 'abXcd' (any other character) match. - insertRaw('ab_cd', '1'); - insertRaw('abXcd', '2'); - insertRaw('abcd', '3'); // one character short at that position — no match + await insertRaw('ab_cd', '1'); + await insertRaw('abXcd', '2'); + await insertRaw('abcd', '3'); // one character short at that position — no match const rows = await appSettings.findByKeyPrefix('ab_c'); expect(rows.map((r) => r.key).sort()).toEqual(legacyPrefixMatch('ab_c').map((r) => r.key).sort()); expect(rows.map((r) => r.key).sort()).toEqual(['abXcd', 'ab_cd']); @@ -188,9 +191,9 @@ describe('AppSettingsRepository', () => { it('APPSETREPO-011: a percent sign in the prefix matches zero or more characters there, like a raw LIKE pattern would', async () => { // 'ab%c%' as a LIKE pattern: '%' matches zero-or-more, so both // 'abc' (zero characters at the % position) and 'abZZc' match. - insertRaw('abc', '1'); - insertRaw('abZZc', '2'); - insertRaw('abd', '3'); // no 'c' after the gap — no match + await insertRaw('abc', '1'); + await insertRaw('abZZc', '2'); + await insertRaw('abd', '3'); // no 'c' after the gap — no match const rows = await appSettings.findByKeyPrefix('ab%c'); expect(rows.map((r) => r.key).sort()).toEqual(legacyPrefixMatch('ab%c').map((r) => r.key).sort()); expect(rows.map((r) => r.key).sort()).toEqual(['abZZc', 'abc']); diff --git a/server/tests/unit/db/repositories/Categories.repository.test.ts b/server/tests/unit/db/repositories/Categories.repository.test.ts index 12338df59..af271df5e 100644 --- a/server/tests/unit/db/repositories/Categories.repository.test.ts +++ b/server/tests/unit/db/repositories/Categories.repository.test.ts @@ -4,6 +4,7 @@ import { resetTestDb } from '../../../helpers/test-db'; import { createTestOrm, type TestOrm } from '../../../helpers/test-orm'; import { createCategory, createUser } from '../../../helpers/factories'; import { Categories } from '../../../../src/db/entities/Categories.entity'; +import { updateRows } from '../../../helpers/factories/rows'; import type { CategoriesRepository } from '../../../../src/db/repositories/Categories.repository'; const testDb = createSnapshotTestDb(); @@ -22,6 +23,7 @@ beforeEach(() => { resetTestDb(testDb); t.clear(); }); afterAll(async () => { await t.close(); testDb.close(); }); function rawCategory(id: number): unknown { + // test-sql-allow: the row as SELECT * returns it is the oracle the repository's output is held to. return testDb.prepare('SELECT * FROM categories WHERE id = ?').get(id); } @@ -93,10 +95,10 @@ describe('CategoriesRepository', () => { // without it this would risk being served from the identity map with // zero queries and the stale pre-UPDATE color. `queries === 1` proves // the real re-query happens. - it('CATREPO-008: a raw UPDATE on the same row then findById reads the new value, in one query', async () => { + it('CATREPO-008: an UPDATE from another context on the same row then findById reads the new value, in one query', async () => { const created = createCategory(testDb, { name: 'RepoStale', color: '#111111' }); expect((await categories.findById(created.id))?.color).toBe('#111111'); // populate the identity map - testDb.prepare('UPDATE categories SET color = ? WHERE id = ?').run('#222222', created.id); + await updateRows(t, Categories, { id: created.id }, { color: '#222222' }); const { value, queries } = await withQueryCount(() => categories.findById(created.id)); expect(value?.color).toBe('#222222'); expect(queries).toBe(1); @@ -169,10 +171,10 @@ describe('CategoriesRepository', () => { // this doubles as the "list → raw UPDATE → patch" case the review // asked for. Without `refresh: true` in `patch`'s lookup, `icon` would // still read the pre-UPDATE 'OLD' (verified: fails without the fix). - it('CATREPO-018: an untouched column reflects a raw UPDATE made after the identity map was populated by list()', async () => { + it('CATREPO-018: an untouched column reflects an UPDATE from another context made after the identity map was populated by list()', async () => { const created = createCategory(testDb, { name: 'RepoRaceIcon', color: '#aaaaaa', icon: 'OLD' }); await categories.list(); // populate the identity map with icon: 'OLD' - testDb.prepare('UPDATE categories SET icon = ? WHERE id = ?').run('NEW', created.id); + await updateRows(t, Categories, { id: created.id }, { icon: 'NEW' }); const updated = await categories.patch(created.id, { color: '#bbbbbb' }); expect(updated?.icon).toBe('NEW'); }); @@ -184,10 +186,10 @@ describe('CategoriesRepository', () => { // though the method reports success. The raw-row assertion is what // catches it — `updated?.color` alone would still read '#111111' // either way (verified: fails without the fix). - it('CATREPO-019: a patch matching a concurrent raw write is not silently dropped', async () => { + it('CATREPO-019: a patch matching a concurrent write from another context is not silently dropped', async () => { const created = createCategory(testDb, { name: 'RepoRaceColor', color: '#111111' }); await categories.findById(created.id); // populate the identity map with '#111111' - testDb.prepare('UPDATE categories SET color = ? WHERE id = ?').run('#333333', created.id); + await updateRows(t, Categories, { id: created.id }, { color: '#333333' }); const updated = await categories.patch(created.id, { color: '#111111' }); expect(updated?.color).toBe('#111111'); expect(rawCategory(created.id)).toMatchObject({ color: '#111111' }); diff --git a/server/tests/unit/db/repositories/MaintenanceRepository.test.ts b/server/tests/unit/db/repositories/MaintenanceRepository.test.ts index 1c87f3333..4ecddab8b 100644 --- a/server/tests/unit/db/repositories/MaintenanceRepository.test.ts +++ b/server/tests/unit/db/repositories/MaintenanceRepository.test.ts @@ -19,10 +19,11 @@ afterAll(async () => { await t.close(); testDb.close(); }); describe('MaintenanceRepository', () => { it('MAINTREPO-001: walCheckpoint renders PRAGMA wal_checkpoint(TRUNCATE) — pinned text, equivalent to the legacy raw call', async () => { - // Rendered-SQL-equivalence: the exact text a direct db.prepare(...) call - // on the same input executes without throwing, string-for-string what - // the legacy `db.exec('PRAGMA wal_checkpoint(TRUNCATE)')` issued. + // Rendered-SQL-equivalence: the exact text a direct statement on the + // handle executes without throwing, string-for-string what the legacy + // `db.exec('PRAGMA wal_checkpoint(TRUNCATE)')` issued. const RENDERED = 'PRAGMA wal_checkpoint(TRUNCATE)'; + // test-sql-allow: the pragma text itself is under test, run as the raw statement it renders to. expect(() => testDb.prepare(RENDERED).run()).not.toThrow(); await expect(maintenance.walCheckpoint()).resolves.toBeUndefined(); }); @@ -41,6 +42,7 @@ describe('MaintenanceRepository', () => { const snap = new Database(scratchPath, { readonly: true }); try { + // test-sql-allow: a snapshot file no ORM is bound to, read through sqlite_master. const row = snap.prepare('SELECT name FROM sqlite_master WHERE type = ? AND name = ?').get('table', 'users'); expect(row).toBeTruthy(); } finally { @@ -60,6 +62,7 @@ describe('MaintenanceRepository', () => { // against another target path, succeeds identically. const directPath = path.join(dirWithQuote, 'snap-direct.db'); const escaped = directPath.replaceAll("'", "''"); + // test-sql-allow: the VACUUM INTO text itself is under test, run as the raw statement it renders to. expect(() => testDb.prepare(`VACUUM INTO '${escaped}'`).run()).not.toThrow(); expect(fs.existsSync(directPath)).toBe(true); fs.rmSync(directPath, { force: true }); diff --git a/server/tests/unit/db/repositories/NotificationChannelPreferences.repository.test.ts b/server/tests/unit/db/repositories/NotificationChannelPreferences.repository.test.ts index 7e62ba12c..bed4c273a 100644 --- a/server/tests/unit/db/repositories/NotificationChannelPreferences.repository.test.ts +++ b/server/tests/unit/db/repositories/NotificationChannelPreferences.repository.test.ts @@ -4,6 +4,7 @@ import { resetTestDb } from '../../../helpers/test-db'; import { createTestOrm, type TestOrm } from '../../../helpers/test-orm'; import { createUser, type TestUser } from '../../../helpers/factories'; import { NotificationChannelPreferences } from '../../../../src/db/entities/NotificationChannelPreferences.entity'; +import { countRows, findRow, insertRow } from '../../../helpers/factories/rows'; import type { NotificationChannelPreferencesRepository } from '../../../../src/db/repositories/NotificationChannelPreferences.repository'; const testDb = createSnapshotTestDb(); @@ -27,12 +28,18 @@ afterAll(async () => { testDb.close(); }); -function insertRaw(userId: number, eventType: string, channel: string, enabled: number): void { - testDb.prepare('INSERT INTO notification_channel_preferences (user_id, event_type, channel, enabled) VALUES (?, ?, ?, ?)').run(userId, eventType, channel, enabled); +async function insertRaw(userId: number, eventType: string, channel: string, enabled: number): Promise { + await insertRow(t, NotificationChannelPreferences, { user: userId, event_type: eventType, channel, enabled }); +} + +/** The stored preference row, or null. */ +function storedPref(userId: number, eventType: string, channel: string) { + return findRow(t, NotificationChannelPreferences, { user: userId, event_type: eventType, channel }); } describe('notification_channel_preferences — the (user_id, event_type, channel) composite PK the ON CONFLICT target relies on (R5/§14)', () => { it("NCPREPO-SCHEMA-001: PRAGMA table_info('notification_channel_preferences') reports a 3-column PRIMARY KEY over exactly (user_id, event_type, channel), matching the migration DDL", () => { + // test-sql-allow: schema introspection, which no entity or repository maps. const cols = testDb.prepare("PRAGMA table_info('notification_channel_preferences')").all() as { name: string; pk: number }[]; const pkCols = cols.filter((c) => c.pk > 0).sort((a, b) => a.pk - b.pk).map((c) => c.name); expect(pkCols).toEqual(['user_id', 'event_type', 'channel']); @@ -46,7 +53,8 @@ describe('notification_channel_preferences — the (user_id, event_type, channel describe('NotificationChannelPreferencesRepository — reads (NP2/NP3 parity)', () => { it('NCPREPO-001 — findEnabled matches `SELECT enabled FROM notification_channel_preferences WHERE user_id = ? AND event_type = ? AND channel = ?`, and is null for an absent row (default-enabled)', async () => { - insertRaw(user.id, 'trip_invite', 'email', 0); + await insertRaw(user.id, 'trip_invite', 'email', 0); + // test-sql-allow: the legacy statement is the oracle the repository read is held to. const legacy = testDb.prepare('SELECT enabled FROM notification_channel_preferences WHERE user_id = ? AND event_type = ? AND channel = ?').get(user.id, 'trip_invite', 'email') as { enabled: number }; const converted = await repo.findEnabled(user.id, 'trip_invite', 'email'); // A plain-object equality against the MikroORM-returned entity would also @@ -63,9 +71,10 @@ describe('NotificationChannelPreferencesRepository — reads (NP2/NP3 parity)', ['booking_change', 'email', 1], ['booking_change', 'ntfy', 0], ['vacay_invite', 'inapp', 0], ]; - for (const [event_type, channel, enabled] of combos) insertRaw(user.id, event_type, channel, enabled); - insertRaw(otherUser.id, 'trip_invite', 'email', 0); // a different user's row — must not leak in + for (const [event_type, channel, enabled] of combos) await insertRaw(user.id, event_type, channel, enabled); + await insertRaw(otherUser.id, 'trip_invite', 'email', 0); // a different user's row — must not leak in + // test-sql-allow: the legacy statement is the oracle the repository read is held to. const legacy = (testDb.prepare('SELECT event_type, channel, enabled FROM notification_channel_preferences WHERE user_id = ?').all(user.id) as { event_type: string; channel: string; enabled: number }[]) .map((r) => ({ event_type: r.event_type, channel: r.channel, enabled: r.enabled })) .sort((a, b) => a.event_type.localeCompare(b.event_type) || a.channel.localeCompare(b.channel)); @@ -87,7 +96,7 @@ describe('NotificationChannelPreferencesRepository — writes (NP5/NP6, R5)', () try { // Insert branch: no existing row. await repo.upsertPreference(user.id, 'trip_invite', 'email', 0); - expect(testDb.prepare('SELECT enabled FROM notification_channel_preferences WHERE user_id = ? AND event_type = ? AND channel = ?').get(user.id, 'trip_invite', 'email')).toEqual({ enabled: 0 }); + expect((await storedPref(user.id, 'trip_invite', 'email'))?.enabled).toBe(0); const insertSql = spy.mock.calls.map(([sql]) => sql).find((sql): sql is string => typeof sql === 'string' && /insert into/i.test(sql)); expect(insertSql).toContain('on conflict (`user_id`, `event_type`, `channel`)'); @@ -98,8 +107,8 @@ describe('NotificationChannelPreferencesRepository — writes (NP5/NP6, R5)', () // (there is no other non-key column REPLACE would reset that merge // does not also set). await repo.upsertPreference(user.id, 'trip_invite', 'email', 1); - expect(testDb.prepare('SELECT enabled FROM notification_channel_preferences WHERE user_id = ? AND event_type = ? AND channel = ?').get(user.id, 'trip_invite', 'email')).toEqual({ enabled: 1 }); - expect((testDb.prepare('SELECT COUNT(*) as c FROM notification_channel_preferences WHERE user_id = ? AND event_type = ? AND channel = ?').get(user.id, 'trip_invite', 'email') as { c: number }).c).toBe(1); + expect((await storedPref(user.id, 'trip_invite', 'email'))?.enabled).toBe(1); + expect(await countRows(t, NotificationChannelPreferences, { user: user.id, event_type: 'trip_invite', channel: 'email' })).toBe(1); const mergeSql = spy.mock.calls.map(([sql]) => sql).find((sql): sql is string => typeof sql === 'string' && /insert into/i.test(sql)); expect(mergeSql).toContain('on conflict (`user_id`, `event_type`, `channel`)'); @@ -110,14 +119,14 @@ describe('NotificationChannelPreferencesRepository — writes (NP5/NP6, R5)', () ); it('NCPREPO-003 — deletePreference matches `DELETE FROM notification_channel_preferences WHERE user_id = ? AND event_type = ? AND channel = ?`, scoped to exactly that triple', async () => { - insertRaw(user.id, 'trip_invite', 'email', 0); - insertRaw(user.id, 'trip_invite', 'webhook', 0); // a different channel — must survive - insertRaw(otherUser.id, 'trip_invite', 'email', 0); // a different user — must survive + await insertRaw(user.id, 'trip_invite', 'email', 0); + await insertRaw(user.id, 'trip_invite', 'webhook', 0); // a different channel — must survive + await insertRaw(otherUser.id, 'trip_invite', 'email', 0); // a different user — must survive expect(await repo.deletePreference(user.id, 'trip_invite', 'email')).toBe(1); - expect(testDb.prepare('SELECT * FROM notification_channel_preferences WHERE user_id = ? AND event_type = ? AND channel = ?').get(user.id, 'trip_invite', 'email')).toBeUndefined(); - expect(testDb.prepare('SELECT * FROM notification_channel_preferences WHERE user_id = ? AND event_type = ? AND channel = ?').get(user.id, 'trip_invite', 'webhook')).toBeDefined(); - expect(testDb.prepare('SELECT * FROM notification_channel_preferences WHERE user_id = ? AND event_type = ? AND channel = ?').get(otherUser.id, 'trip_invite', 'email')).toBeDefined(); + expect(await storedPref(user.id, 'trip_invite', 'email')).toBeNull(); + expect(await storedPref(user.id, 'trip_invite', 'webhook')).not.toBeNull(); + expect(await storedPref(otherUser.id, 'trip_invite', 'email')).not.toBeNull(); expect(await repo.deletePreference(user.id, 'trip_invite', 'email')).toBe(0); // already gone — no-op }); }); diff --git a/server/tests/unit/db/repositories/OauthClients.repository.test.ts b/server/tests/unit/db/repositories/OauthClients.repository.test.ts index 1f3a46155..b052c9aea 100644 --- a/server/tests/unit/db/repositories/OauthClients.repository.test.ts +++ b/server/tests/unit/db/repositories/OauthClients.repository.test.ts @@ -4,6 +4,7 @@ import { resetTestDb } from '../../../helpers/test-db'; import { createTestOrm, type TestOrm } from '../../../helpers/test-orm'; import { createUser } from '../../../helpers/factories'; import { OauthClients } from '../../../../src/db/entities/OauthClients.entity'; +import { deleteRows, findRow, insertRow, updateRows } from '../../../helpers/factories/rows'; import type { OauthClientsRepository } from '../../../../src/db/repositories/OauthClients.repository'; import { UnitOfWork } from '../../../../src/nest/database/unit-of-work'; import { withRequestContext } from '../../../../src/nest/database/request-context'; @@ -18,42 +19,44 @@ beforeAll(async () => { clients = t.repo(OauthClients); uow = new UnitOfWork(t.em); }); -beforeEach(() => { resetTestDb(testDb); t.clear(); testDb.exec('DELETE FROM oauth_clients'); }); +beforeEach(async () => { resetTestDb(testDb); t.clear(); await deleteRows(t, OauthClients); }); afterAll(async () => { await t.close(); testDb.close(); }); -function seedClient( +async function seedClient( overrides: Partial<{ id: string; userId: number | null; name: string; clientId: string; secretHash: string; redirectUris: string; allowedScopes: string; isPublic: number; createdVia: string; allowsClientCredentials: number }> = {}, -): { id: string; userId: number | null; clientId: string } { +): Promise<{ id: string; userId: number | null; clientId: string }> { const id = overrides.id ?? `row-${Math.random().toString(36).slice(2)}`; const clientId = overrides.clientId ?? `proto-${Math.random().toString(36).slice(2)}`; - testDb.prepare( - `INSERT INTO oauth_clients (id, user_id, name, client_id, client_secret_hash, redirect_uris, allowed_scopes, is_public, created_via, allows_client_credentials) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - ).run( + await insertRow(t, OauthClients, { id, - overrides.userId ?? null, - overrides.name ?? 'Test Client', - clientId, - overrides.secretHash ?? 'hash-value', - overrides.redirectUris ?? '["https://example.com/cb"]', - overrides.allowedScopes ?? '["trips:read"]', - overrides.isPublic ?? 0, - overrides.createdVia ?? 'settings_ui', - overrides.allowsClientCredentials ?? 0, - ); + user: overrides.userId ?? null, + name: overrides.name ?? 'Test Client', + client_id: clientId, + client_secret_hash: overrides.secretHash ?? 'hash-value', + redirect_uris: overrides.redirectUris ?? '["https://example.com/cb"]', + allowed_scopes: overrides.allowedScopes ?? '["trips:read"]', + is_public: overrides.isPublic ?? 0, + created_via: overrides.createdVia ?? 'settings_ui', + allows_client_credentials: overrides.allowsClientCredentials ?? 0, + }); return { id, userId: overrides.userId ?? null, clientId }; } +/** The stored row, client_secret_hash included, which no repository read returns. */ +function storedClient(id: string) { + return findRow(t, OauthClients, { id }); +} + describe('OauthClientsRepository', () => { describe('listByUser (OA1)', () => { it('OAUTHCLIENTREPO-001: returns the caller\'s clients only, newest first, without the secret hash', async () => { const { user } = createUser(testDb); const { user: other } = createUser(testDb); - seedClient({ userId: user.id, name: 'first', createdVia: 'settings_ui' }); + await seedClient({ userId: user.id, name: 'first', createdVia: 'settings_ui' }); testDb.exec("UPDATE oauth_clients SET created_at = '2020-01-01 00:00:00' WHERE name = 'first'"); - seedClient({ userId: user.id, name: 'second' }); + await seedClient({ userId: user.id, name: 'second' }); testDb.exec("UPDATE oauth_clients SET created_at = '2021-01-01 00:00:00' WHERE name = 'second'"); - seedClient({ userId: other.id, name: 'not-mine' }); + await seedClient({ userId: other.id, name: 'not-mine' }); const rows = await clients.listByUser(user.id); expect(rows.map((r) => r.name)).toEqual(['second', 'first']); @@ -68,8 +71,8 @@ describe('OauthClientsRepository', () => { it('OAUTHCLIENTREPO-002b: created_at NULL comes back null, not undefined (coverage: rule 16)', async () => { const { user } = createUser(testDb); - const { id } = seedClient({ userId: user.id, name: 'null-created' }); - testDb.prepare('UPDATE oauth_clients SET created_at = NULL WHERE id = ?').run(id); + const { id } = await seedClient({ userId: user.id, name: 'null-created' }); + await updateRows(t, OauthClients, { id }, { created_at: null }); const rows = await clients.listByUser(user.id); expect(rows[0].created_at).toBeNull(); }); @@ -79,18 +82,18 @@ describe('OauthClientsRepository', () => { it('OAUTHCLIENTREPO-003: countByUser only counts the given user\'s rows', async () => { const { user } = createUser(testDb); const { user: other } = createUser(testDb); - seedClient({ userId: user.id }); - seedClient({ userId: user.id }); - seedClient({ userId: other.id }); + await seedClient({ userId: user.id }); + await seedClient({ userId: user.id }); + await seedClient({ userId: other.id }); expect(await clients.countByUser(user.id)).toBe(2); expect(await clients.countByUser(other.id)).toBe(1); }); it('OAUTHCLIENTREPO-004: countAnonymous counts only NULL-user rows', async () => { const { user } = createUser(testDb); - seedClient({ userId: user.id }); - seedClient({ userId: null }); - seedClient({ userId: null }); + await seedClient({ userId: user.id }); + await seedClient({ userId: null }); + await seedClient({ userId: null }); expect(await clients.countAnonymous()).toBe(2); }); }); @@ -121,8 +124,8 @@ describe('OauthClientsRepository', () => { created_via: 'settings_ui', allows_client_credentials: 0, }); - const raw = testDb.prepare('SELECT * FROM oauth_clients WHERE id = ?').get('client-row-1') as Record; - expect(raw.client_secret_hash).toBe('secrethash'); + const raw = await storedClient('client-row-1'); + expect(raw?.client_secret_hash).toBe('secrethash'); }); it('OAUTHCLIENTREPO-005b: findPublicById is null for an unknown id', async () => { @@ -130,8 +133,8 @@ describe('OauthClientsRepository', () => { }); it('OAUTHCLIENTREPO-005c: findPublicById — created_at NULL comes back null, not undefined (coverage: rule 16)', async () => { - const { id } = seedClient({ name: 'null-created-public' }); - testDb.prepare('UPDATE oauth_clients SET created_at = NULL WHERE id = ?').run(id); + const { id } = await seedClient({ name: 'null-created-public' }); + await updateRows(t, OauthClients, { id }, { created_at: null }); const row = await clients.findPublicById(id); expect(row?.created_at).toBeNull(); }); @@ -173,7 +176,7 @@ describe('OauthClientsRepository', () => { describe('findOwned (OA6/OA9)', () => { it('OAUTHCLIENTREPO-007: matches id+user, returns the superset projection', async () => { const { user } = createUser(testDb); - const { id, clientId } = seedClient({ userId: user.id, isPublic: 1 }); + const { id, clientId } = await seedClient({ userId: user.id, isPublic: 1 }); const row = await clients.findOwned(id, user.id); expect(row).toEqual({ id, client_id: clientId, is_public: 1 }); }); @@ -181,31 +184,31 @@ describe('OauthClientsRepository', () => { it('OAUTHCLIENTREPO-008: 404-shape null for another user\'s client (never 403)', async () => { const { user } = createUser(testDb); const { user: other } = createUser(testDb); - const { id } = seedClient({ userId: other.id }); + const { id } = await seedClient({ userId: other.id }); expect(await clients.findOwned(id, user.id)).toBeNull(); }); }); describe('updateSecretHash (OA7)', () => { it('OAUTHCLIENTREPO-009: writes the new hash, nothing else', async () => { - const { id } = seedClient({ secretHash: 'old' }); + const { id } = await seedClient({ secretHash: 'old' }); await clients.updateSecretHash(id, 'new-hash'); - const row = testDb.prepare('SELECT client_secret_hash FROM oauth_clients WHERE id = ?').get(id) as { client_secret_hash: string }; - expect(row.client_secret_hash).toBe('new-hash'); + const row = await storedClient(id); + expect(row?.client_secret_hash).toBe('new-hash'); }); }); describe('remove (OA10)', () => { it('OAUTHCLIENTREPO-010: deletes the row', async () => { - const { id } = seedClient(); + const { id } = await seedClient(); await clients.remove(id); - expect(testDb.prepare('SELECT id FROM oauth_clients WHERE id = ?').get(id)).toBeUndefined(); + expect(await storedClient(id)).toBeNull(); }); }); describe('findSdkProjection (OA15)', () => { it('OAUTHCLIENTREPO-011: never carries client_secret_hash', async () => { - const { clientId } = seedClient({ secretHash: 'super-secret', name: 'SDK Client' }); + const { clientId } = await seedClient({ secretHash: 'super-secret', name: 'SDK Client' }); const row = await clients.findSdkProjection(clientId); expect(row).toEqual({ client_id: clientId, @@ -225,7 +228,7 @@ describe('OauthClientsRepository', () => { describe('findAuthRow (OA21)', () => { it('OAUTHCLIENTREPO-013: exactly the three auth columns, a distinct projection from OA15', async () => { - const { clientId } = seedClient({ secretHash: 'auth-secret', isPublic: 0 }); + const { clientId } = await seedClient({ secretHash: 'auth-secret', isPublic: 0 }); const row = await clients.findAuthRow(clientId); expect(row).toEqual({ client_id: clientId, client_secret_hash: 'auth-secret', is_public: 0 }); }); @@ -238,7 +241,7 @@ describe('OauthClientsRepository', () => { describe('findByClientIdFull (OA29/OA30)', () => { it('OAUTHCLIENTREPO-014: the full row, including the secret hash', async () => { const { user } = createUser(testDb); - const { clientId } = seedClient({ userId: user.id, secretHash: 'full-row-secret' }); + const { clientId } = await seedClient({ userId: user.id, secretHash: 'full-row-secret' }); const row = await clients.findByClientIdFull(clientId); expect(row?.client_secret_hash).toBe('full-row-secret'); expect(row?.user_id).toBe(user.id); @@ -263,7 +266,7 @@ describe('OauthClientsRepository', () => { // load-bearing in the task report. it('OAUTHCLIENTREPO-016: findAuthRow then findOwned then updateSecretHash inside uow.transactional — the write survives', async () => { const { user } = createUser(testDb); - const { id, clientId } = seedClient({ userId: user.id, secretHash: 'original', name: 'kept-name' }); + const { id, clientId } = await seedClient({ userId: user.id, secretHash: 'original', name: 'kept-name' }); await withRequestContext(t.orm, async () => { // Projection A — wide, carries client_secret_hash. @@ -276,9 +279,9 @@ describe('OauthClientsRepository', () => { }); }); - const row = testDb.prepare('SELECT client_secret_hash, name FROM oauth_clients WHERE id = ?').get(id) as { client_secret_hash: string; name: string }; - expect(row.client_secret_hash).toBe('rotated'); // the nativeUpdate must stick - expect(row.name).toBe('kept-name'); // untouched by either read + const row = await storedClient(id); + expect(row?.client_secret_hash).toBe('rotated'); // the nativeUpdate must stick + expect(row?.name).toBe('kept-name'); // untouched by either read }); }); }); diff --git a/server/tests/unit/db/repositories/OauthConsents.repository.test.ts b/server/tests/unit/db/repositories/OauthConsents.repository.test.ts index 6bbdcfa3f..4e53e5887 100644 --- a/server/tests/unit/db/repositories/OauthConsents.repository.test.ts +++ b/server/tests/unit/db/repositories/OauthConsents.repository.test.ts @@ -4,6 +4,8 @@ import { resetTestDb } from '../../../helpers/test-db'; import { createTestOrm, type TestOrm } from '../../../helpers/test-orm'; import { createUser } from '../../../helpers/factories'; import { OauthConsents } from '../../../../src/db/entities/OauthConsents.entity'; +import { OauthClients } from '../../../../src/db/entities/OauthClients.entity'; +import { findRow, findRows, insertRow, updateRows } from '../../../helpers/factories/rows'; import type { OauthConsentsRepository } from '../../../../src/db/repositories/OauthConsents.repository'; import { UnitOfWork } from '../../../../src/nest/database/unit-of-work'; import { withRequestContext } from '../../../../src/nest/database/request-context'; @@ -21,24 +23,35 @@ beforeAll(async () => { beforeEach(() => { resetTestDb(testDb); t.clear(); testDb.exec('DELETE FROM oauth_consents'); testDb.exec('DELETE FROM oauth_clients'); }); afterAll(async () => { await t.close(); testDb.close(); }); -function seedClient(userId: number, clientId: string): void { - testDb.prepare( - `INSERT INTO oauth_clients (id, user_id, name, client_id, client_secret_hash, redirect_uris, allowed_scopes) VALUES (?, ?, ?, ?, ?, '[]', '[]')`, - ).run(`row-${clientId}`, userId, 'C', clientId, 'hash'); +async function seedClient(userId: number, clientId: string): Promise { + await insertRow(t, OauthClients, { + id: `row-${clientId}`, + user: userId, + name: 'C', + client_id: clientId, + client_secret_hash: 'hash', + redirect_uris: '[]', + allowed_scopes: '[]', + }); +} + +/** The stored consent of `userId` for `clientId`, or null. */ +function storedConsent(clientId: string, userId: number) { + return findRow(t, OauthConsents, { client: clientId, user: userId }); } describe('OauthConsentsRepository', () => { describe('findScopes (OA11)', () => { it('OAUTHCONSENTREPO-001: null when no consent has been granted', async () => { const { user } = createUser(testDb); - seedClient(user.id, 'proto-1'); + await seedClient(user.id, 'proto-1'); expect(await consents.findScopes('proto-1', user.id)).toBeNull(); }); it('OAUTHCONSENTREPO-002: returns the stored scopes JSON text', async () => { const { user } = createUser(testDb); - seedClient(user.id, 'proto-2'); - testDb.prepare(`INSERT INTO oauth_consents (client_id, user_id, scopes) VALUES (?, ?, ?)`).run('proto-2', user.id, '["trips:read"]'); + await seedClient(user.id, 'proto-2'); + await insertRow(t, OauthConsents, { client: 'proto-2', user: user.id, scopes: '["trips:read"]' }); expect(await consents.findScopes('proto-2', user.id)).toEqual({ scopes: '["trips:read"]' }); }); }); @@ -46,38 +59,42 @@ describe('OauthConsentsRepository', () => { describe('upsertGrant (OA12)', () => { it('OAUTHCONSENTREPO-003: inserts a new row exactly as the legacy INSERT would', async () => { const { user } = createUser(testDb); - seedClient(user.id, 'proto-3'); + await seedClient(user.id, 'proto-3'); await consents.upsertGrant('proto-3', user.id, '["a"]'); - const row = testDb.prepare('SELECT client_id, user_id, scopes FROM oauth_consents WHERE client_id = ? AND user_id = ?').get('proto-3', user.id); - expect(row).toEqual({ client_id: 'proto-3', user_id: user.id, scopes: '["a"]' }); + const row = await storedConsent('proto-3', user.id); + expect({ client_id: row?.client_id, user_id: row?.user_id, scopes: row?.scopes }).toEqual({ + client_id: 'proto-3', + user_id: user.id, + scopes: '["a"]', + }); }); it('OAUTHCONSENTREPO-004: on an existing (client, user) it replaces the row in place — no duplicate, matching ON CONFLICT(client_id, user_id) DO UPDATE', async () => { const { user } = createUser(testDb); - seedClient(user.id, 'proto-4'); + await seedClient(user.id, 'proto-4'); await consents.upsertGrant('proto-4', user.id, '["a"]'); await consents.upsertGrant('proto-4', user.id, '["a","b"]'); - const rows = testDb.prepare('SELECT scopes FROM oauth_consents WHERE client_id = ? AND user_id = ?').all('proto-4', user.id); + const rows = await findRows(t, OauthConsents, { client: 'proto-4', user: user.id }); expect(rows).toHaveLength(1); - expect((rows[0] as { scopes: string }).scopes).toBe('["a","b"]'); + expect(rows[0].scopes).toBe('["a","b"]'); }); it('OAUTHCONSENTREPO-005: updated_at is bumped on every call, insert or merge alike (unconditional, matching INSERT OR REPLACE)', async () => { const { user } = createUser(testDb); - seedClient(user.id, 'proto-5'); + await seedClient(user.id, 'proto-5'); await consents.upsertGrant('proto-5', user.id, '["a"]'); - const first = testDb.prepare('SELECT updated_at FROM oauth_consents WHERE client_id = ? AND user_id = ?').get('proto-5', user.id) as { updated_at: string }; - testDb.exec(`UPDATE oauth_consents SET updated_at = '2000-01-01 00:00:00' WHERE client_id = 'proto-5'`); + const first = await storedConsent('proto-5', user.id); + await updateRows(t, OauthConsents, { client: 'proto-5' }, { updated_at: '2000-01-01 00:00:00' }); await consents.upsertGrant('proto-5', user.id, '["a","b"]'); - const second = testDb.prepare('SELECT updated_at FROM oauth_consents WHERE client_id = ? AND user_id = ?').get('proto-5', user.id) as { updated_at: string }; - expect(second.updated_at).not.toBe('2000-01-01 00:00:00'); - expect(first.updated_at).toBeTruthy(); + const second = await storedConsent('proto-5', user.id); + expect(second?.updated_at).not.toBe('2000-01-01 00:00:00'); + expect(first?.updated_at).toBeTruthy(); }); it('OAUTHCONSENTREPO-006: the same client scoped by different users stores two separate rows, not a collision', async () => { const { user: a } = createUser(testDb); const { user: b } = createUser(testDb); - seedClient(a.id, 'proto-6'); + await seedClient(a.id, 'proto-6'); await consents.upsertGrant('proto-6', a.id, '["a"]'); await consents.upsertGrant('proto-6', b.id, '["b"]'); expect(await consents.findScopes('proto-6', a.id)).toEqual({ scopes: '["a"]' }); @@ -99,8 +116,8 @@ describe('OauthConsentsRepository', () => { describe('findScopes does not dirty the entity it reads', () => { it('OAUTHCONSENTREPO-007: findScopes then a raw write on the same transaction connection — the raw write survives the transaction\'s closing flush', async () => { const { user } = createUser(testDb); - seedClient(user.id, 'proto-7'); - testDb.prepare(`INSERT INTO oauth_consents (client_id, user_id, scopes) VALUES (?, ?, ?)`).run('proto-7', user.id, '["a"]'); + await seedClient(user.id, 'proto-7'); + await insertRow(t, OauthConsents, { client: 'proto-7', user: user.id, scopes: '["a"]' }); await withRequestContext(t.orm, async () => { await uow.transactional(async () => { @@ -112,12 +129,13 @@ describe('OauthConsentsRepository', () => { // A write bypassing the ORM entirely, on the SAME single SQLite // connection the open transaction holds (D3/D6) — the shape any // nativeUpdate write from elsewhere in the same request takes. + // test-sql-allow: the write has to run on the connection the open transaction holds, past the ORM. testDb.prepare('UPDATE oauth_consents SET scopes = ? WHERE client_id = ? AND user_id = ?').run('["a","b"]', 'proto-7', user.id); }); }); - const row = testDb.prepare('SELECT scopes FROM oauth_consents WHERE client_id = ? AND user_id = ?').get('proto-7', user.id) as { scopes: string }; - expect(row.scopes).toBe('["a","b"]'); + const row = await storedConsent('proto-7', user.id); + expect(row?.scopes).toBe('["a","b"]'); }); }); }); diff --git a/server/tests/unit/db/repositories/PackingItems.repository.test.ts b/server/tests/unit/db/repositories/PackingItems.repository.test.ts index 400ae7b16..4be3e196a 100644 --- a/server/tests/unit/db/repositories/PackingItems.repository.test.ts +++ b/server/tests/unit/db/repositories/PackingItems.repository.test.ts @@ -15,18 +15,28 @@ import { createSnapshotTestDb } from '../../../helpers/db-mock'; import { resetTestDb } from '../../../helpers/test-db'; import { createPackingItem, createTrip, createUser } from '../../../helpers/factories'; import { createTestPackingItemsRepo } from '../../../helpers/packing-repos'; +import { createTestOrm, type TestOrm } from '../../../helpers/test-orm'; +import { updateRows } from '../../../helpers/factories/rows'; +import { addPackingItemRecipients } from '../../../helpers/factories/packing'; +import { PackingItems } from '../../../../src/db/entities/PackingItems.entity'; import type { PackingItemsRepository } from '../../../../src/db/repositories/PackingItems.repository'; const testDb = createSnapshotTestDb(); let packingItemsRepo: PackingItemsRepository; +let t: TestOrm; beforeAll(async () => { packingItemsRepo = await createTestPackingItemsRepo(testDb); + t = await createTestOrm(testDb); }); beforeEach(() => resetTestDb(testDb)); -afterAll(() => testDb.close()); +afterAll(async () => { + await t.close(); + testDb.close(); +}); function legacyPublicForShare(tripId: number): unknown { + // test-sql-allow: the legacy statement is the oracle the repository read is held to. return testDb.prepare( 'SELECT * FROM packing_items WHERE trip_id = ? AND is_private = 0 ORDER BY sort_order ASC', ).all(tripId); @@ -40,30 +50,41 @@ describe('PackingItemsRepository — share.service.ts SH13 read', () => { // Common, every nullable column set. const commonFull = createPackingItem(testDb, trip.id, { name: 'Tent', category: 'Gear' }); - testDb.prepare(` - UPDATE packing_items SET checked = 1, sort_order = 5, weight_grams = 1200, bag_id = NULL, - quantity = 2, updated_at = '2026-09-01T00:00:00.000Z', is_private = 0, owner_id = ? - WHERE id = ?`).run(owner.id, commonFull.id); + await updateRows(t, PackingItems, { id: commonFull.id }, { + checked: 1, + sort_order: 5, + weight_grams: 1200, + bag: null, + quantity: 2, + updated_at: '2026-09-01T00:00:00.000Z', + is_private: 0, + owner: owner.id, + }); // Common, every nullable column left null. const commonBare = createPackingItem(testDb, trip.id, { name: 'Rope' }); - testDb.prepare(` - UPDATE packing_items SET category = NULL, sort_order = NULL, weight_grams = NULL, - bag_id = NULL, updated_at = NULL, is_private = 0, owner_id = NULL - WHERE id = ?`).run(commonBare.id); + await updateRows(t, PackingItems, { id: commonBare.id }, { + category: null, + sort_order: null, + weight_grams: null, + bag: null, + updated_at: null, + is_private: 0, + owner: null, + }); // Personal (is_private = 1, owned) — must be excluded even though it has an owner. const personal = createPackingItem(testDb, trip.id, { name: 'Passport' }); - testDb.prepare('UPDATE packing_items SET is_private = 1, owner_id = ? WHERE id = ?').run(owner.id, personal.id); + await updateRows(t, PackingItems, { id: personal.id }, { is_private: 1, owner: owner.id }); // Shared-with-people (is_private = 1, has a recipient) — must be excluded too. const shared = createPackingItem(testDb, trip.id, { name: 'First aid kit' }); - testDb.prepare('UPDATE packing_items SET is_private = 1, owner_id = ? WHERE id = ?').run(owner.id, shared.id); - testDb.prepare('INSERT INTO packing_item_recipients (item_id, user_id) VALUES (?, ?)').run(shared.id, owner.id); + await updateRows(t, PackingItems, { id: shared.id }, { is_private: 1, owner: owner.id }); + await addPackingItemRecipients(t, shared.id, [owner.id]); // A Common item on a different trip must never leak in. const foreign = createPackingItem(testDb, other.id, { name: 'Foreign item' }); - testDb.prepare('UPDATE packing_items SET is_private = 0 WHERE id = ?').run(foreign.id); + await updateRows(t, PackingItems, { id: foreign.id }, { is_private: 0 }); const legacy = legacyPublicForShare(trip.id); const typed = await packingItemsRepo.listPublicForShare(trip.id); @@ -76,7 +97,7 @@ describe('PackingItemsRepository — share.service.ts SH13 read', () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); const personalOnly = createPackingItem(testDb, trip.id); - testDb.prepare('UPDATE packing_items SET is_private = 1 WHERE id = ?').run(personalOnly.id); + await updateRows(t, PackingItems, { id: personalOnly.id }, { is_private: 1 }); expect(await packingItemsRepo.listPublicForShare(trip.id)).toEqual([]); }); }); diff --git a/server/tests/unit/db/repositories/ReservationDayPositions.repository.test.ts b/server/tests/unit/db/repositories/ReservationDayPositions.repository.test.ts index 5d97c365f..0af50cdbd 100644 --- a/server/tests/unit/db/repositories/ReservationDayPositions.repository.test.ts +++ b/server/tests/unit/db/repositories/ReservationDayPositions.repository.test.ts @@ -11,6 +11,7 @@ import { resetTestDb } from '../../../helpers/test-db'; import { createTestOrm, type TestOrm } from '../../../helpers/test-orm'; import { createDay, createReservation, createTrip, createUser } from '../../../helpers/factories'; import { ReservationDayPositions } from '../../../../src/db/entities/ReservationDayPositions.entity'; +import { insertRow } from '../../../helpers/factories/rows'; import type { ReservationDayPositionsRepository } from '../../../../src/db/repositories/ReservationDayPositions.repository'; const testDb = createSnapshotTestDb(); @@ -25,25 +26,26 @@ beforeEach(() => { resetTestDb(testDb); t.clear(); }); afterAll(async () => { await t.close(); testDb.close(); }); describe('ReservationDayPositionsRepository — fully seeded world', () => { - const seed = () => { + const seed = async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); const day1 = createDay(testDb, trip.id); const day2 = createDay(testDb, trip.id); const multiDay = createReservation(testDb, trip.id, { title: 'Rental car', type: 'car' }); - testDb.prepare('INSERT INTO reservation_day_positions (reservation_id, day_id, position) VALUES (?, ?, ?)').run(multiDay.id, day1.id, 0); - testDb.prepare('INSERT INTO reservation_day_positions (reservation_id, day_id, position) VALUES (?, ?, ?)').run(multiDay.id, day2.id, 1); + await insertRow(t, ReservationDayPositions, { reservation: multiDay.id, day: day1.id, position: 0 }); + await insertRow(t, ReservationDayPositions, { reservation: multiDay.id, day: day2.id, position: 1 }); const otherTrip = createTrip(testDb, user.id); const otherDay = createDay(testDb, otherTrip.id); const foreignRes = createReservation(testDb, otherTrip.id, { title: 'Foreign rental', type: 'car' }); - testDb.prepare('INSERT INTO reservation_day_positions (reservation_id, day_id, position) VALUES (?, ?, ?)').run(foreignRes.id, otherDay.id, 0); + await insertRow(t, ReservationDayPositions, { reservation: foreignRes.id, day: otherDay.id, position: 0 }); return { trip, otherTrip, multiDay, day1, day2 }; }; it('RS19 listForTrip — matches the legacy JOIN statement, scoped by trip, both persist(false) mirror columns present', async () => { - const { trip, multiDay, day1, day2 } = seed(); + const { trip, multiDay, day1, day2 } = await seed(); + // test-sql-allow: the legacy statement is the oracle the repository read is held to. const legacy = testDb.prepare(` SELECT rdp.reservation_id, rdp.day_id, rdp.position FROM reservation_day_positions rdp JOIN reservations r ON rdp.reservation_id = r.id WHERE r.trip_id = ?`).all(trip.id); diff --git a/server/tests/unit/db/repositories/ReservationEndpoints.repository.test.ts b/server/tests/unit/db/repositories/ReservationEndpoints.repository.test.ts index 3eba3ae6a..41947a5cc 100644 --- a/server/tests/unit/db/repositories/ReservationEndpoints.repository.test.ts +++ b/server/tests/unit/db/repositories/ReservationEndpoints.repository.test.ts @@ -10,6 +10,7 @@ import { resetTestDb } from '../../../helpers/test-db'; import { createTestOrm, type TestOrm } from '../../../helpers/test-orm'; import { createReservation, createTrip, createUser } from '../../../helpers/factories'; import { ReservationEndpoints } from '../../../../src/db/entities/ReservationEndpoints.entity'; +import { insertRow } from '../../../helpers/factories/rows'; import type { ReservationEndpointsRepository } from '../../../../src/db/repositories/ReservationEndpoints.repository'; const testDb = createSnapshotTestDb(); @@ -23,40 +24,46 @@ beforeAll(async () => { beforeEach(() => { resetTestDb(testDb); t.clear(); }); afterAll(async () => { await t.close(); testDb.close(); }); -const insertEndpoint = ( +const insertEndpoint = async ( reservationId: number, role: string, sequence: number, overrides: Partial<{ name: string; code: string | null; lat: number; lng: number; timezone: string | null; local_time: string | null; local_date: string | null }> = {}, ) => { - testDb.prepare(` - INSERT INTO reservation_endpoints (reservation_id, role, sequence, name, code, lat, lng, timezone, local_time, local_date) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run( - reservationId, role, sequence, - overrides.name ?? `${role} endpoint`, overrides.code ?? null, overrides.lat ?? 48.0, overrides.lng ?? 11.0, - overrides.timezone ?? null, overrides.local_time ?? null, overrides.local_date ?? null, - ); + await insertRow(t, ReservationEndpoints, { + reservation: reservationId, + role, + sequence, + name: overrides.name ?? `${role} endpoint`, + code: overrides.code ?? null, + lat: overrides.lat ?? 48.0, + lng: overrides.lng ?? 11.0, + timezone: overrides.timezone ?? null, + local_time: overrides.local_time ?? null, + local_date: overrides.local_date ?? null, + }); }; describe('ReservationEndpointsRepository — fully seeded world', () => { - const seed = () => { + const seed = async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); const flight = createReservation(testDb, trip.id, { title: 'Flight', type: 'flight' }); const otherFlight = createReservation(testDb, trip.id, { title: 'Return flight', type: 'flight' }); - insertEndpoint(flight.id, 'from', 0, { name: 'MUC', code: 'MUC', local_date: '2026-09-01' }); - insertEndpoint(flight.id, 'to', 1, { name: 'JFK', code: 'JFK', local_date: '2026-09-01' }); - insertEndpoint(otherFlight.id, 'from', 0, { name: 'JFK', code: 'JFK' }); + await insertEndpoint(flight.id, 'from', 0, { name: 'MUC', code: 'MUC', local_date: '2026-09-01' }); + await insertEndpoint(flight.id, 'to', 1, { name: 'JFK', code: 'JFK', local_date: '2026-09-01' }); + await insertEndpoint(otherFlight.id, 'from', 0, { name: 'JFK', code: 'JFK' }); const otherTrip = createTrip(testDb, user.id); const foreignFlight = createReservation(testDb, otherTrip.id, { title: 'Foreign', type: 'flight' }); - insertEndpoint(foreignFlight.id, 'from', 0, { name: 'LHR', code: 'LHR' }); + await insertEndpoint(foreignFlight.id, 'from', 0, { name: 'LHR', code: 'LHR' }); return { trip, otherTrip, flight, otherFlight, foreignFlight }; }; it('RS3 listForTrip — matches the legacy JOIN reservations statement, ordered by reservation then sequence, scoped by trip', async () => { - const { trip, flight, otherFlight } = seed(); + const { trip, flight, otherFlight } = await seed(); + // test-sql-allow: the legacy statement is the oracle the repository read is held to. const legacy = testDb.prepare(` SELECT e.* FROM reservation_endpoints e JOIN reservations r ON e.reservation_id = r.id WHERE r.trip_id = ? ORDER BY e.reservation_id, e.sequence`).all(trip.id); @@ -68,7 +75,8 @@ describe('ReservationEndpointsRepository — fully seeded world', () => { }); it('RR2 listForReservation — matches the legacy statement, ordered by sequence, scoped to one reservation', async () => { - const { flight } = seed(); + const { flight } = await seed(); + // test-sql-allow: the legacy statement is the oracle the repository read is held to. const legacy = testDb.prepare('SELECT * FROM reservation_endpoints WHERE reservation_id = ? ORDER BY sequence').all(flight.id); const typed = await repo.listForReservation(flight.id); expect(typed).toEqual(legacy); @@ -76,7 +84,8 @@ describe('ReservationEndpointsRepository — fully seeded world', () => { }); it('DY17 listIdAndDate — matches the legacy statement', async () => { - const { flight } = seed(); + const { flight } = await seed(); + // test-sql-allow: the legacy statement is the oracle the repository read is held to. const legacy = testDb.prepare('SELECT id, local_date FROM reservation_endpoints WHERE reservation_id = ?').all(flight.id); const typed = await repo.listIdAndDate(flight.id); expect(typed).toEqual(legacy); @@ -84,7 +93,8 @@ describe('ReservationEndpointsRepository — fully seeded world', () => { }); it('RPL6 listRoadtripTerminals — matches the legacy JOIN reservations statement, ordered by reservation then sequence, scoped by trip', async () => { - const { trip, flight, otherFlight } = seed(); + const { trip, flight, otherFlight } = await seed(); + // test-sql-allow: the legacy statement is the oracle the repository read is held to. const legacy = testDb.prepare(` SELECT e.reservation_id, e.role, e.sequence, e.name, e.code, e.lat, e.lng FROM reservation_endpoints e JOIN reservations r ON r.id = e.reservation_id WHERE r.trip_id = ? ORDER BY e.reservation_id, e.sequence`).all(trip.id); diff --git a/server/tests/unit/db/repositories/TripInviteTokens.repository.test.ts b/server/tests/unit/db/repositories/TripInviteTokens.repository.test.ts index e181580d5..2ffe59377 100644 --- a/server/tests/unit/db/repositories/TripInviteTokens.repository.test.ts +++ b/server/tests/unit/db/repositories/TripInviteTokens.repository.test.ts @@ -4,6 +4,7 @@ import { resetTestDb } from '../../../helpers/test-db'; import { createTestOrm, type TestOrm } from '../../../helpers/test-orm'; import { createTrip, createUser } from '../../../helpers/factories'; import { TripInviteTokens } from '../../../../src/db/entities/TripInviteTokens.entity'; +import { countRows, findRow, insertRow } from '../../../helpers/factories/rows'; import type { TripInviteTokensRepository } from '../../../../src/db/repositories/TripInviteTokens.repository'; const testDb = createSnapshotTestDb(); @@ -17,6 +18,13 @@ beforeAll(async () => { beforeEach(() => { resetTestDb(testDb); t.clear(); }); afterAll(async () => { await t.close(); testDb.close(); }); +/** The trip's stored invite row as it is now; fails the case when there is none. */ +async function storedToken(tripId: number) { + const row = await findRow(t, TripInviteTokens, { trip: tripId }); + if (!row) throw new Error(`no invite token for trip ${tripId}`); + return row; +} + describe('TripInviteTokensRepository.findInfoByTrip / existsForTrip (Plan 4 Task 1)', () => { it('TIREPO-001: findInfoByTrip returns token/expires_at/created_at only, undefined when no row', async () => { const { user } = createUser(testDb); @@ -24,8 +32,9 @@ describe('TripInviteTokensRepository.findInfoByTrip / existsForTrip (Plan 4 Task expect(await tripInviteTokens.findInfoByTrip(trip.id)).toBeUndefined(); expect(await tripInviteTokens.existsForTrip(trip.id)).toBe(false); - testDb.prepare("INSERT INTO trip_invite_tokens (trip_id, token, created_by, expires_at) VALUES (?, 'tok', ?, NULL)").run(trip.id, user.id); + await insertRow(t, TripInviteTokens, { trip: trip.id, token: 'tok', createdByRef: user.id, expires_at: null }); + // test-sql-allow: the legacy statement is the oracle the repository read is held to. const legacy = testDb.prepare('SELECT token, expires_at, created_at FROM trip_invite_tokens WHERE trip_id = ?').get(trip.id); expect(await tripInviteTokens.findInfoByTrip(trip.id)).toEqual(legacy); expect(await tripInviteTokens.existsForTrip(trip.id)).toBe(true); @@ -39,9 +48,14 @@ describe('TripInviteTokensRepository.insertForTrip / updateForTrip (Plan 4 Task await tripInviteTokens.insertForTrip({ trip_id: trip.id, token: 'abc', created_by: user.id, expires_at: null }); - const row = testDb.prepare('SELECT trip_id, token, created_by, expires_at FROM trip_invite_tokens WHERE trip_id = ?').get(trip.id); - expect(row).toEqual({ trip_id: trip.id, token: 'abc', created_by: user.id, expires_at: null }); - expect((testDb.prepare('SELECT created_at FROM trip_invite_tokens WHERE trip_id = ?').get(trip.id) as { created_at: string }).created_at).toBeTruthy(); + const row = await storedToken(trip.id); + expect({ trip_id: row.trip_id, token: row.token, created_by: row.created_by, expires_at: row.expires_at }).toEqual({ + trip_id: trip.id, + token: 'abc', + created_by: user.id, + expires_at: null, + }); + expect(row.created_at).toBeTruthy(); }); it('TIREPO-003: updateForTrip rewrites token/expires_at/created_by AND bumps created_at, on the SAME row (single row per trip)', async () => { @@ -49,18 +63,16 @@ describe('TripInviteTokensRepository.insertForTrip / updateForTrip (Plan 4 Task const { user: rotator } = createUser(testDb, { username: 'rotator' }); const trip = createTrip(testDb, owner.id); await tripInviteTokens.insertForTrip({ trip_id: trip.id, token: 'first', created_by: owner.id, expires_at: null }); - const before = testDb.prepare('SELECT id, created_at FROM trip_invite_tokens WHERE trip_id = ?').get(trip.id) as { id: number; created_at: string }; + const before = await storedToken(trip.id); await tripInviteTokens.updateForTrip(trip.id, { token: 'second', expires_at: '2030-01-01T00:00:00.000Z', created_by: rotator.id }); - const after = testDb.prepare('SELECT id, token, expires_at, created_by, created_at FROM trip_invite_tokens WHERE trip_id = ?').get(trip.id) as { - id: number; token: string; expires_at: string; created_by: number; created_at: string; - }; + const after = await storedToken(trip.id); expect(after.id).toBe(before.id); // same row, not a second insert expect(after.token).toBe('second'); expect(after.expires_at).toBe('2030-01-01T00:00:00.000Z'); expect(after.created_by).toBe(rotator.id); - expect(testDb.prepare('SELECT COUNT(*) AS c FROM trip_invite_tokens WHERE trip_id = ?').get(trip.id)).toEqual({ c: 1 }); + expect(await countRows(t, TripInviteTokens, { trip: trip.id })).toBe(1); }); }); diff --git a/server/tests/unit/db/repositories/VacayYears.repository.test.ts b/server/tests/unit/db/repositories/VacayYears.repository.test.ts index e4d1d0e55..d4bab7400 100644 --- a/server/tests/unit/db/repositories/VacayYears.repository.test.ts +++ b/server/tests/unit/db/repositories/VacayYears.repository.test.ts @@ -9,6 +9,8 @@ import { resetTestDb } from '../../../helpers/test-db'; import { createTestOrm, type TestOrm } from '../../../helpers/test-orm'; import { createUser } from '../../../helpers/factories'; import { VacayYears } from '../../../../src/db/entities/VacayYears.entity'; +import { insertRow } from '../../../helpers/factories/rows'; +import { makeVacayPlan } from '../../../helpers/factories/vacay'; import type { VacayYearsRepository } from '../../../../src/db/repositories/VacayYears.repository'; const testDb = createSnapshotTestDb(); @@ -22,19 +24,20 @@ beforeAll(async () => { beforeEach(() => { resetTestDb(testDb); t.clear(); }); afterAll(async () => { await t.close(); testDb.close(); }); -function insertPlan(ownerId: number): number { - return Number(testDb.prepare('INSERT INTO vacay_plans (owner_id) VALUES (?)').run(ownerId).lastInsertRowid); +async function insertPlan(ownerId: number): Promise { + return (await makeVacayPlan(t, ownerId)).id; } describe('VacayYearsRepository.listForPlan (VC33/94, VacayService.listYears)', () => { it('VACAYYEARREPO-001: matches SELECT year FROM vacay_years WHERE plan_id = ? ORDER BY year run raw, scoped to the plan', async () => { const { user } = createUser(testDb); - const planId = insertPlan(user.id); - const other = insertPlan(createUser(testDb).user.id); - testDb.prepare('INSERT INTO vacay_years (plan_id, year) VALUES (?, ?)').run(planId, 2027); - testDb.prepare('INSERT INTO vacay_years (plan_id, year) VALUES (?, ?)').run(planId, 2026); - testDb.prepare('INSERT INTO vacay_years (plan_id, year) VALUES (?, ?)').run(other, 2099); + const planId = await insertPlan(user.id); + const other = await insertPlan(createUser(testDb).user.id); + await insertRow(t, VacayYears, { plan: planId, year: 2027 }); + await insertRow(t, VacayYears, { plan: planId, year: 2026 }); + await insertRow(t, VacayYears, { plan: other, year: 2099 }); + // test-sql-allow: the legacy statement is the oracle the repository read is held to. const legacy = testDb.prepare('SELECT year FROM vacay_years WHERE plan_id = ? ORDER BY year').all(planId); const years = await repo.listForPlan(planId); @@ -44,7 +47,7 @@ describe('VacayYearsRepository.listForPlan (VC33/94, VacayService.listYears)', ( it('VACAYYEARREPO-002: empty array for a plan with no years', async () => { const { user } = createUser(testDb); - const planId = insertPlan(user.id); + const planId = await insertPlan(user.id); expect(await repo.listForPlan(planId)).toEqual([]); }); }); diff --git a/server/tests/unit/db/repositories/VisitedRegions.repository.test.ts b/server/tests/unit/db/repositories/VisitedRegions.repository.test.ts index cfe583d0b..4a24a8ac9 100644 --- a/server/tests/unit/db/repositories/VisitedRegions.repository.test.ts +++ b/server/tests/unit/db/repositories/VisitedRegions.repository.test.ts @@ -12,6 +12,7 @@ import { resetTestDb } from '../../../helpers/test-db'; import { createTestOrm, type TestOrm } from '../../../helpers/test-orm'; import { createUser } from '../../../helpers/factories'; import { VisitedRegions } from '../../../../src/db/entities/VisitedRegions.entity'; +import { insertRow } from '../../../helpers/factories/rows'; import type { VisitedRegionsRepository } from '../../../../src/db/repositories/VisitedRegions.repository'; const testDb = createSnapshotTestDb(); @@ -29,13 +30,11 @@ describe('VisitedRegionsRepository.listForUser (AT21)', () => { it('VISREGREPO-001: matches SELECT region_code, region_name, country_code FROM visited_regions WHERE user_id = ? ORDER BY created_at DESC run raw', async () => { const { user } = createUser(testDb); const { user: other } = createUser(testDb, { username: 'other' }); - testDb.prepare('INSERT INTO visited_regions (user_id, region_code, region_name, country_code, created_at) VALUES (?, ?, ?, ?, ?)') - .run(user.id, 'DE-BY', 'Bavaria', 'DE', '2026-01-01T00:00:00.000Z'); - testDb.prepare('INSERT INTO visited_regions (user_id, region_code, region_name, country_code, created_at) VALUES (?, ?, ?, ?, ?)') - .run(user.id, 'DE-BE', 'Berlin', 'DE', '2026-02-01T00:00:00.000Z'); - testDb.prepare('INSERT INTO visited_regions (user_id, region_code, region_name, country_code, created_at) VALUES (?, ?, ?, ?, ?)') - .run(other.id, 'FR-IDF', 'Ile-de-France', 'FR', '2026-03-01T00:00:00.000Z'); + await insertRow(t, VisitedRegions, { user: user.id, region_code: 'DE-BY', region_name: 'Bavaria', country_code: 'DE', created_at: '2026-01-01T00:00:00.000Z' }); + await insertRow(t, VisitedRegions, { user: user.id, region_code: 'DE-BE', region_name: 'Berlin', country_code: 'DE', created_at: '2026-02-01T00:00:00.000Z' }); + await insertRow(t, VisitedRegions, { user: other.id, region_code: 'FR-IDF', region_name: 'Ile-de-France', country_code: 'FR', created_at: '2026-03-01T00:00:00.000Z' }); + // test-sql-allow: the legacy statement is the oracle the repository read is held to. const legacy = testDb.prepare('SELECT region_code, region_name, country_code FROM visited_regions WHERE user_id = ? ORDER BY created_at DESC').all(user.id); const rows = await repo.listForUser(user.id); @@ -52,9 +51,10 @@ describe('VisitedRegionsRepository.listForUser (AT21)', () => { describe('VisitedRegionsRepository.listRegionCodesForCountry (AT24)', () => { it('VISREGREPO-003: matches SELECT region_code FROM visited_regions WHERE user_id = ? AND country_code = ? run raw', async () => { const { user } = createUser(testDb); - testDb.prepare('INSERT INTO visited_regions (user_id, region_code, region_name, country_code) VALUES (?, ?, ?, ?)').run(user.id, 'DE-BY', 'Bavaria', 'DE'); - testDb.prepare('INSERT INTO visited_regions (user_id, region_code, region_name, country_code) VALUES (?, ?, ?, ?)').run(user.id, 'FR-IDF', 'Ile-de-France', 'FR'); + await insertRow(t, VisitedRegions, { user: user.id, region_code: 'DE-BY', region_name: 'Bavaria', country_code: 'DE' }); + await insertRow(t, VisitedRegions, { user: user.id, region_code: 'FR-IDF', region_name: 'Ile-de-France', country_code: 'FR' }); + // test-sql-allow: the legacy statement is the oracle the repository read is held to. const legacy = testDb.prepare('SELECT region_code FROM visited_regions WHERE user_id = ? AND country_code = ?').all(user.id, 'DE'); const codes = await repo.listRegionCodesForCountry(user.id, 'DE'); @@ -71,8 +71,9 @@ describe('VisitedRegionsRepository.listRegionCodesForCountry (AT24)', () => { describe('VisitedRegionsRepository.findCountryCode (AT25)', () => { it('VISREGREPO-005: matches SELECT country_code FROM visited_regions WHERE user_id = ? AND region_code = ? run raw', async () => { const { user } = createUser(testDb); - testDb.prepare('INSERT INTO visited_regions (user_id, region_code, region_name, country_code) VALUES (?, ?, ?, ?)').run(user.id, 'DE-BY', 'Bavaria', 'DE'); + await insertRow(t, VisitedRegions, { user: user.id, region_code: 'DE-BY', region_name: 'Bavaria', country_code: 'DE' }); + // test-sql-allow: the legacy statement is the oracle the repository read is held to. const legacy = testDb.prepare('SELECT country_code FROM visited_regions WHERE user_id = ? AND region_code = ?').get(user.id, 'DE-BY') as { country_code: string }; expect(await repo.findCountryCode(user.id, 'DE-BY')).toEqual(legacy.country_code); }); diff --git a/server/tests/unit/db/repositories/WebauthnCredentials.repository.test.ts b/server/tests/unit/db/repositories/WebauthnCredentials.repository.test.ts index da4134bc2..133d201fe 100644 --- a/server/tests/unit/db/repositories/WebauthnCredentials.repository.test.ts +++ b/server/tests/unit/db/repositories/WebauthnCredentials.repository.test.ts @@ -4,6 +4,7 @@ import { resetTestDb } from '../../../helpers/test-db'; import { createTestOrm, type TestOrm } from '../../../helpers/test-orm'; import { createUser } from '../../../helpers/factories'; import { WebauthnCredentials } from '../../../../src/db/entities/WebauthnCredentials.entity'; +import { findRows, insertRow, insertRows, updateRows } from '../../../helpers/factories/rows'; import type { WebauthnCredentialsRepository } from '../../../../src/db/repositories/WebauthnCredentials.repository'; import { UnitOfWork } from '../../../../src/nest/database/unit-of-work'; import { withRequestContext } from '../../../../src/nest/database/request-context'; @@ -20,9 +21,25 @@ beforeEach(() => { resetTestDb(testDb); t.clear(); }); afterAll(async () => { await t.close(); testDb.close(); }); let _credSeq = 0; -function insertCredential(userId: number, overrides: Record = {}): { id: number } & Record { + +interface CredentialColumns { + credential_id: string; + public_key: Buffer; + counter: number; + transports: string | null; + device_type: string | null; + backed_up: number; + name: string | null; + aaguid: string | null; + created_at?: string; +} + +async function insertCredential( + userId: number, + overrides: Partial = {}, +): Promise<{ id: number } & CredentialColumns> { _credSeq++; - const row = { + const row: CredentialColumns = { credential_id: `cred-${_credSeq}`, public_key: Buffer.from([1, 2, 3, _credSeq]), counter: 0, @@ -33,19 +50,17 @@ function insertCredential(userId: number, overrides: Record = { aaguid: null, ...overrides, }; - const result = testDb.prepare( - `INSERT INTO webauthn_credentials - (user_id, credential_id, public_key, counter, transports, device_type, backed_up, name, aaguid) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, - ).run(userId, row.credential_id, row.public_key, row.counter, row.transports, row.device_type, row.backed_up, row.name, row.aaguid); - return { id: Number(result.lastInsertRowid), ...row }; + const id = await insertRow(t, WebauthnCredentials, { user: userId, ...row }); + return { id, ...row }; } function rawCredential(id: number): Record | undefined { + // test-sql-allow: the stored row, BLOB bytes included, is what these cases check, past any ORM mapping. return testDb.prepare('SELECT * FROM webauthn_credentials WHERE id = ?').get(id) as Record | undefined; } function rawCredentialByCredentialId(credentialId: string): Record | undefined { + // test-sql-allow: the stored row, BLOB bytes included, is what these cases check, past any ORM mapping. return testDb.prepare('SELECT * FROM webauthn_credentials WHERE credential_id = ?').get(credentialId) as Record | undefined; } @@ -54,7 +69,7 @@ describe('WebauthnCredentialsRepository', () => { it('WEBAUTHN-CRED-REPO-001: true iff the user owns at least one credential', async () => { const { user } = createUser(testDb); expect(await creds.hasAny(user.id)).toBe(false); - insertCredential(user.id); + await insertCredential(user.id); expect(await creds.hasAny(user.id)).toBe(true); }); }); @@ -63,9 +78,9 @@ describe('WebauthnCredentialsRepository', () => { it('WEBAUTHN-CRED-REPO-002: projects credential_id + transports for one user only', async () => { const { user } = createUser(testDb); const { user: other } = createUser(testDb); - insertCredential(user.id, { credential_id: 'mine-1', transports: JSON.stringify(['usb']) }); - insertCredential(user.id, { credential_id: 'mine-2', transports: null }); - insertCredential(other.id, { credential_id: 'theirs' }); + await insertCredential(user.id, { credential_id: 'mine-1', transports: JSON.stringify(['usb']) }); + await insertCredential(user.id, { credential_id: 'mine-2', transports: null }); + await insertCredential(other.id, { credential_id: 'theirs' }); const rows = await creds.listExcludeCredentials(user.id); expect(rows).toHaveLength(2); @@ -81,7 +96,7 @@ describe('WebauthnCredentialsRepository', () => { describe('existsByCredentialId', () => { it('WEBAUTHN-CRED-REPO-003: true for a known credential_id, false otherwise', async () => { const { user } = createUser(testDb); - insertCredential(user.id, { credential_id: 'known-cred' }); + await insertCredential(user.id, { credential_id: 'known-cred' }); expect(await creds.existsByCredentialId('known-cred')).toBe(true); expect(await creds.existsByCredentialId('unknown-cred')).toBe(false); }); @@ -141,7 +156,7 @@ describe('WebauthnCredentialsRepository', () => { describe('findCreatedCredential (PK8) vs findByCredentialId (PK9)', () => { it('WEBAUTHN-CRED-REPO-006: findCreatedCredential projects only the panel columns', async () => { const { user } = createUser(testDb); - const cred = insertCredential(user.id, { name: 'Post-Reg' }); + const cred = await insertCredential(user.id, { name: 'Post-Reg' }); const row = await creds.findCreatedCredential(cred.credential_id as string); expect(row).toEqual({ @@ -157,8 +172,8 @@ describe('WebauthnCredentialsRepository', () => { it('WEBAUTHN-CRED-REPO-006b: name/created_at NULL come back null, not undefined (coverage: rule 16)', async () => { const { user } = createUser(testDb); - const cred = insertCredential(user.id, { name: null }); - testDb.prepare('UPDATE webauthn_credentials SET created_at = NULL WHERE id = ?').run(cred.id); + const cred = await insertCredential(user.id, { name: null }); + await updateRows(t, WebauthnCredentials, { id: cred.id }, { created_at: null }); const row = await creds.findCreatedCredential(cred.credential_id as string); expect(row?.name).toBeNull(); expect(row?.created_at).toBeNull(); @@ -171,7 +186,7 @@ describe('WebauthnCredentialsRepository', () => { it('WEBAUTHN-CRED-REPO-008: findByCredentialId returns the full row, public_key a real Buffer', async () => { const { user } = createUser(testDb); const pk = Buffer.from([1, 2, 3]); - const cred = insertCredential(user.id, { public_key: pk }); + const cred = await insertCredential(user.id, { public_key: pk }); const row = await creds.findByCredentialId(cred.credential_id as string); expect(row).not.toBeNull(); @@ -189,7 +204,7 @@ describe('WebauthnCredentialsRepository', () => { describe('updateCounterAndLastUsed', () => { it('WEBAUTHN-CRED-REPO-010: bumps counter and stamps last_used_at', async () => { const { user } = createUser(testDb); - const cred = insertCredential(user.id, { counter: 5 }); + const cred = await insertCredential(user.id, { counter: 5 }); await creds.updateCounterAndLastUsed(cred.id, 6); @@ -203,11 +218,11 @@ describe('WebauthnCredentialsRepository', () => { it('WEBAUTHN-CRED-REPO-011: newest-first, scoped to one user, matches PK8\'s column set', async () => { const { user } = createUser(testDb); const { user: other } = createUser(testDb); - testDb.prepare( - `INSERT INTO webauthn_credentials (user_id, credential_id, public_key, counter, backed_up, name, created_at) - VALUES (?, 'old', x'01', 0, 0, 'Old', '2026-01-01 00:00:00'), (?, 'new', x'02', 0, 1, 'New', '2026-02-01 00:00:00')`, - ).run(user.id, user.id); - insertCredential(other.id); + await insertRows(t, WebauthnCredentials, [ + { user: user.id, credential_id: 'old', public_key: Buffer.from([1]), counter: 0, backed_up: 0, name: 'Old', created_at: '2026-01-01 00:00:00' }, + { user: user.id, credential_id: 'new', public_key: Buffer.from([2]), counter: 0, backed_up: 1, name: 'New', created_at: '2026-02-01 00:00:00' }, + ]); + await insertCredential(other.id); const rows = await creds.listForPanel(user.id); expect(rows.map((r) => r.name)).toEqual(['New', 'Old']); @@ -218,7 +233,7 @@ describe('WebauthnCredentialsRepository', () => { describe('renameOwned', () => { it('WEBAUTHN-CRED-REPO-012: renames when id + user_id both match, returns the affected count', async () => { const { user } = createUser(testDb); - const cred = insertCredential(user.id); + const cred = await insertCredential(user.id); expect(await creds.renameOwned(cred.id, user.id, 'Renamed')).toBe(1); expect(rawCredential(cred.id)!.name).toBe('Renamed'); @@ -227,7 +242,7 @@ describe('WebauthnCredentialsRepository', () => { it('WEBAUTHN-CRED-REPO-013: a foreign owner or unknown id affects 0 rows (404, never 403)', async () => { const { user } = createUser(testDb); const { user: other } = createUser(testDb); - const cred = insertCredential(user.id, { name: 'Original' }); + const cred = await insertCredential(user.id, { name: 'Original' }); expect(await creds.renameOwned(cred.id, other.id, 'Stolen')).toBe(0); expect(await creds.renameOwned(999_999, user.id, 'Ghost')).toBe(0); @@ -238,7 +253,7 @@ describe('WebauthnCredentialsRepository', () => { describe('deleteOwned', () => { it('WEBAUTHN-CRED-REPO-014: deletes when id + user_id both match, returns the affected count', async () => { const { user } = createUser(testDb); - const cred = insertCredential(user.id); + const cred = await insertCredential(user.id); expect(await creds.deleteOwned(cred.id, user.id)).toBe(1); expect(rawCredential(cred.id)).toBeUndefined(); }); @@ -246,7 +261,7 @@ describe('WebauthnCredentialsRepository', () => { it('WEBAUTHN-CRED-REPO-015: a foreign owner affects 0 rows and the row survives', async () => { const { user } = createUser(testDb); const { user: other } = createUser(testDb); - const cred = insertCredential(user.id); + const cred = await insertCredential(user.id); expect(await creds.deleteOwned(cred.id, other.id)).toBe(0); expect(rawCredential(cred.id)).toBeDefined(); }); @@ -269,7 +284,7 @@ describe('WebauthnCredentialsRepository', () => { // describe-body top level, outside any request context). it('WEBAUTHN-CRED-REPO-017: a nativeUpdate inside uow.transactional is not discarded by a stale entity read earlier under a different projection', async () => { const { user } = createUser(testDb); - const cred = insertCredential(user.id, { counter: 1 }); + const cred = await insertCredential(user.id, { counter: 1 }); const uow = new UnitOfWork(t.em); await withRequestContext(t.orm, async () => { @@ -302,13 +317,13 @@ describe('WebauthnCredentialsRepository', () => { it('WEBAUTHN-CRED-REPO-016: clears every credential for one user, returns the deleted count, other users untouched', async () => { const { user } = createUser(testDb); const { user: other } = createUser(testDb); - insertCredential(user.id); - insertCredential(user.id); - const kept = insertCredential(other.id); + await insertCredential(user.id); + await insertCredential(user.id); + const kept = await insertCredential(other.id); expect(await creds.deleteAllForUser(user.id)).toBe(2); expect(await creds.deleteAllForUser(user.id)).toBe(0); // already empty - expect((testDb.prepare('SELECT id FROM webauthn_credentials').all() as Array<{ id: number }>)).toEqual([{ id: kept.id }]); + expect((await findRows(t, WebauthnCredentials)).map((c) => ({ id: c.id }))).toEqual([{ id: kept.id }]); }); }); }); diff --git a/server/tests/unit/db/repositories/_shared/atlas-travowns-consumers.parity.test.ts b/server/tests/unit/db/repositories/_shared/atlas-travowns-consumers.parity.test.ts index 8705338ec..f3c84d4bf 100644 --- a/server/tests/unit/db/repositories/_shared/atlas-travowns-consumers.parity.test.ts +++ b/server/tests/unit/db/repositories/_shared/atlas-travowns-consumers.parity.test.ts @@ -19,6 +19,10 @@ import { createReservation, createTrip, createUser, addTripMember } from '../../ import { createTestReservationEndpointsRepo } from '../../../../helpers/test-uow'; import type { ReservationEndpointsRepository } from '../../../../../src/db/repositories/ReservationEndpoints.repository'; import { todayUtc } from '@trek/shared'; +import { createTestOrm, type TestOrm } from '../../../../helpers/test-orm'; +import { updateRows } from '../../../../helpers/factories/rows'; +import { addReservationTraveler, makeReservationEndpoint } from '../../../../helpers/factories/reservations'; +import { Reservations } from '../../../../../src/db/entities/Reservations.entity'; const TRAVELER_OWNS = ` (NOT EXISTS (SELECT 1 FROM reservation_travelers rt WHERE rt.reservation_id = r.id) @@ -66,6 +70,7 @@ const AT46 = ` const db = createSnapshotTestDb(); let repo: ReservationEndpointsRepository; +let orm: TestOrm; const users: number[] = []; const trips: number[] = []; const sortRows = (rows: unknown[]) => [...rows].sort((a, b) => (a as { id: number }).id - (b as { id: number }).id); @@ -82,10 +87,27 @@ beforeAll(async () => { addTripMember(db, past, B); addTripMember(db, past, C); addTripMember(db, future, B); - const ep = db.prepare( - 'INSERT INTO reservation_endpoints (reservation_id, role, sequence, name, code, lat, lng, local_date, local_time) VALUES (?,?,?,?,?,?,?,?,?)', - ); - const trv = db.prepare('INSERT INTO reservation_travelers (reservation_id, user_id) VALUES (?, ?)'); + orm = await createTestOrm(db); + const ep = ( + reservationId: number, + role: string, + sequence: number, + code: string, + lat: number, + lng: number, + localDate: string | null, + localTime: string | null, + ) => + makeReservationEndpoint(orm, reservationId, { + role, + sequence, + name: 'X', + code, + lat, + lng, + local_date: localDate, + local_time: localTime, + }); let k = 0; for (const tid of [past, future, dateless]) { const shapes: [string, string | null, number[]][] = [ @@ -98,25 +120,28 @@ beforeAll(async () => { ]; for (const [type, status, tr] of shapes) { const r = createReservation(db, tid, { type }).id; - db.prepare('UPDATE reservations SET status = ?, reservation_time = ?, reservation_end_time = ? WHERE id = ?').run( - status ?? 'pending', - k % 2 ? '2020-01-01T10:00' : null, - k % 3 ? '2020-01-01T14:00' : null, - r, - ); + await updateRows(orm, Reservations, { id: r }, { + status: status ?? 'pending', + reservation_time: k % 2 ? '2020-01-01T10:00' : null, + reservation_end_time: k % 3 ? '2020-01-01T14:00' : null, + }); k++; - ep.run(r, 'from', 0, 'X', 'FRA', 50.03 + k, 8.57, k % 2 ? '2020-01-01' : null, k % 2 ? '10:00' : null); - ep.run(r, 'stop', 1, 'X', 'DXB', 25.25, 55.36, null, null); - ep.run(r, 'to', 2, 'X', 'SIN', 1.36, 103.99 + k, '2020-01-02', null); - for (const u of tr) trv.run(r, u); + await ep(r, 'from', 0, 'FRA', 50.03 + k, 8.57, k % 2 ? '2020-01-01' : null, k % 2 ? '10:00' : null); + await ep(r, 'stop', 1, 'DXB', 25.25, 55.36, null, null); + await ep(r, 'to', 2, 'SIN', 1.36, 103.99 + k, '2020-01-02', null); + for (const u of tr) await addReservationTraveler(orm, r, u); } } }); -afterAll(() => db.close()); +afterAll(async () => { + await orm.close(); + db.close(); +}); describe('TRAVELER_OWNS consumers: repository vs legacy raw SQL, full rows', () => { it('AT6 — listOwnedEndpointsForTrips', async () => { for (const u of users) { + // test-sql-allow: the legacy statement is the oracle the repository read is held to. const legacy = db.prepare(AT6(trips.length)).all(...trips, u); expect(sortRows(await repo.listOwnedEndpointsForTrips(trips, u))).toEqual(sortRows(legacy)); } @@ -124,6 +149,7 @@ describe('TRAVELER_OWNS consumers: repository vs legacy raw SQL, full rows', () it('AT45 — listOwnedEndpointsForUser', async () => { for (const u of users) { + // test-sql-allow: the legacy statement is the oracle the repository read is held to. const legacy = db.prepare(AT45).all(u, u, u); expect(sortRows(await repo.listOwnedEndpointsForUser(u, todayUtc()))).toEqual(sortRows(legacy)); } @@ -131,6 +157,7 @@ describe('TRAVELER_OWNS consumers: repository vs legacy raw SQL, full rows', () it('AT46 — listOwnedFlightLegsForUser (ordered)', async () => { for (const u of users) { + // test-sql-allow: the legacy statement is the oracle the repository read is held to. const legacy = db.prepare(AT46).all(u, u, u); expect(await repo.listOwnedFlightLegsForUser(u)).toEqual(legacy); } @@ -141,11 +168,13 @@ describe('TRAVELER_OWNS consumers: repository vs legacy raw SQL, full rows', () const ids = (u: number) => JSON.stringify( [ + // test-sql-allow: the legacy statement is the oracle the repository read is held to. ...new Set((db.prepare(AT6(trips.length)).all(...trips, u) as { reservation_id: number }[]).map((r) => r.reservation_id)), ].sort(), ); const a = ids(A).length; const c = ids(C).length; + // test-sql-allow: the legacy statement is the oracle the repository read is held to. const d = db.prepare(AT45).all(D, D, D).length; expect(a).toBeGreaterThan(0); expect(c).toBeGreaterThan(0); diff --git a/server/tests/unit/db/repositories/trek-repository.test.ts b/server/tests/unit/db/repositories/trek-repository.test.ts index 5ea5f81b6..e75591641 100644 --- a/server/tests/unit/db/repositories/trek-repository.test.ts +++ b/server/tests/unit/db/repositories/trek-repository.test.ts @@ -41,6 +41,7 @@ import { createSnapshotTestDb } from '../../../helpers/db-mock'; import { resetTestDb } from '../../../helpers/test-db'; import { createTestOrm, type TestOrm } from '../../../helpers/test-orm'; import { createUser } from '../../../helpers/factories'; +import { countRows, findRow } from '../../../helpers/factories/rows'; import { Users } from '../../../../src/db/entities/Users.entity'; import type { UsersRepository } from '../../../../src/db/repositories/Users.repository'; import { WebauthnChallenges } from '../../../../src/db/entities/WebauthnChallenges.entity'; @@ -226,8 +227,8 @@ describe('TrekRepository — every overridden path succeeds inside withRequestCo await users.nativeDelete({ id: inserted as number }); }); - const row = testDb.prepare('SELECT username FROM users WHERE id = ?').get(user.id) as { username: string }; - expect(row.username).toBe('inside-upserted'); // the upsert's merge is the last write and must stick + const row = await findRow(t, Users, { id: user.id }); + expect(row?.username).toBe('inside-upserted'); // the upsert's merge is the last write and must stick }); // Task 7 review, M4 — the same seven methods, inside a request context. @@ -256,7 +257,7 @@ describe('TrekRepository — every overridden path succeeds inside withRequestCo // SQLite (via better-sqlite3) does not return one primary key per row // for a batch insert — only that it does not throw inside a request // context is what this test proves; the row count is verified below - // from the raw table, outside the context. + // from a fresh context of its own, outside this one. await users.insertMany([ { username: 'm4-many-1', email: 'm4-many-1@example.test', password_hash: 'h', role: 'user', first_seen_version: '1.0' }, { username: 'm4-many-2', email: 'm4-many-2@example.test', password_hash: 'h', role: 'user', first_seen_version: '1.0' }, @@ -269,10 +270,9 @@ describe('TrekRepository — every overridden path succeeds inside withRequestCo expect(upserted).toHaveLength(1); }); - const row = testDb.prepare('SELECT username FROM users WHERE id = ?').get(user.id) as { username: string }; - expect(row.username).toBe('m4-upserted'); - const manyRows = testDb.prepare("SELECT username FROM users WHERE username LIKE 'm4-many-%'").all() as { username: string }[]; - expect(manyRows).toHaveLength(2); + const row = await findRow(t, Users, { id: user.id }); + expect(row?.username).toBe('m4-upserted'); + expect(await countRows(t, Users, { username: { $like: 'm4-many-%' } })).toBe(2); }); }); @@ -305,8 +305,8 @@ describe('TrekRepository — inside uow.transactional', () => { }); }); - const row = testDb.prepare('SELECT username, email FROM users WHERE id = ?').get(user.id) as { username: string; email: string }; - expect(row.username).toBe('after'); // the nativeUpdate must stick - expect(row.email).toBe('before@example.test'); // untouched by either read + const row = await findRow(t, Users, { id: user.id }); + expect(row?.username).toBe('after'); // the nativeUpdate must stick + expect(row?.email).toBe('before@example.test'); // untouched by either read }); }); diff --git a/server/tests/unit/db/trip-access.test.ts b/server/tests/unit/db/trip-access.test.ts index ab7a869cd..09d86ef4d 100644 --- a/server/tests/unit/db/trip-access.test.ts +++ b/server/tests/unit/db/trip-access.test.ts @@ -12,6 +12,8 @@ import { createSnapshotTestDb } from '../../helpers/db-mock'; import { CAN_ACCESS_TRIP_SQL, buildDbMock, resetTestDb } from '../../helpers/test-db'; import { createTestOrm, type TestOrm } from '../../helpers/test-orm'; import { Trips } from '../../../src/db/entities/Trips.entity'; +import { makeUser } from '../../helpers/factories/users'; +import { addTripMember, makeTrip } from '../../helpers/factories/trips'; import type { TripsRepository } from '../../../src/db/repositories/Trips.repository'; const testDb = createSnapshotTestDb(); @@ -25,42 +27,31 @@ beforeAll(async () => { beforeEach(() => { resetTestDb(testDb); t.clear(); }); afterAll(async () => { await t.close(); testDb.close(); }); -function seedUser(username: string): number { - return Number( - testDb.prepare("INSERT INTO users (username, email, password_hash, role) VALUES (?, ?, 'x', 'user')") - .run(username, `${username}@example.test`).lastInsertRowid, - ); +async function seedUser(username: string): Promise { + return (await makeUser(t, { username, email: `${username}@example.test` })).user.id; } describe('TripsRepository.findAccessible (formerly canAccessTrip)', () => { it('returns the trip currency for the owner (#1543)', async () => { - const owner = seedUser('owner'); - const tripId = Number( - testDb.prepare("INSERT INTO trips (user_id, title, currency) VALUES (?, 'Trip', 'RUB')") - .run(owner).lastInsertRowid, - ); + const owner = await seedUser('owner'); + const tripId = (await makeTrip(t, owner, { title: 'Trip', currency: 'RUB' })).id; expect(await trips.findAccessible(tripId, owner)).toMatchObject({ id: tripId, user_id: owner, currency: 'RUB' }); }); it('returns the trip currency for a member too', async () => { - const owner = seedUser('owner2'); - const member = seedUser('member2'); - const tripId = Number( - testDb.prepare("INSERT INTO trips (user_id, title, currency) VALUES (?, 'Trip', 'JPY')") - .run(owner).lastInsertRowid, - ); - testDb.prepare('INSERT INTO trip_members (trip_id, user_id) VALUES (?, ?)').run(tripId, member); + const owner = await seedUser('owner2'); + const member = await seedUser('member2'); + const tripId = (await makeTrip(t, owner, { title: 'Trip', currency: 'JPY' })).id; + await addTripMember(t, tripId, member); expect(await trips.findAccessible(tripId, member)).toMatchObject({ currency: 'JPY' }); }); it('returns undefined for a user with no access', async () => { - const owner = seedUser('owner3'); - const stranger = seedUser('stranger3'); - const tripId = Number( - testDb.prepare("INSERT INTO trips (user_id, title) VALUES (?, 'Trip')").run(owner).lastInsertRowid, - ); + const owner = await seedUser('owner3'); + const stranger = await seedUser('stranger3'); + const tripId = (await makeTrip(t, owner, { title: 'Trip' })).id; expect(await trips.findAccessible(tripId, stranger)).toBeUndefined(); }); @@ -69,8 +60,8 @@ describe('TripsRepository.findAccessible (formerly canAccessTrip)', () => { // before the value reaches the statement) survives the repository move — // pinned directly here rather than only inferred from the boot matrix. it('a non-numeric-looking id finds nothing, the same as the legacy raw-bind statement', async () => { - const owner = seedUser('owner4'); - testDb.prepare("INSERT INTO trips (user_id, title) VALUES (?, 'Trip')").run(owner); + const owner = await seedUser('owner4'); + await makeTrip(t, owner, { title: 'Trip' }); expect(await trips.findAccessible('not-a-number', owner)).toBeUndefined(); }); }); @@ -82,18 +73,14 @@ describe('TripsRepository.findAccessible (formerly canAccessTrip)', () => { describe('the buildDbMock stand-in for canAccessTrip', () => { it('hands back the trip currency, like the real one', async () => { const dbmockDb = createSnapshotTestDb(); + const seeder = await createTestOrm(dbmockDb); try { - const owner = Number( - dbmockDb.prepare("INSERT INTO users (username, email, password_hash, role) VALUES ('m', 'm@example.test', 'x', 'user')") - .run().lastInsertRowid, - ); - const tripId = Number( - dbmockDb.prepare("INSERT INTO trips (user_id, title, currency) VALUES (?, 'Trip', 'ISK')") - .run(owner).lastInsertRowid, - ); + const owner = (await makeUser(seeder, { username: 'm', email: 'm@example.test' })).user.id; + const tripId = (await makeTrip(seeder, owner, { title: 'Trip', currency: 'ISK' })).id; expect(await buildDbMock(dbmockDb).canAccessTrip(tripId, owner)).toMatchObject({ id: tripId, currency: 'ISK' }); } finally { + await seeder.close(); dbmockDb.close(); } });