test(server): seed the repository specs through the ORM factories

The repository suites now write their fixtures and read back what a call
left behind through the factories and readers, not hand-written SQLite on
the better-sqlite3 handle. What stays raw is marked with test-sql-allow:
the legacy statements the parity cases hold a repository read to, rows
read as the table stores them (a BLOB, an integer the entity maps to a
boolean), and a write that has to land on the connection an open
transaction holds.

Cases that pinned a "raw UPDATE" between two repository reads now make
that write through a context of their own, which the repository's
identity map knows as little about, so they still prove the same thing.
This commit is contained in:
Maurice
2026-10-08 15:49:29 +02:00
parent b2f2aef016
commit d28e91d056
17 changed files with 420 additions and 293 deletions
@@ -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<void> {
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);
});
@@ -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<void> {
await insertRow(t, AppSettings, { key, value });
}
/**
@@ -45,7 +47,7 @@ async function withQueryCount<T>(fn: () => Promise<T>): 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']);
@@ -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' });
@@ -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 });
@@ -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<void> {
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
});
});
@@ -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<string, unknown>;
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
});
});
});
@@ -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<void> {
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"]');
});
});
});
@@ -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([]);
});
});
@@ -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);
@@ -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);
@@ -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);
});
});
@@ -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<number> {
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([]);
});
});
@@ -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);
});
@@ -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<string, unknown> = {}): { id: number } & Record<string, unknown> {
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<CredentialColumns> = {},
): 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<string, unknown> = {
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<string, unknown> | 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<string, unknown> | undefined;
}
function rawCredentialByCredentialId(credentialId: string): Record<string, unknown> | 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<string, unknown> | 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 }]);
});
});
});
@@ -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);
@@ -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
});
});
+19 -32
View File
@@ -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<number> {
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();
}
});