mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
feat(db): AppSettings and Users repositories; forFeature wiring pattern
This commit is contained in:
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -61,15 +61,20 @@ import { SettingsService } from '../../../src/nest/settings/settings.service';
|
||||
import { QueryHelpersService } from '../../../src/nest/query-helpers/query-helpers.service';
|
||||
import type { User } from '../../../src/types';
|
||||
import { createTestUnitOfWork } from '../../helpers/test-uow';
|
||||
import { createTestOrm, type TestOrm } from '../../helpers/test-orm';
|
||||
import { AppSettings } from '../../../src/db/entities/AppSettings.entity';
|
||||
import type { AppSettingsRepository } from '../../../src/db/repositories/AppSettings.repository';
|
||||
|
||||
let svc: ShareService;
|
||||
let t: TestOrm;
|
||||
|
||||
beforeAll(async () => {
|
||||
createTables(testDb);
|
||||
runMigrations(testDb);
|
||||
t = await createTestOrm(testDb);
|
||||
svc = new ShareService(
|
||||
new DatabaseService(testDb),
|
||||
new SettingsService(new DatabaseService(testDb), await createTestUnitOfWork(testDb)),
|
||||
new SettingsService(new DatabaseService(testDb), await createTestUnitOfWork(testDb), t.repo(AppSettings) as AppSettingsRepository),
|
||||
permissionsStub,
|
||||
new QueryHelpersService(new DatabaseService(testDb)),
|
||||
photoCacheStub,
|
||||
@@ -79,11 +84,13 @@ beforeAll(async () => {
|
||||
|
||||
beforeEach(() => {
|
||||
resetTestDb(testDb);
|
||||
t.clear();
|
||||
checkPermission.mockReset();
|
||||
serveKey.mockReset();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
afterAll(async () => {
|
||||
await t.close();
|
||||
testDb.close();
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user