feat(db): AppSettings and Users repositories; forFeature wiring pattern

This commit is contained in:
jubnl
2026-09-22 12:06:13 +02:00
parent 47e1801908
commit f460ae4fcb
30 changed files with 557 additions and 37 deletions
@@ -0,0 +1,121 @@
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
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 type { AppSettingsRepository } from '../../../../src/db/repositories/AppSettings.repository';
const testDb = createSnapshotTestDb();
let t: TestOrm;
let appSettings: AppSettingsRepository;
beforeAll(async () => {
t = await createTestOrm(testDb);
appSettings = t.repo(AppSettings) as AppSettingsRepository;
});
beforeEach(() => { resetTestDb(testDb); t.clear(); });
afterAll(async () => { await t.close(); testDb.close(); });
function rawRow(key: string): unknown {
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);
}
describe('AppSettingsRepository', () => {
it('APPSETREPO-001: getValue reads the stored value', async () => {
insertRaw('bag_tracking_enabled', 'true');
expect(await appSettings.getValue('bag_tracking_enabled')).toBe('true');
});
it('APPSETREPO-002: getValue returns null for an unset key', async () => {
expect(await appSettings.getValue('does_not_exist')).toBeNull();
});
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');
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-004: setValue inserts a new row exactly as INSERT OR REPLACE would', async () => {
await appSettings.setValue('bag_tracking_enabled', 'true');
expect(rawRow('bag_tracking_enabled')).toStrictEqual({ key: 'bag_tracking_enabled', value: 'true' });
});
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 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 });
});
it('APPSETREPO-006: deleteValue removes the row and returns the legacy DELETE-affected-row count', async () => {
insertRaw('whitespace_migration_collision', 'true');
expect(await appSettings.deleteValue('whitespace_migration_collision')).toBe(1);
expect(rawRow('whitespace_migration_collision')).toBeUndefined();
});
it('APPSETREPO-007: deleteValue on a missing key is a no-op that reports 0 rows', async () => {
expect(await appSettings.deleteValue('does_not_exist')).toBe(0);
});
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');
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([
{ key: 'perm_view_days', value: '1' },
{ key: 'perm_edit_budget', value: '2' },
]));
});
it('APPSETREPO-009: findByKeyPrefix with no matches returns an empty array', async () => {
insertRaw('unrelated_key', 'x');
expect(await appSettings.findByKeyPrefix('perm_')).toEqual([]);
});
// LIKE-escaping finding: neither the legacy `LIKE 'prefix%'` literal nor
// `$like`'s bound parameter here escapes `%`/`_` in the prefix — a bound
// LIKE parameter's wildcards are interpreted by SQLite exactly like a
// literal's are, so `findByKeyPrefix` reproduces the legacy statement's
// matches (and its same non-escaping quirk) for a prefix containing either
// character. These tests compare the repository's result against the
// 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 }[] {
return testDb.prepare('SELECT key, value FROM app_settings WHERE key LIKE ?').all(`${prefix}%`) as {
key: string;
value: string;
}[];
}
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
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']);
});
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
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']);
});
});
});
@@ -0,0 +1,56 @@
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
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 { Users } from '../../../../src/db/entities/Users.entity';
import type { UsersRepository } from '../../../../src/db/repositories/Users.repository';
const testDb = createSnapshotTestDb();
let t: TestOrm;
let users: UsersRepository;
beforeAll(async () => {
t = await createTestOrm(testDb);
users = t.repo(Users) as UsersRepository;
});
beforeEach(() => { resetTestDb(testDb); t.clear(); });
afterAll(async () => { await t.close(); testDb.close(); });
describe('UsersRepository', () => {
it('USERSREPO-001: getEmail reads the stored email, matching SELECT email FROM users WHERE id = ?', async () => {
const { user } = createUser(testDb, { email: 'someone@example.com' });
expect(await users.getEmail(user.id)).toBe('someone@example.com');
const raw = testDb.prepare('SELECT email FROM users WHERE id = ?').get(user.id) as { email: string };
expect(await users.getEmail(user.id)).toBe(raw.email);
});
it('USERSREPO-002: getEmail returns null for a user id that does not exist', async () => {
expect(await users.getEmail(999999)).toBeNull();
});
it('USERSREPO-003: getApiKeyColumn reads maps_api_key, matching SELECT maps_api_key FROM users WHERE id = ?', async () => {
const { user } = createUser(testDb);
testDb.prepare('UPDATE users SET maps_api_key = ? WHERE id = ?').run('encrypted-maps-key', user.id);
expect(await users.getApiKeyColumn(user.id, 'maps_api_key')).toBe('encrypted-maps-key');
});
it('USERSREPO-004: getApiKeyColumn reads unsplash_api_key, not the other two columns', async () => {
const { user } = createUser(testDb);
testDb.prepare('UPDATE users SET maps_api_key = ?, unsplash_api_key = ?, amap_api_key = ? WHERE id = ?')
.run('maps-value', 'unsplash-value', 'amap-value', user.id);
expect(await users.getApiKeyColumn(user.id, 'unsplash_api_key')).toBe('unsplash-value');
});
it('USERSREPO-005: getApiKeyColumn reads amap_api_key', async () => {
const { user } = createUser(testDb);
testDb.prepare('UPDATE users SET amap_api_key = ? WHERE id = ?').run('amap-value', user.id);
expect(await users.getApiKeyColumn(user.id, 'amap_api_key')).toBe('amap-value');
});
it('USERSREPO-006: getApiKeyColumn returns null for an unset column and for a missing user', async () => {
const { user } = createUser(testDb);
expect(await users.getApiKeyColumn(user.id, 'maps_api_key')).toBeNull();
expect(await users.getApiKeyColumn(999999, 'maps_api_key')).toBeNull();
});
});
@@ -0,0 +1,92 @@
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
import { createSnapshotTestDb } from '../../../../helpers/db-mock';
import { resetTestDb } from '../../../../helpers/test-db';
import { createTestOrm, type TestOrm } from '../../../../helpers/test-orm';
import { createCategory, createTag, createUser } from '../../../../helpers/factories';
import { Categories } from '../../../../../src/db/entities/Categories.entity';
import { Tags } from '../../../../../src/db/entities/Tags.entity';
import type { CategoriesRepository } from '../../../../../src/db/repositories/Categories.repository';
import type { TagsRepository } from '../../../../../src/db/repositories/Tags.repository';
import { findOwnedByUser, findOwnedOrGlobal, listForOwner } from '../../../../../src/db/repositories/_shared/owned-lookup';
const testDb = createSnapshotTestDb();
let t: TestOrm;
let categories: CategoriesRepository;
let tags: TagsRepository;
beforeAll(async () => {
t = await createTestOrm(testDb);
categories = t.repo(Categories) as CategoriesRepository;
tags = t.repo(Tags) as TagsRepository;
});
beforeEach(() => { resetTestDb(testDb); t.clear(); });
afterAll(async () => { await t.close(); testDb.close(); });
describe('listForOwner', () => {
it('OWNEDLOOKUP-001: lists only the owner’s rows, ordered by the given field ascending — Tags (a strictly-owned entity, user_id NOT NULL)', async () => {
const { user: owner } = createUser(testDb);
const { user: other } = createUser(testDb);
createTag(testDb, owner.id, { name: 'Zebra' });
createTag(testDb, owner.id, { name: 'Apple' });
createTag(testDb, other.id, { name: 'Middle' });
const rows = await listForOwner<Tags>(tags, 'user', owner.id, 'name');
expect(rows.map((r) => r.name)).toEqual(['Apple', 'Zebra']);
expect(rows.every((r) => r.user_id === owner.id)).toBe(true);
});
it('OWNEDLOOKUP-002: an owner with no rows gets an empty list', async () => {
const { user: owner } = createUser(testDb);
expect(await listForOwner<Tags>(tags, 'user', owner.id, 'name')).toEqual([]);
});
});
describe('findOwnedByUser', () => {
it('OWNEDLOOKUP-003: finds a row the given user owns', async () => {
const { user: owner } = createUser(testDb);
const tag = createTag(testDb, owner.id, { name: 'Mine' });
const found = await findOwnedByUser<Tags>(tags, tag.id, 'user', owner.id);
expect(found?.id).toBe(tag.id);
expect(found?.name).toBe('Mine');
});
it('OWNEDLOOKUP-004: refuses a row owned by somebody else', async () => {
const { user: owner } = createUser(testDb);
const { user: stranger } = createUser(testDb);
const tag = createTag(testDb, owner.id, { name: 'Mine' });
expect(await findOwnedByUser<Tags>(tags, tag.id, 'user', stranger.id)).toBeNull();
});
it('OWNEDLOOKUP-005: returns null for an id that does not exist', async () => {
const { user: owner } = createUser(testDb);
expect(await findOwnedByUser<Tags>(tags, 999999, 'user', owner.id)).toBeNull();
});
});
describe('findOwnedOrGlobal', () => {
it('OWNEDLOOKUP-006: finds a row the given user owns — Categories (a permissively-owned entity, user_id nullable)', async () => {
const { user: owner } = createUser(testDb);
const category = createCategory(testDb, { name: 'Mine', user_id: owner.id });
const found = await findOwnedOrGlobal<Categories>(categories, category.id, 'user', owner.id);
expect(found?.id).toBe(category.id);
});
it('OWNEDLOOKUP-007: finds a row with no owner at all (a globally-shared row)', async () => {
const { user: someone } = createUser(testDb);
const global = createCategory(testDb, { name: 'Global', user_id: null });
const found = await findOwnedOrGlobal<Categories>(categories, global.id, 'user', someone.id);
expect(found?.id).toBe(global.id);
});
it('OWNEDLOOKUP-008: refuses a row owned by somebody else — a non-null owner that is not the caller is not global', async () => {
const { user: owner } = createUser(testDb);
const { user: stranger } = createUser(testDb);
const category = createCategory(testDb, { name: 'Theirs', user_id: owner.id });
expect(await findOwnedOrGlobal<Categories>(categories, category.id, 'user', stranger.id)).toBeNull();
});
it('OWNEDLOOKUP-009: returns null for an id that does not exist', async () => {
const { user: someone } = createUser(testDb);
expect(await findOwnedOrGlobal<Categories>(categories, 999999, 'user', someone.id)).toBeNull();
});
});
@@ -42,26 +42,33 @@ import { JourneyShareService } from '../../../src/nest/journey/journey-share.ser
import { SettingsService } from '../../../src/nest/settings/settings.service';
import { db as dbConn } from '../../../src/db/database';
import { createTestUnitOfWork } from '../../helpers/test-uow';
import { createTestOrm, type TestOrm } from '../../helpers/test-orm';
import { AppSettings } from '../../../src/db/entities/AppSettings.entity';
import type { AppSettingsRepository } from '../../../src/db/repositories/AppSettings.repository';
const dbs = new DatabaseService(dbConn);
let svc: JourneyShareService;
let t: TestOrm;
beforeAll(async () => {
createTables(testDb);
runMigrations(testDb);
const uow = await createTestUnitOfWork(testDb);
t = await createTestOrm(testDb);
svc = new JourneyShareService(
dbs,
new JourneyDomainService(dbs, new RealtimeService(), new TrekPhotosRepository(dbs), uow),
new SettingsService(dbs, uow),
new SettingsService(dbs, uow, t.repo(AppSettings) as AppSettingsRepository),
);
});
beforeEach(() => {
resetTestDb(testDb);
t.clear();
});
afterAll(() => {
afterAll(async () => {
await t.close();
testDb.close();
});
@@ -45,23 +45,34 @@ import { createTables } from '../../../src/db/schema';
import { runMigrations } from '../../../src/db/migrations';
import { resetTestDb } from '../../helpers/test-db';
import { createUser } from '../../helpers/factories';
import { createTestOrm, type TestOrm } from '../../helpers/test-orm';
import { DatabaseService } from '../../../src/nest/database/database.service';
import { SettingsService } from '../../../src/nest/settings/settings.service';
import { createTestUnitOfWork } from '../../helpers/test-uow';
import { AppSettings } from '../../../src/db/entities/AppSettings.entity';
import type { AppSettingsRepository } from '../../../src/db/repositories/AppSettings.repository';
let svc: SettingsService;
let t: TestOrm;
beforeAll(async () => {
createTables(testDb);
runMigrations(testDb);
svc = new SettingsService(new DatabaseService(testDb), await createTestUnitOfWork(testDb));
t = await createTestOrm(testDb);
svc = new SettingsService(
new DatabaseService(testDb),
await createTestUnitOfWork(testDb),
t.repo(AppSettings) as AppSettingsRepository,
);
});
beforeEach(() => {
resetTestDb(testDb);
t.clear();
});
afterAll(() => {
afterAll(async () => {
await t.close();
testDb.close();
});
+9 -2
View File
@@ -61,15 +61,20 @@ import { SettingsService } from '../../../src/nest/settings/settings.service';
import { QueryHelpersService } from '../../../src/nest/query-helpers/query-helpers.service';
import type { User } from '../../../src/types';
import { createTestUnitOfWork } from '../../helpers/test-uow';
import { createTestOrm, type TestOrm } from '../../helpers/test-orm';
import { AppSettings } from '../../../src/db/entities/AppSettings.entity';
import type { AppSettingsRepository } from '../../../src/db/repositories/AppSettings.repository';
let svc: ShareService;
let t: TestOrm;
beforeAll(async () => {
createTables(testDb);
runMigrations(testDb);
t = await createTestOrm(testDb);
svc = new ShareService(
new DatabaseService(testDb),
new SettingsService(new DatabaseService(testDb), await createTestUnitOfWork(testDb)),
new SettingsService(new DatabaseService(testDb), await createTestUnitOfWork(testDb), t.repo(AppSettings) as AppSettingsRepository),
permissionsStub,
new QueryHelpersService(new DatabaseService(testDb)),
photoCacheStub,
@@ -79,11 +84,13 @@ beforeAll(async () => {
beforeEach(() => {
resetTestDb(testDb);
t.clear();
checkPermission.mockReset();
serveKey.mockReset();
});
afterAll(() => {
afterAll(async () => {
await t.close();
testDb.close();
});