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
+2 -1
View File
@@ -93,13 +93,14 @@ import { NotificationsModule } from '../../src/nest/notifications/notifications.
import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
import { ZodValidationPipe } from '../../src/nest/common/zod-validation.pipe';
import { TestUnitOfWorkModule } from '../helpers/test-uow';
import { createTestMikroOrmModule } from '../helpers/test-orm';
describe('Admin e2e (real auth + admin guard + temp SQLite)', () => {
let server: Server;
let app: Awaited<ReturnType<typeof build>>;
async function build() {
const moduleRef = await Test.createTestingModule({ imports: [await TestUnitOfWorkModule.forRoot(db), DatabaseModule, RealtimeModule, AdminModule, OidcModule, SettingsModule, NotificationsModule] }).compile();
const moduleRef = await Test.createTestingModule({ imports: [await TestUnitOfWorkModule.forRoot(db), await createTestMikroOrmModule(db), DatabaseModule, RealtimeModule, AdminModule, OidcModule, SettingsModule, NotificationsModule] }).compile();
const nest = moduleRef.createNestApplication();
nest.use(cookieParser());
// Mirror the production APP_PIPE (app.module.ts): DTO-typed bodies validate
+2 -1
View File
@@ -76,13 +76,14 @@ import { AssignmentsModule } from '../../src/nest/assignments/assignments.module
import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
import { ZodValidationPipe } from '../../src/nest/common/zod-validation.pipe';
import { TestUnitOfWorkModule } from '../helpers/test-uow';
import { createTestMikroOrmModule } from '../helpers/test-orm';
describe('Assignments e2e (real auth guard + temp SQLite)', () => {
let server: Server;
let app: Awaited<ReturnType<typeof build>>;
async function build() {
const moduleRef = await Test.createTestingModule({ imports: [await TestUnitOfWorkModule.forRoot(db), DatabaseModule, RealtimeModule, AssignmentsModule] })
const moduleRef = await Test.createTestingModule({ imports: [await TestUnitOfWorkModule.forRoot(db), await createTestMikroOrmModule(db), DatabaseModule, RealtimeModule, AssignmentsModule] })
.overrideProvider(JourneyDomainService)
.useValue({ reconcileTripSkeletons })
.compile();
@@ -37,6 +37,7 @@ import { DatabaseModule } from '../../src/nest/database/database.module';
import { FeedsModule } from '../../src/nest/feeds/feeds.module';
import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
import { TestUnitOfWorkModule } from '../helpers/test-uow';
import { createTestMikroOrmModule } from '../helpers/test-orm';
describe('Calendar feed visibility e2e (real CalendarService over temp SQLite)', () => {
let server: Server;
@@ -44,7 +45,7 @@ describe('Calendar feed visibility e2e (real CalendarService over temp SQLite)',
let feedToken: string;
async function build() {
const moduleRef = await Test.createTestingModule({ imports: [await TestUnitOfWorkModule.forRoot(db), DatabaseModule, FeedsModule] }).compile();
const moduleRef = await Test.createTestingModule({ imports: [await TestUnitOfWorkModule.forRoot(db), await createTestMikroOrmModule(db), DatabaseModule, FeedsModule] }).compile();
const nest = moduleRef.createNestApplication();
nest.useGlobalFilters(new TrekExceptionFilter());
await nest.init();
+2 -1
View File
@@ -60,6 +60,7 @@ import { AddonsService } from '../../src/nest/addons/addons.service';
import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
import { ZodValidationPipe } from '../../src/nest/common/zod-validation.pipe';
import { TestUnitOfWorkModule } from '../helpers/test-uow';
import { createTestMikroOrmModule } from '../helpers/test-orm';
/**
* Every outbound call an instance would ever receive, stubbed to throw. A route
@@ -100,7 +101,7 @@ describe('Dawarich e2e (real addon gate + real auth guard + real services + temp
let strangerSuggestionId: number;
async function build() {
const moduleRef = await Test.createTestingModule({ imports: [await TestUnitOfWorkModule.forRoot(db), DatabaseModule, DawarichModule] })
const moduleRef = await Test.createTestingModule({ imports: [await TestUnitOfWorkModule.forRoot(db), await createTestMikroOrmModule(db), DatabaseModule, DawarichModule] })
.overrideProvider(AddonsService)
.useValue({ isAddonEnabled })
.overrideProvider(DawarichClient)
+2 -1
View File
@@ -91,13 +91,14 @@ import { DayNotesModule } from '../../src/nest/day-notes/day-notes.module';
import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
import { ZodValidationPipe } from '../../src/nest/common/zod-validation.pipe';
import { TestUnitOfWorkModule } from '../helpers/test-uow';
import { createTestMikroOrmModule } from '../helpers/test-orm';
describe('Days + day-notes e2e (real auth guard + temp SQLite, real day SQL)', () => {
let server: Server;
let app: Awaited<ReturnType<typeof build>>;
async function build() {
const moduleRef = await Test.createTestingModule({ imports: [await TestUnitOfWorkModule.forRoot(db), DatabaseModule, RealtimeModule, DaysModule, DayNotesModule] }).compile();
const moduleRef = await Test.createTestingModule({ imports: [await TestUnitOfWorkModule.forRoot(db), await createTestMikroOrmModule(db), DatabaseModule, RealtimeModule, DaysModule, DayNotesModule] }).compile();
const nest = moduleRef.createNestApplication();
nest.use(cookieParser());
nest.useGlobalPipes(new ZodValidationPipe());
+2 -1
View File
@@ -78,6 +78,7 @@ import { AppConfigModule } from '../../src/nest/app-config/app-config.module';
import { GlobalAuthGuard } from '../../src/nest/auth/global-auth.guard';
import { MfaPolicyGuard } from '../../src/nest/auth/mfa-policy.guard';
import { TestUnitOfWorkModule } from '../helpers/test-uow';
import { createTestMikroOrmModule } from '../helpers/test-orm';
const BASE = 'https://trek.example.test';
@@ -93,7 +94,7 @@ describe('Calendar-feed e2e (real auth guard + temp SQLite)', () => {
// on the feed controller passed here while production 401'd every calendar
// client).
const moduleRef = await Test.createTestingModule({
imports: [await TestUnitOfWorkModule.forRoot(db), AppConfigModule, DatabaseModule, RealtimeModule, FeedsModule],
imports: [await TestUnitOfWorkModule.forRoot(db), await createTestMikroOrmModule(db), AppConfigModule, DatabaseModule, RealtimeModule, FeedsModule],
providers: [
{ provide: APP_GUARD, useClass: GlobalAuthGuard },
{ provide: APP_GUARD, useClass: MfaPolicyGuard },
+2 -1
View File
@@ -71,13 +71,14 @@ import { AddonsService } from '../../src/nest/addons/addons.service';
import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
import { ZodValidationPipe } from '../../src/nest/common/zod-validation.pipe';
import { TestUnitOfWorkModule } from '../helpers/test-uow';
import { createTestMikroOrmModule } from '../helpers/test-orm';
describe('Journey e2e (real auth guard + temp SQLite)', () => {
let server: Server;
let app: Awaited<ReturnType<typeof build>>;
async function build() {
const moduleRef = await Test.createTestingModule({ imports: [await TestUnitOfWorkModule.forRoot(db), DatabaseModule, JourneyModule] })
const moduleRef = await Test.createTestingModule({ imports: [await TestUnitOfWorkModule.forRoot(db), await createTestMikroOrmModule(db), DatabaseModule, JourneyModule] })
.overrideProvider(JourneyDomainService)
.useValue(jsvc)
.overrideProvider(JourneyShareService)
+2 -1
View File
@@ -86,13 +86,14 @@ import { PlacesService } from '../../src/nest/places/places.service';
import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
import { ZodValidationPipe } from '../../src/nest/common/zod-validation.pipe';
import { TestUnitOfWorkModule } from '../helpers/test-uow';
import { createTestMikroOrmModule } from '../helpers/test-orm';
describe('Places e2e (real auth guard + temp SQLite)', () => {
let server: Server;
let app: Awaited<ReturnType<typeof build>>;
async function build() {
const moduleRef = await Test.createTestingModule({ imports: [await TestUnitOfWorkModule.forRoot(db), DatabaseModule, RealtimeModule, PlacesModule] })
const moduleRef = await Test.createTestingModule({ imports: [await TestUnitOfWorkModule.forRoot(db), await createTestMikroOrmModule(db), DatabaseModule, RealtimeModule, PlacesModule] })
.overrideProvider(JourneyDomainService)
.useValue({
onPlaceCreated: vi.fn().mockResolvedValue(undefined),
+2 -1
View File
@@ -58,6 +58,7 @@ import { createTables } from '../../src/db/schema';
import { runMigrations } from '../../src/db/migrations';
import { NotificationsService } from '../../src/nest/notifications/notifications.service';
import { TestUnitOfWorkModule } from '../helpers/test-uow';
import { createTestMikroOrmModule } from '../helpers/test-orm';
describe('Reservations + accommodations e2e (real auth guard + temp SQLite, real reservation SQL)', () => {
let server: Server;
@@ -65,7 +66,7 @@ describe('Reservations + accommodations e2e (real auth guard + temp SQLite, real
let tripId: number;
async function build() {
const moduleRef = await Test.createTestingModule({ imports: [await TestUnitOfWorkModule.forRoot(db), DatabaseModule, RealtimeModule, ReservationsModule, AccommodationsModule] })
const moduleRef = await Test.createTestingModule({ imports: [await TestUnitOfWorkModule.forRoot(db), await createTestMikroOrmModule(db), DatabaseModule, RealtimeModule, ReservationsModule, AccommodationsModule] })
.overrideProvider(NotificationsService)
.useValue({ send: notificationSend })
.compile();
+2 -1
View File
@@ -29,6 +29,7 @@ import type { Server } from 'http';
import request from 'supertest';
import { describe, it, expect, beforeAll, afterAll, beforeEach, vi, type MockInstance } from 'vitest';
import { TestUnitOfWorkModule } from '../helpers/test-uow';
import { createTestMikroOrmModule } from '../helpers/test-orm';
const { db } = vi.hoisted(() => {
// eslint-disable-next-line @typescript-eslint/no-require-imports
@@ -96,7 +97,7 @@ describe('Roadtrip e2e (real guard chain + temp SQLite)', () => {
async function build() {
const moduleRef = await Test.createTestingModule({
imports: [await TestUnitOfWorkModule.forRoot(db), DatabaseModule, RealtimeModule, RoadtripModule],
imports: [await TestUnitOfWorkModule.forRoot(db), await createTestMikroOrmModule(db), DatabaseModule, RealtimeModule, RoadtripModule],
}).compile();
const nest = moduleRef.createNestApplication();
nest.use(cookieParser());
+2 -1
View File
@@ -32,13 +32,14 @@ import { SettingsModule } from '../../src/nest/settings/settings.module';
import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
import { ZodValidationPipe } from '../../src/nest/common/zod-validation.pipe';
import { TestUnitOfWorkModule } from '../helpers/test-uow';
import { createTestMikroOrmModule } from '../helpers/test-orm';
describe('Settings e2e (real auth guard + temp SQLite)', () => {
let server: Server;
let app: Awaited<ReturnType<typeof build>>;
async function build() {
const moduleRef = await Test.createTestingModule({ imports: [await TestUnitOfWorkModule.forRoot(db), DatabaseModule, SettingsModule] }).compile();
const moduleRef = await Test.createTestingModule({ imports: [await TestUnitOfWorkModule.forRoot(db), await createTestMikroOrmModule(db), DatabaseModule, SettingsModule] }).compile();
const nest = moduleRef.createNestApplication();
nest.use(cookieParser());
nest.useGlobalFilters(new TrekExceptionFilter());
+2 -1
View File
@@ -59,6 +59,7 @@ import { DatabaseModule } from '../../src/nest/database/database.module';
import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
import { ZodValidationPipe } from '../../src/nest/common/zod-validation.pipe';
import { TestUnitOfWorkModule } from '../helpers/test-uow';
import { createTestMikroOrmModule } from '../helpers/test-orm';
describe('Share-link e2e (real auth guard + real SQL over temp SQLite)', () => {
let server: Server;
@@ -66,7 +67,7 @@ describe('Share-link e2e (real auth guard + real SQL over temp SQLite)', () => {
let tripId: number;
async function build() {
const moduleRef = await Test.createTestingModule({ imports: [await TestUnitOfWorkModule.forRoot(db), DatabaseModule, ShareModule] })
const moduleRef = await Test.createTestingModule({ imports: [await TestUnitOfWorkModule.forRoot(db), await createTestMikroOrmModule(db), DatabaseModule, ShareModule] })
.overrideProvider(PlacePhotoCacheService).useValue({ serveKey })
.compile();
const nest = moduleRef.createNestApplication();
@@ -33,6 +33,7 @@ import { RealtimeModule } from '../../src/nest/realtime/realtime.module';
import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
import { clearGoogleTransitCache } from '../../src/nest/transit/google-transit.provider';
import { TestUnitOfWorkModule } from '../helpers/test-uow';
import { createTestMikroOrmModule } from '../helpers/test-orm';
const ADMIN = 1;
@@ -49,7 +50,7 @@ describe('Transit backend switch e2e (#1699)', () => {
async function build() {
const moduleRef = await Test.createTestingModule({
imports: [await TestUnitOfWorkModule.forRoot(db), DatabaseModule, RealtimeModule, TransitModule],
imports: [await TestUnitOfWorkModule.forRoot(db), await createTestMikroOrmModule(db), DatabaseModule, RealtimeModule, TransitModule],
}).compile();
const nest = moduleRef.createNestApplication();
nest.use(cookieParser());
+2 -1
View File
@@ -31,6 +31,7 @@ import { DatabaseModule } from '../../src/nest/database/database.module';
import { RealtimeModule } from '../../src/nest/realtime/realtime.module';
import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
import { TestUnitOfWorkModule } from '../helpers/test-uow';
import { createTestMikroOrmModule } from '../helpers/test-orm';
describe('Transit proxy e2e (real auth guard + temp SQLite)', () => {
let server: Server;
@@ -42,7 +43,7 @@ describe('Transit proxy e2e (real auth guard + temp SQLite)', () => {
const moduleRef = await Test.createTestingModule({
// DatabaseModule + RealtimeModule are @Global in the app graph;
// TransitModule's DaysModule/ReservationsModule imports need them here.
imports: [await TestUnitOfWorkModule.forRoot(db), DatabaseModule, RealtimeModule, TransitModule],
imports: [await TestUnitOfWorkModule.forRoot(db), await createTestMikroOrmModule(db), DatabaseModule, RealtimeModule, TransitModule],
}).compile();
const nest = moduleRef.createNestApplication();
nest.use(cookieParser());
+2 -1
View File
@@ -133,13 +133,14 @@ import { BudgetService } from '../../src/nest/budget/budget.service';
import { TripsModule } from '../../src/nest/trips/trips.module';
import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
import { TestUnitOfWorkModule } from '../helpers/test-uow';
import { createTestMikroOrmModule } from '../helpers/test-orm';
describe('Trips e2e (real auth guard + temp SQLite)', () => {
let server: Server;
let app: Awaited<ReturnType<typeof build>>;
async function build() {
const moduleRef = await Test.createTestingModule({ imports: [await TestUnitOfWorkModule.forRoot(db), DatabaseModule, RealtimeModule, TripsModule] }).compile();
const moduleRef = await Test.createTestingModule({ imports: [await TestUnitOfWorkModule.forRoot(db), await createTestMikroOrmModule(db), DatabaseModule, RealtimeModule, TripsModule] }).compile();
const nest = moduleRef.createNestApplication();
nest.use(cookieParser());
nest.useGlobalFilters(new TrekExceptionFilter());
+7 -3
View File
@@ -115,6 +115,9 @@ import { ReservationImportMcp } from '../../src/nest/reservation-import/reservat
import { HelpMcp } from '../../src/nest/help/help.mcp';
import { AddonsMcp } from '../../src/nest/addons/addons.mcp';
import { createTestUnitOfWork } from './test-uow';
import { createTestOrm } from './test-orm';
import { AppSettings } from '../../src/db/entities/AppSettings.entity';
import type { AppSettingsRepository } from '../../src/db/repositories/AppSettings.repository';
/**
* Hand-wired counterpart of the boot-time discovery in McpRegistryService,
@@ -126,6 +129,7 @@ import { createTestUnitOfWork } from './test-uow';
export async function createMcpTestRegistry(): Promise<McpRegistry> {
const dbService = new DatabaseService(db);
const generalStorage = makeStorageFixture('').storage;
const appSettings = (await createTestOrm(dbService.connection)).repo(AppSettings) as AppSettingsRepository;
const permissionsService = new PermissionsService(dbService, await createTestUnitOfWork(dbService.connection));
// Same argument list as auth.bridge.ts. AtlasService used to sit in third
// place; when getTravelStats moved onto AtlasService itself the edge was
@@ -232,7 +236,7 @@ export async function createMcpTestRegistry(): Promise<McpRegistry> {
new SchoolHolidaysMcp(new SchoolHolidaysService(dbService, await createTestUnitOfWork(dbService.connection)), guards),
new TripsMcp(tripsService, todoService, collabService, authService, calendarService, membersService, readModelService, addonsService, guards),
new TripPromptsMcp(tripsService, readModelService, packingService, addonsService),
new ShareMcp(new ShareService(dbService, new SettingsService(dbService, await createTestUnitOfWork(dbService.connection)), permissionsService, queryHelpersService, placePhotoCache, await createTestUnitOfWork(dbService.connection)), authService, guards),
new ShareMcp(new ShareService(dbService, new SettingsService(dbService, await createTestUnitOfWork(dbService.connection), appSettings), permissionsService, queryHelpersService, placePhotoCache, await createTestUnitOfWork(dbService.connection)), authService, guards),
new FeedsMcp(new FeedsService(dbService, calendarService), dbService, new RuntimeEnvService(), guards),
new TripInviteMcp(new TripInviteService(dbService, permissionsService, new TripMembershipService(dbService), await createTestUnitOfWork(dbService.connection)), dbService, new RuntimeEnvService(), guards, new AuditService(dbService)),
new MapsMcp(mapsService),
@@ -240,12 +244,12 @@ export async function createMcpTestRegistry(): Promise<McpRegistry> {
new CollectionsMcp(new CollectionsService(dbService, permissionsService, realtimeService, notificationsStub(), generalStorage, await createTestUnitOfWork(dbService.connection)), dbService, authService, addonsService),
new TransitMcp(new TransitService(new GoogleTransitProvider(dbService)), daysService, reservationsService, dbService, authService, guards),
new AtlasMcp(new AtlasService(dbService, await createTestUnitOfWork(dbService.connection)), addonsService, authService),
new JourneyMcp(journeyDomain, new JourneyShareService(dbService, journeyDomain, new SettingsService(dbService, await createTestUnitOfWork(dbService.connection))), addonsService, authService, captureBackfill),
new JourneyMcp(journeyDomain, new JourneyShareService(dbService, journeyDomain, new SettingsService(dbService, await createTestUnitOfWork(dbService.connection), appSettings)), addonsService, authService, captureBackfill),
new MemoriesMcp(immichService, synologyService, dbService, addonsService),
new NotificationsMcp(await makeNotificationsService(dbService, realtimeService), authService),
new AirtrailMcp(new AirtrailService(dbService, new AuditService(dbService), new AirtrailClient()), addonsService),
new ReservationImportMcp(new AirtrailImportService(dbService, realtimeService, reservationsService, new AirtrailClient(), new AirtrailService(dbService, new AuditService(dbService), new AirtrailClient())), dbService, authService, guards, addonsService),
new SettingsMcp(new SettingsService(dbService, await createTestUnitOfWork(dbService.connection)), authService),
new SettingsMcp(new SettingsService(dbService, await createTestUnitOfWork(dbService.connection), appSettings), authService),
new HelpMcp(), new AddonsMcp(addonsService),
new TripWarningsMcp(new PluginHooks({ providersOf: () => [], invokeHook: async () => [] } as unknown as PluginRuntimeService), dbService),
new PluginSearchMcp(new PluginHooks({ providersOf: () => [], invokeHook: async () => [] } as unknown as PluginRuntimeService)),
+5 -1
View File
@@ -73,6 +73,9 @@ import { TrekPhotosRepository } from '../../src/nest/photos/trek-photos.reposito
import { RuntimeEnvService } from '../../src/nest/app-config/runtime-env.service';
import { makeStorageFixture } from './storage-fixture';
import { createTestUnitOfWork } from './test-uow';
import { createTestOrm } from './test-orm';
import { AppSettings } from '../../src/db/entities/AppSettings.entity';
import type { AppSettingsRepository } from '../../src/db/repositories/AppSettings.repository';
/**
* Hand-wired counterpart of the PluginsModule DI graph for no-Nest tests
@@ -86,6 +89,7 @@ import { createTestUnitOfWork } from './test-uow';
*/
export async function createPluginRpcHostFactory(dbs: DatabaseService): Promise<PluginRpcHostFactory> {
const generalStorage = makeStorageFixture('').storage;
const appSettings = (await createTestOrm(dbs.connection)).repo(AppSettings) as AppSettingsRepository;
const permissions = new PermissionsService(dbs, await createTestUnitOfWork(dbs.connection));
const exchangeRates = new ExchangeRatesService();
const realtime = new RealtimeService();
@@ -107,7 +111,7 @@ export async function createPluginRpcHostFactory(dbs: DatabaseService): Promise<
const assignments = new AssignmentsService(dbs, permissions, realtime, queryHelpers, journey, await createTestUnitOfWork(dbs.connection));
const membership = new TripMembershipService(dbs);
const notifications = await makeNotificationsService(dbs, realtime);
const llmConfig = new LlmConfigResolver(new SettingsService(dbs, await createTestUnitOfWork(dbs.connection)), dbs, addons);
const llmConfig = new LlmConfigResolver(new SettingsService(dbs, await createTestUnitOfWork(dbs.connection), appSettings), dbs, addons);
const oauth = new PluginOAuthService(dbs);
const accommodations = new AccommodationsService(dbs, permissions, realtime, assignments, await createTestUnitOfWork(dbs.connection));
// After it: deleting a place cancels the nights booked at it through this one.
+27
View File
@@ -1,5 +1,7 @@
import type { EntityClass, EntityRepository, GetRepository } from '@mikro-orm/core';
import { MikroORM, type EntityManager } from '@mikro-orm/sqlite';
import { MikroOrmModule } from '@mikro-orm/nestjs';
import type { DynamicModule } from '@nestjs/common';
import type Database from 'better-sqlite3';
import { ALL_ENTITIES } from '../../src/db/entities';
import { createBoundSqliteDriver } from '../../src/db/orm-driver';
@@ -65,3 +67,28 @@ export async function createTestOrm(
},
};
}
/**
* `MikroOrmModule.forRoot`, bound to a suite's own better-sqlite3 handle the
* same way `createTestOrm` is, for the partial `Test.createTestingModule`
* e2e harnesses (`imports: [DatabaseModule, RealtimeModule, SomeModule]`)
* that don't go through `buildApp()`.
*
* `buildApp()` always registers `MikroOrmModule.forRoot` (D5), so any domain
* module that uses `@InjectRepository`/`MikroOrmModule.forFeature` needs an
* `EntityManager` provider in the graph to resolve at all — a partial harness
* that composes such a module without this fails Nest's DI at `compile()`,
* not at the assertion. `allowGlobalContext` is left at its production
* default (`false`): these are real HTTP requests through
* `createNestApplication()`, so `registerRequestContext` (the NestJS
* integration's default) forks a context-resolving EntityManager per request
* the same way `buildApp()` does — no test-only global context needed here.
*/
export function createTestMikroOrmModule(db: Database.Database): DynamicModule | Promise<DynamicModule> {
return MikroOrmModule.forRoot({
entities: [...ALL_ENTITIES],
driver: createBoundSqliteDriver(() => db),
dbName: ':memory:',
discovery: { warnWhenNoEntities: false },
});
}
@@ -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();
});