mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-23 23:46:35 +00:00
feat(db): AppSettings and Users repositories; forFeature wiring pattern
This commit is contained in:
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user