mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
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:
@@ -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 });
|
||||
|
||||
+23
-14
@@ -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
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user