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
@@ -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<AppSettings> {}
/** An `app_settings` row as the API emits it. */
export interface AppSettingsRow {
key: string | null;
value: string | null;
}
const _appSettingsRowKeys: AssertRowKeys<AppSettingsRow, AppSettings> = true;
export class AppSettingsRepository extends EntityRepository<AppSettings> {
/** `SELECT value FROM app_settings WHERE key = ?` */
async getValue(key: string): Promise<string | null> {
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<Map<string, string>> {
const rows = await this.find({ key: { $in: keys } });
const values = new Map<string, string>();
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<void> {
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<AppSettingsRow[]> {
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<number> {
return this.nativeDelete({ key });
}
}
+30 -1
View File
@@ -1,4 +1,33 @@
import type { Users } from '../entities/Users.entity';
import { EntityRepository } from '@mikro-orm/sql';
export class UsersRepository extends EntityRepository<Users> {}
/**
* 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<Users> {
/** `SELECT email FROM users WHERE id = ?` */
async getEmail(userId: number): Promise<string | null> {
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 <name> 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<string | null> {
const row = await this.findOne({ id: userId }, { fields: [name] });
return row ? (row[name] ?? null) : null;
}
}
@@ -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<T>`/`as Record<string, 'asc'>` 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 <table> WHERE <ownerField> = ? ORDER BY <orderField> ASC`.
*/
export async function listForOwner<T extends object>(
repo: EntityRepository<T>,
ownerField: keyof T,
ownerId: unknown,
orderField: keyof T,
): Promise<T[]> {
return repo.find({ [ownerField]: ownerId } as FilterQuery<T>, {
orderBy: { [orderField]: 'asc' } as OrderDefinition<T>,
});
}
/**
* Find one row by id, strictly scoped to an owner:
* `SELECT * FROM <table> WHERE id = ? AND <ownerField> = ?`.
*/
export async function findOwnedByUser<T extends object>(
repo: EntityRepository<T>,
id: unknown,
ownerField: keyof T,
ownerId: unknown,
): Promise<T | null> {
return repo.findOne({ id, [ownerField]: ownerId } as FilterQuery<T>);
}
/**
* 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 <table> WHERE id = ? AND (<ownerField> = ? OR <ownerField>
* IS NULL)`.
*/
export async function findOwnedOrGlobal<T extends object>(
repo: EntityRepository<T>,
id: unknown,
ownerField: keyof T,
ownerId: unknown,
): Promise<T | null> {
return repo.findOne({
id,
$or: [{ [ownerField]: ownerId }, { [ownerField]: null }],
} as unknown as FilterQuery<T>);
}
+10 -3
View File
@@ -13,10 +13,17 @@ export function toRow<T extends object>(entity: T): EntityDTO<T> {
return wrap(entity).toObject() as EntityDTO<T>;
}
/** 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<keyof T, symbol>` 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<T> = {
[K in keyof T]-?: NonNullable<T[K]> extends Collection<object> | Reference<object> ? never : K;
}[keyof T];
[K in Exclude<keyof T, symbol>]-?: NonNullable<T[K]> extends Collection<object> | Reference<object> ? never : K;
}[Exclude<keyof T, symbol>];
/**
* Compile-time parity between a hand-written row interface and the entity's
+6 -2
View File
@@ -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],
+9 -5
View File
@@ -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<Record<string, unknown>> {
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<string, unknown> = {};
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;
+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();
});