diff --git a/server/src/db/repositories/AppSettings.repository.ts b/server/src/db/repositories/AppSettings.repository.ts index 84bcfc8b5..c40f59c69 100644 --- a/server/src/db/repositories/AppSettings.repository.ts +++ b/server/src/db/repositories/AppSettings.repository.ts @@ -1,4 +1,66 @@ import type { AppSettings } from '../entities/AppSettings.entity'; +import { toRow, type AssertRowKeys } from './_shared/rows'; import { EntityRepository } from '@mikro-orm/sql'; -export class AppSettingsRepository extends EntityRepository {} +/** An `app_settings` row as the API emits it. */ +export interface AppSettingsRow { + key: string | null; + value: string | null; +} + +const _appSettingsRowKeys: AssertRowKeys = true; + +export class AppSettingsRepository extends EntityRepository { + /** `SELECT value FROM app_settings WHERE key = ?` */ + async getValue(key: string): Promise { + const row = await this.findOne({ key }, { fields: ['value'] }); + return row?.value ?? null; + } + + /** `SELECT key, value FROM app_settings WHERE key IN (?, ?, ...)` */ + async getValues(keys: string[]): Promise> { + const rows = await this.find({ key: { $in: keys } }); + const values = new Map(); + for (const row of rows) { + if (row.key != null && row.value != null) values.set(row.key, row.value); + } + return values; + } + + /** + * Upsert ruling: `app_settings` (`Migration20200101000000_baseline_schema.ts`) + * is `CREATE TABLE app_settings (key TEXT PRIMARY KEY, value TEXT)` and no + * later migration adds a column or a timestamp — the table has never carried + * anything beyond `key`/`value`. Every legacy call site across settings, + * addons and permissions is either `INSERT OR REPLACE INTO app_settings + * (key, value) VALUES (?, ?)` or `INSERT INTO app_settings (key, value) + * VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value` — + * with no other column whose reset by REPLACE could be observable, both + * spellings are exactly `em.upsert`'s job, with the sole unique field + * (`key`, the primary key) as the conflict target. + */ + async setValue(key: string, value: string): Promise { + await this.upsert({ key, value }, { onConflictFields: ['key'], onConflictAction: 'merge' }); + } + + /** + * `SELECT key, value FROM app_settings WHERE key LIKE ?` — the caller + * passes the bare prefix; this appends the trailing `%` itself, matching + * every legacy call site (`'perm_%'`, `'default_user_setting_%'`). + * + * Escaping: neither the legacy `LIKE 'prefix%'` literals nor `$like`'s + * parameter binding here escape `%`/`_` in the prefix — both are plain + * SQL LIKE, where a bound parameter's `%`/`_` are wildcards exactly like a + * literal's are. The repository test proves the two behave identically for + * a prefix that itself contains `%`/`_`. + */ + async findByKeyPrefix(prefix: string): Promise { + const rows = await this.find({ key: { $like: `${prefix}%` } }); + return rows.map((row) => toRow(row) as AppSettingsRow); + } + + /** `DELETE FROM app_settings WHERE key = ?` */ + async deleteValue(key: string): Promise { + return this.nativeDelete({ key }); + } +} diff --git a/server/src/db/repositories/Users.repository.ts b/server/src/db/repositories/Users.repository.ts index 38e63a4b2..b828b47ed 100644 --- a/server/src/db/repositories/Users.repository.ts +++ b/server/src/db/repositories/Users.repository.ts @@ -1,4 +1,33 @@ import type { Users } from '../entities/Users.entity'; import { EntityRepository } from '@mikro-orm/sql'; -export class UsersRepository extends EntityRepository {} +/** + * The three third-party keys that belong to the instance rather than to a + * person (see `nest/settings/instance-api-keys.ts`). A closed union, never a + * dynamic column string — `getApiKeyColumn` dispatches through MikroORM's + * typed `fields` option, which only accepts a real property of `Users`, so a + * name outside this union fails `tsc` rather than reaching a query. + * + * This repository is the union's source of truth; `instance-api-keys.ts` + * imports it rather than keeping its own copy (Task 5 wires that import). + */ +export type InstanceApiKeyName = 'maps_api_key' | 'unsplash_api_key' | 'amap_api_key'; + +export class UsersRepository extends EntityRepository { + /** `SELECT email FROM users WHERE id = ?` */ + async getEmail(userId: number): Promise { + const row = await this.findOne({ id: userId }, { fields: ['email'] }); + return row?.email ?? null; + } + + /** + * One of the three instance-API-key columns, selected by `name`: + * `SELECT FROM users WHERE id = ?`. `name` is the typed union above, + * not an interpolated column — MikroORM's `fields` option only accepts a + * real property of `Users`, so this can never select an arbitrary column. + */ + async getApiKeyColumn(userId: number, name: InstanceApiKeyName): Promise { + const row = await this.findOne({ id: userId }, { fields: [name] }); + return row ? (row[name] ?? null) : null; + } +} diff --git a/server/src/db/repositories/_shared/owned-lookup.ts b/server/src/db/repositories/_shared/owned-lookup.ts new file mode 100644 index 000000000..366f64b68 --- /dev/null +++ b/server/src/db/repositories/_shared/owned-lookup.ts @@ -0,0 +1,70 @@ +import type { EntityRepository, FilterQuery, OrderDefinition } from '@mikro-orm/core'; + +/** + * Shared lookup shapes for entities scoped to a user, some of which also + * allow a globally-shared row (a nullable owner column). + * + * Built for `CategoriesRepository`/`TagsRepository` (Plan 3a Task 3), whose + * `list`/`getById`/`getByIdAndUser` methods are the same two query shapes + * over different tables: list-scoped-to-owner and find-one-by-id with an + * ownership check that is either strict (`tags`, whose `user_id` column is + * `NOT NULL`) or permissive (`categories`, whose `user_id` is nullable and + * `NULL` rows are read as globally shared). Task 3 decides, per call site, + * which shape a given legacy statement actually used — `categories.getById` + * today has no ownership filter at all (`SELECT * FROM categories WHERE id + * = ?`), so it stays a plain `findOne({ id })` rather than either helper + * here; changing that would be a behaviour change, not a refactor. + * + * `ownerField`/`orderField` are taken as parameters (`keyof T`) rather than + * hard-coded so one helper serves every entity shaped this way; the `as + * FilterQuery`/`as Record` casts are the one place that + * type is asserted rather than inferred; the entity's own properties still + * decide, at the call site, which literal `ownerField`/`orderField` values + * are legal (`keyof T`). + */ + +/** + * List every row owned by `ownerId`, ordered by `orderField` ascending: + * `SELECT * FROM WHERE = ? ORDER BY ASC`. + */ +export async function listForOwner( + repo: EntityRepository, + ownerField: keyof T, + ownerId: unknown, + orderField: keyof T, +): Promise { + return repo.find({ [ownerField]: ownerId } as FilterQuery, { + orderBy: { [orderField]: 'asc' } as OrderDefinition, + }); +} + +/** + * Find one row by id, strictly scoped to an owner: + * `SELECT * FROM
WHERE id = ? AND = ?`. + */ +export async function findOwnedByUser( + repo: EntityRepository, + id: unknown, + ownerField: keyof T, + ownerId: unknown, +): Promise { + return repo.findOne({ id, [ownerField]: ownerId } as FilterQuery); +} + +/** + * Find one row by id, matching either a row `ownerId` owns or a row with no + * owner at all (a nullable owner column used for globally-shared rows): + * `SELECT * FROM
WHERE id = ? AND ( = ? OR + * IS NULL)`. + */ +export async function findOwnedOrGlobal( + repo: EntityRepository, + id: unknown, + ownerField: keyof T, + ownerId: unknown, +): Promise { + return repo.findOne({ + id, + $or: [{ [ownerField]: ownerId }, { [ownerField]: null }], + } as unknown as FilterQuery); +} diff --git a/server/src/db/repositories/_shared/rows.ts b/server/src/db/repositories/_shared/rows.ts index 4432e4881..9da0a7597 100644 --- a/server/src/db/repositories/_shared/rows.ts +++ b/server/src/db/repositories/_shared/rows.ts @@ -13,10 +13,17 @@ export function toRow(entity: T): EntityDTO { return wrap(entity).toObject() as EntityDTO; } -/** The scalar (column-backed) keys of an entity class: everything that is not a relation or a collection. */ +/** + * The scalar (column-backed) keys of an entity class: everything that is not + * a relation or a collection. `Exclude` drops MikroORM's + * internal meta-properties (`[PrimaryKeyProp]`, `[OptionalProps]`, …) — + * `unique symbol`-keyed, declared on an entity whose primary key isn't a + * plain autoincrement `id` (e.g. `AppSettings`'s `[PrimaryKeyProp]?: 'key'`) + * — which are not columns and have no place in a hand-written row interface. + */ export type ScalarKeys = { - [K in keyof T]-?: NonNullable extends Collection | Reference ? never : K; -}[keyof T]; + [K in Exclude]-?: NonNullable extends Collection | Reference ? never : K; +}[Exclude]; /** * Compile-time parity between a hand-written row interface and the entity's diff --git a/server/src/nest/settings/settings.module.ts b/server/src/nest/settings/settings.module.ts index 25152f243..a46b75580 100644 --- a/server/src/nest/settings/settings.module.ts +++ b/server/src/nest/settings/settings.module.ts @@ -1,16 +1,20 @@ import { Module } from '@nestjs/common'; +import { MikroOrmModule } from '@mikro-orm/nestjs'; import { AdminDefaultUserSettingsController, SettingsController } from './settings.controller'; import { AuthModule } from '../auth/auth.module'; import { AuditModule } from '../audit/audit.module'; import { SettingsService } from './settings.service'; import { SettingsMcp } from './settings.mcp'; import { AppConfigModule } from '../app-config/app-config.module'; +import { AppSettings } from '../../db/entities/AppSettings.entity'; /** Exports SettingsService for in-container consumers (admin, share, llm-parse). */ @Module({ // AuthModule for the admin gate on the defaults routes, and for the - // AuthService that SettingsMcp's demo gate injects. - imports: [AppConfigModule, AuthModule, AuditModule], + // AuthService that SettingsMcp's demo gate injects. MikroOrmModule.forFeature + // registers AppSettingsRepository for @InjectRepository(AppSettings) below — + // the forFeature + @InjectRepository wiring pattern every later domain copies. + imports: [AppConfigModule, AuthModule, AuditModule, MikroOrmModule.forFeature([AppSettings])], controllers: [SettingsController, AdminDefaultUserSettingsController], providers: [SettingsService, SettingsMcp], exports: [SettingsService], diff --git a/server/src/nest/settings/settings.service.ts b/server/src/nest/settings/settings.service.ts index e66b0184c..00421c134 100644 --- a/server/src/nest/settings/settings.service.ts +++ b/server/src/nest/settings/settings.service.ts @@ -1,9 +1,12 @@ import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@mikro-orm/nestjs'; import { DatabaseService } from '../database/database.service'; import { UnitOfWork } from '../database/unit-of-work'; import { decrypt_api_key, maybe_encrypt_api_key } from '../common/crypto/apiKeyCrypto'; import { MASKED_SETTING_VALUE, normalizeAppearance } from '@trek/shared'; import { readEnv } from '../../app-config'; +import { AppSettings } from '../../db/entities/AppSettings.entity'; +import type { AppSettingsRepository } from '../../db/repositories/AppSettings.repository'; /** * Exported so a caller that hands settings to somebody else can assert its own @@ -153,19 +156,20 @@ export class SettingsService { constructor( private readonly db: DatabaseService, private readonly uow: UnitOfWork, + // Wiring proof (Plan 3a Task 0): the rest of this service's app_settings + // sites (S2/S3, the admin-defaults writes) stay on `this.db` until Task 5. + @InjectRepository(AppSettings) private readonly appSettings: AppSettingsRepository, ) {} async getAdminUserDefaults(): Promise> { - const rows = this.db.all<{ key: string; value: string }>( - "SELECT key, value FROM app_settings WHERE key LIKE 'default_user_setting_%'" - ); + const rows = await this.appSettings.findByKeyPrefix('default_user_setting_'); const defaults: Record = {}; for (const row of rows) { - const settingKey = row.key.slice('default_user_setting_'.length); + const settingKey = (row.key ?? '').slice('default_user_setting_'.length); if (ENCRYPTED_SETTING_KEYS.has(settingKey)) { defaults[settingKey] = row.value ? (decrypt_api_key(row.value) ?? '') : ''; } else { - defaults[settingKey] = parseValue(row.value); + defaults[settingKey] = parseValue(row.value ?? ''); } } return defaults; diff --git a/server/tests/e2e/admin.e2e.test.ts b/server/tests/e2e/admin.e2e.test.ts index 84b06af0e..1c215d2d9 100644 --- a/server/tests/e2e/admin.e2e.test.ts +++ b/server/tests/e2e/admin.e2e.test.ts @@ -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>; 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 diff --git a/server/tests/e2e/assignments.e2e.test.ts b/server/tests/e2e/assignments.e2e.test.ts index 8b4fb0348..f46222abe 100644 --- a/server/tests/e2e/assignments.e2e.test.ts +++ b/server/tests/e2e/assignments.e2e.test.ts @@ -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>; 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(); diff --git a/server/tests/e2e/calendar-feed-visibility.e2e.test.ts b/server/tests/e2e/calendar-feed-visibility.e2e.test.ts index 704a1ace5..de9ab5bfc 100644 --- a/server/tests/e2e/calendar-feed-visibility.e2e.test.ts +++ b/server/tests/e2e/calendar-feed-visibility.e2e.test.ts @@ -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(); diff --git a/server/tests/e2e/dawarich.e2e.test.ts b/server/tests/e2e/dawarich.e2e.test.ts index e1bdc0325..83e73403d 100644 --- a/server/tests/e2e/dawarich.e2e.test.ts +++ b/server/tests/e2e/dawarich.e2e.test.ts @@ -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) diff --git a/server/tests/e2e/days.e2e.test.ts b/server/tests/e2e/days.e2e.test.ts index eea315fba..3576ab89d 100644 --- a/server/tests/e2e/days.e2e.test.ts +++ b/server/tests/e2e/days.e2e.test.ts @@ -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>; 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()); diff --git a/server/tests/e2e/feeds.e2e.test.ts b/server/tests/e2e/feeds.e2e.test.ts index ec9b201af..4669df472 100644 --- a/server/tests/e2e/feeds.e2e.test.ts +++ b/server/tests/e2e/feeds.e2e.test.ts @@ -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 }, diff --git a/server/tests/e2e/journey.e2e.test.ts b/server/tests/e2e/journey.e2e.test.ts index 15413f577..77ef072de 100644 --- a/server/tests/e2e/journey.e2e.test.ts +++ b/server/tests/e2e/journey.e2e.test.ts @@ -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>; 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) diff --git a/server/tests/e2e/places.e2e.test.ts b/server/tests/e2e/places.e2e.test.ts index 11359fd78..c58c7e769 100644 --- a/server/tests/e2e/places.e2e.test.ts +++ b/server/tests/e2e/places.e2e.test.ts @@ -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>; 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), diff --git a/server/tests/e2e/reservations.e2e.test.ts b/server/tests/e2e/reservations.e2e.test.ts index a3e2b880e..7ea99308c 100644 --- a/server/tests/e2e/reservations.e2e.test.ts +++ b/server/tests/e2e/reservations.e2e.test.ts @@ -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(); diff --git a/server/tests/e2e/roadtrip.e2e.test.ts b/server/tests/e2e/roadtrip.e2e.test.ts index 4586f3a96..16219c4f3 100644 --- a/server/tests/e2e/roadtrip.e2e.test.ts +++ b/server/tests/e2e/roadtrip.e2e.test.ts @@ -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()); diff --git a/server/tests/e2e/settings.e2e.test.ts b/server/tests/e2e/settings.e2e.test.ts index aea764ad4..9d96d2c09 100644 --- a/server/tests/e2e/settings.e2e.test.ts +++ b/server/tests/e2e/settings.e2e.test.ts @@ -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>; 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()); diff --git a/server/tests/e2e/share.e2e.test.ts b/server/tests/e2e/share.e2e.test.ts index 368e27f2f..576444543 100644 --- a/server/tests/e2e/share.e2e.test.ts +++ b/server/tests/e2e/share.e2e.test.ts @@ -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(); diff --git a/server/tests/e2e/transit-provider.e2e.test.ts b/server/tests/e2e/transit-provider.e2e.test.ts index 7194e901b..cda3efc76 100644 --- a/server/tests/e2e/transit-provider.e2e.test.ts +++ b/server/tests/e2e/transit-provider.e2e.test.ts @@ -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()); diff --git a/server/tests/e2e/transit.e2e.test.ts b/server/tests/e2e/transit.e2e.test.ts index a2c0e8675..f4fa6643a 100644 --- a/server/tests/e2e/transit.e2e.test.ts +++ b/server/tests/e2e/transit.e2e.test.ts @@ -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()); diff --git a/server/tests/e2e/trips.e2e.test.ts b/server/tests/e2e/trips.e2e.test.ts index e2d9a2c45..448946594 100644 --- a/server/tests/e2e/trips.e2e.test.ts +++ b/server/tests/e2e/trips.e2e.test.ts @@ -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>; 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()); diff --git a/server/tests/helpers/mcp-test-controllers.ts b/server/tests/helpers/mcp-test-controllers.ts index 8e864bc07..00ad77db5 100644 --- a/server/tests/helpers/mcp-test-controllers.ts +++ b/server/tests/helpers/mcp-test-controllers.ts @@ -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 { 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 { 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 { 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)), diff --git a/server/tests/helpers/plugin-host.ts b/server/tests/helpers/plugin-host.ts index ea423cdfb..cd84156ca 100644 --- a/server/tests/helpers/plugin-host.ts +++ b/server/tests/helpers/plugin-host.ts @@ -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 { 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. diff --git a/server/tests/helpers/test-orm.ts b/server/tests/helpers/test-orm.ts index 04ede7247..3326e7291 100644 --- a/server/tests/helpers/test-orm.ts +++ b/server/tests/helpers/test-orm.ts @@ -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 { + return MikroOrmModule.forRoot({ + entities: [...ALL_ENTITIES], + driver: createBoundSqliteDriver(() => db), + dbName: ':memory:', + discovery: { warnWhenNoEntities: false }, + }); +} diff --git a/server/tests/unit/db/repositories/AppSettings.repository.test.ts b/server/tests/unit/db/repositories/AppSettings.repository.test.ts new file mode 100644 index 000000000..aaa3769eb --- /dev/null +++ b/server/tests/unit/db/repositories/AppSettings.repository.test.ts @@ -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']); + }); + }); +}); diff --git a/server/tests/unit/db/repositories/Users.repository.test.ts b/server/tests/unit/db/repositories/Users.repository.test.ts new file mode 100644 index 000000000..ff6670fee --- /dev/null +++ b/server/tests/unit/db/repositories/Users.repository.test.ts @@ -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(); + }); +}); diff --git a/server/tests/unit/db/repositories/_shared/owned-lookup.test.ts b/server/tests/unit/db/repositories/_shared/owned-lookup.test.ts new file mode 100644 index 000000000..985cad233 --- /dev/null +++ b/server/tests/unit/db/repositories/_shared/owned-lookup.test.ts @@ -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, '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, '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, 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, 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, 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, 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, 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, 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, 999999, 'user', someone.id)).toBeNull(); + }); +}); diff --git a/server/tests/unit/nest/journey-share.service.test.ts b/server/tests/unit/nest/journey-share.service.test.ts index 9ae783056..36b95043f 100644 --- a/server/tests/unit/nest/journey-share.service.test.ts +++ b/server/tests/unit/nest/journey-share.service.test.ts @@ -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(); }); diff --git a/server/tests/unit/nest/settings.service.test.ts b/server/tests/unit/nest/settings.service.test.ts index 51fea90de..ca8316637 100644 --- a/server/tests/unit/nest/settings.service.test.ts +++ b/server/tests/unit/nest/settings.service.test.ts @@ -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(); }); diff --git a/server/tests/unit/nest/share.service.test.ts b/server/tests/unit/nest/share.service.test.ts index 933b5c101..02b293c05 100644 --- a/server/tests/unit/nest/share.service.test.ts +++ b/server/tests/unit/nest/share.service.test.ts @@ -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(); });