test(server): seed the small leftover specs through the repositories

Converts the raw prepare() reads and writes in the share, airtrail, files,
todo, dev-link, vacay, tours, bucket list, unit-of-work and plugin guard
specs to the ORM factories and readers, so they go through the entities the
app uses instead of hand-written SQLite.

The vacay e2e moves from hand-rolled tables to the migrated snapshot, with
its user pinned to the id its session cookie is signed for. Statements that
are raw by nature keep the allow marker with the reason: the legacy oracle
of the bucket list parity spec, the PRAGMA probe of the trek_photos
migration, the unmapped probe table of the route import rollback, the
behind-the-ORM reads of the entity insert probes, and seedUser, which also
serves the hand-rolled users table of createTempDb. Comments that quoted a
prepare() call are reworded so the ratchet no longer counts them.
This commit is contained in:
Maurice
2026-10-08 15:20:07 +02:00
parent ad1111858b
commit 894a5ee781
20 changed files with 152 additions and 148 deletions
+1
View File
@@ -48,6 +48,7 @@ export function seedUser(db: Database.Database, overrides: Partial<SeededUser> =
role: overrides.role ?? 'user',
password_version: overrides.password_version ?? 0,
};
// test-sql-allow: seedUser also serves createTempDb's hand-rolled users table, which no ORM is bound to.
db.prepare(
'INSERT INTO users (id, username, email, role, password_version) VALUES (?, ?, ?, ?, ?)',
).run(user.id, user.username, user.email, user.role, user.password_version);
+20 -65
View File
@@ -1,6 +1,6 @@
/**
* Vacay module e2e — exercises the migrated /api/addons/vacay endpoints through
* the real JwtAuthGuard against a temp SQLite db. DI-native: VacayService runs
* the real JwtAuthGuard against a migrated temp SQLite db. DI-native: VacayService runs
* its real SQL over the temp db (no legacy service mock); only the websocket
* and notification side channels stay mocked. Focuses on auth, status codes
* (POSTs stay 200), the Zod-pipe 400 envelope and a 403 body.
@@ -10,78 +10,30 @@ import request from 'supertest';
import cookieParser from 'cookie-parser';
import type { Server } from 'http';
import { Test } from '@nestjs/testing';
import { seedUser, sessionCookie } from './harness';
import { sessionCookie } from './harness';
const { db } = vi.hoisted(() => {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const Database = require('better-sqlite3');
const tmp = new Database(':memory:');
tmp.exec('PRAGMA journal_mode = WAL');
tmp.exec(`CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT NOT NULL,
email TEXT NOT NULL UNIQUE, role TEXT NOT NULL DEFAULT 'user', password_version INTEGER NOT NULL DEFAULT 0);`);
// The vacay tables VacayService's real SQL touches (trimmed from src/db/schema.ts,
// with the fraction/kind columns the migrations add to vacay_entries).
tmp.exec(`CREATE TABLE vacay_plans (id INTEGER PRIMARY KEY AUTOINCREMENT,
owner_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
block_weekends INTEGER DEFAULT 1, holidays_enabled INTEGER DEFAULT 0, holidays_region TEXT DEFAULT '',
school_holidays_enabled INTEGER DEFAULT 0, company_holidays_enabled INTEGER DEFAULT 1,
carry_over_enabled INTEGER DEFAULT 1, weekend_days TEXT, week_start INTEGER,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE(owner_id));`);
tmp.exec(`CREATE TABLE vacay_plan_members (id INTEGER PRIMARY KEY AUTOINCREMENT,
plan_id INTEGER NOT NULL REFERENCES vacay_plans(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
status TEXT DEFAULT 'pending', created_at DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE(plan_id, user_id));`);
tmp.exec(`CREATE TABLE vacay_user_colors (id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
plan_id INTEGER NOT NULL REFERENCES vacay_plans(id) ON DELETE CASCADE,
color TEXT DEFAULT '#6366f1', UNIQUE(user_id, plan_id));`);
tmp.exec(`CREATE TABLE vacay_years (id INTEGER PRIMARY KEY AUTOINCREMENT,
plan_id INTEGER NOT NULL REFERENCES vacay_plans(id) ON DELETE CASCADE,
year INTEGER NOT NULL, UNIQUE(plan_id, year));`);
tmp.exec(`CREATE TABLE vacay_user_years (id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
plan_id INTEGER NOT NULL REFERENCES vacay_plans(id) ON DELETE CASCADE,
year INTEGER NOT NULL, vacation_days INTEGER DEFAULT 30, carried_over INTEGER DEFAULT 0,
UNIQUE(user_id, plan_id, year));`);
tmp.exec(`CREATE TABLE vacay_entries (id INTEGER PRIMARY KEY AUTOINCREMENT,
plan_id INTEGER NOT NULL REFERENCES vacay_plans(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
date TEXT NOT NULL, note TEXT DEFAULT '',
fraction REAL NOT NULL DEFAULT 1, kind TEXT NOT NULL DEFAULT 'vacation',
UNIQUE(user_id, plan_id, date));`);
tmp.exec(`CREATE TABLE vacay_company_holidays (id INTEGER PRIMARY KEY AUTOINCREMENT,
plan_id INTEGER NOT NULL REFERENCES vacay_plans(id) ON DELETE CASCADE,
date TEXT NOT NULL, note TEXT DEFAULT '', fraction REAL NOT NULL DEFAULT 1, UNIQUE(plan_id, date));`);
tmp.exec(`CREATE TABLE vacay_holiday_calendars (id INTEGER PRIMARY KEY AUTOINCREMENT,
plan_id INTEGER NOT NULL REFERENCES vacay_plans(id) ON DELETE CASCADE,
type TEXT NOT NULL DEFAULT 'public_holiday', region TEXT NOT NULL, label TEXT,
color TEXT NOT NULL DEFAULT '#fecaca', sort_order INTEGER NOT NULL DEFAULT 0);`);
tmp.exec(`CREATE TABLE vacay_shares (id INTEGER PRIMARY KEY AUTOINCREMENT,
owner_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
hidden INTEGER DEFAULT 0, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE(owner_id, user_id));`);
tmp.exec(`CREATE TABLE vacay_user_settings (
user_id INTEGER PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
year_type TEXT NOT NULL DEFAULT 'calendar', year_start_month INTEGER NOT NULL DEFAULT 1,
year_start_day INTEGER NOT NULL DEFAULT 1, hire_date TEXT);`);
// StorageRegistryService (behind StorageModule, now in this module chain) reads
// this at onModuleInit.
tmp.exec('CREATE TABLE app_settings (key TEXT PRIMARY KEY, value TEXT);');
return { db: tmp };
vi.mock('../../src/db/database', async () => {
const { createSnapshotTestDb, buildDbMock } = await import('../helpers/db-mock');
return buildDbMock(createSnapshotTestDb());
});
vi.mock('../../src/db/database', () => ({ db, closeDb: () => {}, reinitialize: () => {} }));
vi.mock('../../src/websocket', () => ({ broadcastToUser: vi.fn() }));
import { db } from '../../src/db/database';
import { VacayEntries } from '../../src/db/entities/VacayEntries.entity';
import { VacayPlans } from '../../src/db/entities/VacayPlans.entity';
import { RealtimeModule } from '../../src/nest/realtime/realtime.module';
import { VacayModule } from '../../src/nest/vacay/vacay.module';
import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
import { ZodValidationPipe } from '../../src/nest/common/zod-validation.pipe';
import { broadcastToUser } from '../../src/websocket';
import { TestUnitOfWorkModule } from '../helpers/test-uow';
import { createTestMikroOrmModule } from '../helpers/test-orm';
import { createTestMikroOrmModule, createTestOrm, type TestOrm } from '../helpers/test-orm';
import { makeUser } from '../helpers/factories/users';
import { findRow } from '../helpers/factories/rows';
describe('Vacay e2e (real auth guard + temp SQLite)', () => {
let orm: TestOrm;
describe('Vacay e2e (real auth guard + migrated temp SQLite)', () => {
let server: Server;
let app: Awaited<ReturnType<typeof build>>;
@@ -98,13 +50,16 @@ describe('Vacay e2e (real auth guard + temp SQLite)', () => {
}
beforeAll(async () => {
seedUser(db as never, { id: 1 });
orm = await createTestOrm(db);
// Pinned id: sessionCookie(1) signs for exactly this user.
await makeUser(orm, { id: 1, email: 'e2e@example.test' });
app = await build();
server = app.getHttpServer();
});
afterAll(async () => {
await app.close();
await orm.close();
});
it('401 without a session cookie', async () => {
@@ -117,7 +72,7 @@ describe('Vacay e2e (real auth guard + temp SQLite)', () => {
expect(res.status).toBe(200);
expect(res.body.plan.owner_id).toBe(1);
expect(res.body.isOwner).toBe(true);
expect(db.prepare('SELECT id FROM vacay_plans WHERE owner_id = 1').get()).toBeDefined();
expect(await findRow(orm, VacayPlans, { owner: 1 })).not.toBeNull();
});
it('200 (not 201) on POST entries/toggle, forwarding the socket id', async () => {
@@ -125,7 +80,7 @@ describe('Vacay e2e (real auth guard + temp SQLite)', () => {
.set('Cookie', sessionCookie(1)).set('X-Socket-Id', 'sock-7').send({ date: '2026-07-01' });
expect(res.status).toBe(200);
expect(res.body).toEqual({ action: 'added', fraction: 1, kind: 'vacation' });
expect(db.prepare("SELECT id FROM vacay_entries WHERE user_id = 1 AND date = '2026-07-01'").get()).toBeDefined();
expect(await findRow(orm, VacayEntries, { user: 1, date: '2026-07-01' })).not.toBeNull();
expect(vi.mocked(broadcastToUser)).toHaveBeenCalledWith(1, { type: 'vacay:update' }, 'sock-7');
});
+7 -2
View File
@@ -30,13 +30,17 @@ vi.mock('../../src/config', () => ({
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() }));
import { db as testDb } from '../../src/db/database';
import { MikroORM } from '@mikro-orm/core';
import { buildApp } from '../../src/bootstrap';
import { resetTestDb, resetRateLimits } from '../helpers/test-db';
import type { FactoryOrm } from '../helpers/factories/context';
import { createUser, createTrip, createReservation, createPlace, addTripMember } from '../helpers/factories';
import { authCookie, generateToken } from '../helpers/auth';
import { setAppSetting } from '../helpers/factories/settings';
let nestApp: INestApplication;
let app: Application;
let orm: FactoryOrm;
const FIXTURE_PDF = path.join(__dirname, '../fixtures/test.pdf');
const FIXTURE_IMG = path.join(__dirname, '../fixtures/small-image.jpg');
@@ -46,16 +50,17 @@ const uploadsDir = path.join(__dirname, '../../uploads/files');
beforeAll(async () => {
nestApp = await buildApp();
app = nestApp.getHttpAdapter().getInstance();
orm = nestApp.get(MikroORM);
if (!fs.existsSync(uploadsDir)) fs.mkdirSync(uploadsDir, { recursive: true });
// Seed allowed_file_types to include common types (wildcard)
testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('allowed_file_types', '*')").run();
await setAppSetting(orm, 'allowed_file_types', '*');
});
beforeEach(async () => {
resetTestDb(testDb);
await resetRateLimits(nestApp);
// Re-seed allowed_file_types after reset
testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('allowed_file_types', '*')").run();
await setAppSetting(orm, 'allowed_file_types', '*');
});
afterAll(async () => {
@@ -22,6 +22,10 @@ vi.mock('../../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser:
import { PluginRuntimeService } from '../../../src/nest/plugins/plugin-runtime.service';
import { createPluginRuntime } from '../../helpers/plugin-host';
import { sharedTestOrm } from '../../helpers/test-uow';
import { findRow } from '../../helpers/factories/rows';
import { makePlugin } from '../../helpers/factories/plugins';
import { Plugins } from '../../../src/db/entities/Plugins.entity';
const testDb = createSnapshotTestDb();
const dbConn = testDb;
@@ -77,9 +81,7 @@ describe('PluginRuntimeService dev-link', () => {
expect(fs.existsSync(path.join(dest, 'server', 'index.js'))).toBe(true);
expect(fs.realpathSync(dest)).toBe(fs.realpathSync(dir));
const row = testDb.prepare("SELECT source_repo, status, enabled FROM plugins WHERE id = 'linkplug'").get() as {
source_repo: string; status: string; enabled: number;
};
const row = await findRow(await sharedTestOrm(dbConn), Plugins, { id: 'linkplug' });
expect(row).toMatchObject({ source_repo: 'local:link', status: 'inactive', enabled: 0 });
});
@@ -132,7 +134,9 @@ describe('PluginRuntimeService dev-link', () => {
});
it('refuses to clobber a real (non-linked) installed plugin of the same id', async () => {
testDb.prepare("INSERT INTO plugins (id, name, type, version, status, source_repo) VALUES ('installed','X','integration','1.0.0','inactive','local:upload')").run();
await makePlugin(await sharedTestOrm(dbConn), 'installed', {
name: 'X', type: 'integration', version: '1.0.0', status: 'inactive', source_repo: 'local:upload',
});
await expect(runtime.link(writeSource('installed'))).rejects.toThrow(/already installed/);
});
+7 -1
View File
@@ -23,18 +23,24 @@ vi.mock('../../src/config', () => ({
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() }));
import { db as testDb } from '../../src/db/database';
import { MikroORM } from '@mikro-orm/core';
import { buildApp } from '../../src/bootstrap';
import { resetTestDb, resetRateLimits } from '../helpers/test-db';
import type { FactoryOrm } from '../helpers/factories/context';
import { createUser, createTrip, addTripMember } from '../helpers/factories';
import { authCookie } from '../helpers/auth';
import { findRows } from '../helpers/factories/rows';
import { TodoItems } from '../../src/db/entities/TodoItems.entity';
import { invalidatePermissionsCache } from '../../src/nest/permissions/permissions-cache';
let nestApp: INestApplication;
let app: Application;
let orm: FactoryOrm;
beforeAll(async () => {
nestApp = await buildApp();
app = nestApp.getHttpAdapter().getInstance();
orm = nestApp.get(MikroORM);
});
beforeEach(async () => {
@@ -199,7 +205,7 @@ describe('Todo items', () => {
expect(res.body.success).toBe(true);
// Verify the new order in the DB
const items = testDb.prepare('SELECT id, sort_order FROM todo_items WHERE trip_id = ? ORDER BY sort_order').all(trip.id) as any[];
const items = await findRows(orm, TodoItems, { trip: trip.id }, { sort_order: 'asc' });
expect(items[0].id).toBe(id3);
expect(items[1].id).toBe(id2);
expect(items[2].id).toBe(id1);
@@ -163,6 +163,7 @@ describe('insert probes (task-4-review-shape.md, the reviewer\'s exact repro)',
});
await t.em.persist(created).flush();
// test-sql-allow: reads the stored columns behind the ORM to prove what its own insert wrote.
const row = testDb.prepare('SELECT persons, days, place_id, reservation_id FROM budget_items WHERE id = ?').get(created.id);
expect(row).toEqual({ persons: null, days: null, place_id: null, reservation_id: null });
});
@@ -181,6 +182,7 @@ describe('insert probes (task-4-review-shape.md, the reviewer\'s exact repro)',
await t.em.persist(created).flush();
const row = testDb
// test-sql-allow: reads the stored column behind the ORM to prove the database default filled it.
.prepare('SELECT created_at FROM idempotency_keys WHERE key = ? AND user_id = ? AND method = ? AND path = ?')
.get('idem-key-probe-1', user.id, 'POST', '/api/trips') as { created_at: number };
// The DB's own defaultRaw (strftime('%s','now')) filled it in — never NaN, never null.
@@ -10,6 +10,8 @@ import { createTestOrm, type TestOrm } from '../../../helpers/test-orm';
import { createDay, createDayAssignment, createPlace, createTrip, createUser } from '../../../helpers/factories';
import { createTour } from '../../../helpers/tours-repos';
import { Tours } from '../../../../src/db/entities/Tours.entity';
import { TourWaypoints } from '../../../../src/db/entities/TourWaypoints.entity';
import { insertRow, updateRows } from '../../../helpers/factories/rows';
import type { ToursRepository } from '../../../../src/db/repositories/Tours.repository';
const testDb = createSnapshotTestDb();
@@ -30,8 +32,8 @@ function fixture() {
return { trip, other };
}
function addWaypoint(placeId: number, sequence: number): void {
testDb.prepare("INSERT INTO tour_waypoints (place_id, lat, lng, role, sequence) VALUES (?, 47, 11, 'via', ?)").run(placeId, sequence);
async function addWaypoint(placeId: number, sequence: number): Promise<void> {
await insertRow(t, TourWaypoints, { place: placeId, lat: 47, lng: 11, role: 'via', sequence });
}
const METRICS = {
@@ -55,7 +57,7 @@ describe('ToursRepository reads', () => {
createTour(testDb, newer.id, { created_at: '2026-01-02 10:00:00', max_hiking_difficulty: 5 });
createTour(testDb, foreign.id);
createDayAssignment(testDb, createDay(testDb, trip.id).id, older.id);
addWaypoint(newer.id, 0);
await addWaypoint(newer.id, 0);
expect(await repo.listForTrip(trip.id)).toEqual([
{
@@ -149,7 +151,7 @@ describe('ToursRepository writes', () => {
const { trip, other } = fixture();
const source = createPlace(testDb, trip.id);
createTour(testDb, source.id, { created_at: '2026-03-04 05:06:07', distance: 3.5, max_hiking_difficulty: 4 });
testDb.prepare("UPDATE tours SET difficulty = 'T3', wanderer_ref = 'w-1' WHERE place_id = ?").run(source.id);
await updateRows(t, Tours, { place: source.id }, { difficulty: 'T3', wanderer_ref: 'w-1' });
const rows = await repo.listRowsForTrip(trip.id);
expect(rows).toEqual([{
@@ -3,6 +3,7 @@ import { createSnapshotTestDb } from '../../../../helpers/db-mock';
import { resetTestDb } from '../../../../helpers/test-db';
import { createTestOrm, type TestOrm } from '../../../../helpers/test-orm';
import { createTag, createUser } from '../../../../helpers/factories';
import { updateRows } from '../../../../helpers/factories/rows';
import { Tags } from '../../../../../src/db/entities/Tags.entity';
import type { TagsRepository } from '../../../../../src/db/repositories/Tags.repository';
import { findOwnedByUser, listForOwner } from '../../../../../src/db/repositories/_shared/owned-lookup';
@@ -69,7 +70,7 @@ describe('findOwnedByUser', () => {
const { user: owner } = createUser(testDb);
const tag = createTag(testDb, owner.id, { name: 'Old' });
expect((await findOwnedByUser<Tags, 'user'>(tags, tag.id, 'user', owner.id))?.name).toBe('Old');
testDb.prepare('UPDATE tags SET name = ? WHERE id = ?').run('New', tag.id);
await updateRows(t, Tags, { id: tag.id }, { name: 'New' });
const connection = t.orm.em.getConnection();
const spy = vi.spyOn(connection, 'execute');
const found = await findOwnedByUser<Tags, 'user'>(tags, tag.id, 'user', owner.id);
@@ -3,6 +3,7 @@ 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 { makeBucketListItem } from '../../../helpers/factories/atlas';
import { BucketList } from '../../../../src/db/entities/BucketList.entity';
import type { BucketListIdentity } from '../../../../src/db/repositories/BucketList.repository';
@@ -34,15 +35,21 @@ beforeAll(async () => { t = await createTestOrm(testDb); });
beforeEach(() => { resetTestDb(testDb); t.clear(); });
afterAll(async () => { await t.close(); testDb.close(); });
function insertBucketItem(userId: number, row: Partial<BucketListIdentity> & { name: string; notes?: string | null }): number {
const result = testDb
.prepare('INSERT INTO bucket_list (user_id, name, lat, lng, country_code, notes, target_date) VALUES (?, ?, ?, ?, ?, ?, ?)')
.run(userId, row.name, row.lat ?? null, row.lng ?? null, row.country_code ?? null, row.notes ?? null, row.target_date ?? null);
return Number(result.lastInsertRowid);
async function insertBucketItem(userId: number, row: Partial<BucketListIdentity> & { name: string; notes?: string | null }): Promise<number> {
const item = await makeBucketListItem(t, userId, {
name: row.name,
lat: row.lat ?? null,
lng: row.lng ?? null,
country_code: row.country_code ?? null,
notes: row.notes ?? null,
target_date: row.target_date ?? null,
});
return item.id;
}
function legacyFindDuplicate(userId: number, key: BucketListIdentity, excludeId: number | null): number | null {
const row = testDb
// test-sql-allow: the legacy statement is this parity test's oracle and has to run as written.
.prepare(LEGACY_FIND_DUPLICATE)
.get(userId, key.name, key.country_code, key.target_date, key.lat, key.lng, excludeId) as { id: number } | undefined;
return row?.id ?? null;
@@ -55,7 +62,7 @@ async function repoFindDuplicate(userId: number, key: BucketListIdentity, exclud
describe('BucketListRepository.findDuplicate parity (AT31, #1898)', () => {
it('BLDUP-001: an all-NULL fixture row matches an all-NULL key (NULL-safe IS, not NULL = NULL)', async () => {
const { user } = createUser(testDb);
const id = insertBucketItem(user.id, { name: 'Someday Idea', lat: null, lng: null, country_code: null, target_date: null });
const id = await insertBucketItem(user.id, { name: 'Someday Idea', lat: null, lng: null, country_code: null, target_date: null });
const key: BucketListIdentity = { name: 'Someday Idea', lat: null, lng: null, country_code: null, target_date: null };
const legacy = legacyFindDuplicate(user.id, key, null);
@@ -67,7 +74,7 @@ describe('BucketListRepository.findDuplicate parity (AT31, #1898)', () => {
it('BLDUP-002: an all-SET fixture row matches an identical all-SET key', async () => {
const { user } = createUser(testDb);
const id = insertBucketItem(user.id, { name: 'Eiffel Tower', lat: 48.8584, lng: 2.2945, country_code: 'FR', target_date: '2026-06-01' });
const id = await insertBucketItem(user.id, { name: 'Eiffel Tower', lat: 48.8584, lng: 2.2945, country_code: 'FR', target_date: '2026-06-01' });
const key: BucketListIdentity = { name: 'Eiffel Tower', lat: 48.8584, lng: 2.2945, country_code: 'FR', target_date: '2026-06-01' };
const legacy = legacyFindDuplicate(user.id, key, null);
@@ -79,7 +86,7 @@ describe('BucketListRepository.findDuplicate parity (AT31, #1898)', () => {
it('BLDUP-003: name matches case- and whitespace-insensitively (lower(trim()) on both sides)', async () => {
const { user } = createUser(testDb);
const id = insertBucketItem(user.id, { name: 'Eiffel Tower', lat: null, lng: null, country_code: null, target_date: null });
const id = await insertBucketItem(user.id, { name: 'Eiffel Tower', lat: null, lng: null, country_code: null, target_date: null });
const key: BucketListIdentity = { name: ' eiffel TOWER ', lat: null, lng: null, country_code: null, target_date: null };
const legacy = legacyFindDuplicate(user.id, key, null);
@@ -91,7 +98,7 @@ describe('BucketListRepository.findDuplicate parity (AT31, #1898)', () => {
it('BLDUP-004: an all-NULL row does NOT match an all-SET key with the same name (mixed NULL/non-NULL, every column checked)', async () => {
const { user } = createUser(testDb);
insertBucketItem(user.id, { name: 'Tower', lat: null, lng: null, country_code: null, target_date: null });
await insertBucketItem(user.id, { name: 'Tower', lat: null, lng: null, country_code: null, target_date: null });
const key: BucketListIdentity = { name: 'Tower', lat: 48.8584, lng: 2.2945, country_code: 'FR', target_date: '2026-06-01' };
const legacy = legacyFindDuplicate(user.id, key, null);
@@ -103,7 +110,7 @@ describe('BucketListRepository.findDuplicate parity (AT31, #1898)', () => {
it('BLDUP-005: excludeId excludes the row itself (the update-against-self no-op path) — a bound NULL excludeId (create) never excludes anything', async () => {
const { user } = createUser(testDb);
const id = insertBucketItem(user.id, { name: 'Colosseum', lat: 41.8902, lng: 12.4922, country_code: 'IT', target_date: null });
const id = await insertBucketItem(user.id, { name: 'Colosseum', lat: 41.8902, lng: 12.4922, country_code: 'IT', target_date: null });
const key: BucketListIdentity = { name: 'Colosseum', lat: 41.8902, lng: 12.4922, country_code: 'IT', target_date: null };
// update-against-self: excluding the row's own id finds nothing.
@@ -126,8 +133,8 @@ describe('BucketListRepository.findDuplicate parity (AT31, #1898)', () => {
// Restored immediately after observing the failure; `findDuplicate` itself is
// unchanged in this commit — see this task's report for the per-column results.
const { user } = createUser(testDb);
const genericIdea = insertBucketItem(user.id, { name: 'Tower', lat: null, lng: null, country_code: null, target_date: null });
const pinnedPlace = insertBucketItem(user.id, { name: 'Tower', lat: 48.8584, lng: 2.2945, country_code: null, target_date: null });
const genericIdea = await insertBucketItem(user.id, { name: 'Tower', lat: null, lng: null, country_code: null, target_date: null });
const pinnedPlace = await insertBucketItem(user.id, { name: 'Tower', lat: 48.8584, lng: 2.2945, country_code: null, target_date: null });
const key: BucketListIdentity = { name: 'Tower', lat: 48.8584, lng: 2.2945, country_code: null, target_date: null };
const legacy = legacyFindDuplicate(user.id, key, null);
@@ -143,8 +150,8 @@ describe('BucketListRepository.findDuplicate parity (AT31, #1898)', () => {
// object and re-running this suite made this test go red (`ideaAnywhere` was returned
// in place of `franceIdea`). Restored immediately; `findDuplicate` unchanged.
const { user } = createUser(testDb);
const ideaAnywhere = insertBucketItem(user.id, { name: 'Castle', lat: null, lng: null, country_code: null, target_date: null });
const franceIdea = insertBucketItem(user.id, { name: 'Castle', lat: null, lng: null, country_code: 'FR', target_date: null });
const ideaAnywhere = await insertBucketItem(user.id, { name: 'Castle', lat: null, lng: null, country_code: null, target_date: null });
const franceIdea = await insertBucketItem(user.id, { name: 'Castle', lat: null, lng: null, country_code: 'FR', target_date: null });
const key: BucketListIdentity = { name: 'Castle', lat: null, lng: null, country_code: 'FR', target_date: null };
const legacy = legacyFindDuplicate(user.id, key, null);
@@ -160,8 +167,8 @@ describe('BucketListRepository.findDuplicate parity (AT31, #1898)', () => {
// object and re-running this suite made this test go red (`undated` was returned in
// place of `dated`). Restored immediately; `findDuplicate` unchanged.
const { user } = createUser(testDb);
const undated = insertBucketItem(user.id, { name: 'Museum', lat: null, lng: null, country_code: null, target_date: null });
const dated = insertBucketItem(user.id, { name: 'Museum', lat: null, lng: null, country_code: null, target_date: '2026-09-01' });
const undated = await insertBucketItem(user.id, { name: 'Museum', lat: null, lng: null, country_code: null, target_date: null });
const dated = await insertBucketItem(user.id, { name: 'Museum', lat: null, lng: null, country_code: null, target_date: '2026-09-01' });
const key: BucketListIdentity = { name: 'Museum', lat: null, lng: null, country_code: null, target_date: '2026-09-01' };
const legacy = legacyFindDuplicate(user.id, key, null);
+38 -33
View File
@@ -33,6 +33,10 @@ import { AirtrailClient, AirtrailRequestError, type AirtrailFlightRaw } from '..
import { AirtrailImportService } from '../../../src/nest/integrations/airtrail-import.service';
import { PermissionsService } from '../../../src/nest/permissions/permissions.service';
import { createTestUnitOfWork, createTestAppSettingsRepo } from '../../helpers/test-uow';
import { createTestOrm, type TestOrm } from '../../helpers/test-orm';
import { findRows, updateRows } from '../../helpers/factories/rows';
import { Users } from '../../../src/db/entities/Users.entity';
import { Reservations } from '../../../src/db/entities/Reservations.entity';
// The permissions cache is module-scoped, so a write through any instance is
// what the tool's own checkPermission call reads back.
@@ -60,7 +64,13 @@ beforeEach(async () => {
await savePermissions({ reservation_edit: 'trip_member' });
});
afterAll(() => {
let orm: TestOrm;
beforeAll(async () => {
orm = await createTestOrm(testDb);
});
afterAll(async () => {
await orm.close();
testDb.close();
});
@@ -70,8 +80,8 @@ async function withHarness(userId: number, fn: (h: McpHarness) => Promise<void>,
}
/** The stored connection, plaintext key: decrypt_api_key passes legacy plaintext straight through. */
function connectAirtrail(userId: number, url = 'https://airtrail.example.com') {
testDb.prepare('UPDATE users SET airtrail_url = ?, airtrail_api_key = ? WHERE id = ?').run(url, 'plain-test-key', userId);
async function connectAirtrail(userId: number, url = 'https://airtrail.example.com'): Promise<void> {
await updateRows(orm, Users, { id: userId }, { airtrail_url: url, airtrail_api_key: 'plain-test-key' });
}
const ZRH = { id: 1, icao: 'LSZH', iata: 'ZRH', name: 'Zurich', lat: 47.458, lon: 8.548, tz: 'Europe/Zurich', country: 'CH' };
@@ -99,12 +109,7 @@ function flight(overrides: Partial<AirtrailFlightRaw> & { id: number }): Airtrai
}
function reservationRows(tripId: number) {
return testDb.prepare(
'SELECT id, title, type, external_source, external_id, external_owner_user_id, sync_enabled, metadata FROM reservations WHERE trip_id = ? ORDER BY id',
).all(tripId) as {
id: number; title: string; type: string; external_source: string | null; external_id: string | null;
external_owner_user_id: number | null; sync_enabled: number | null; metadata: string | null;
}[];
return findRows(orm, Reservations, { trip: tripId }, { id: 'asc' });
}
// ---------------------------------------------------------------------------
@@ -114,7 +119,7 @@ function reservationRows(tripId: number) {
describe('Tool: list_airtrail_flights', () => {
it('returns the caller flights, normalized and oldest departure first', async () => {
const { user } = createUser(testDb);
connectAirtrail(user.id);
await connectAirtrail(user.id);
listFlightsMock.mockResolvedValue([
flight({ id: 22, date: '2026-09-20', departure: '2026-09-20T06:00:00Z', flightNumber: 'LH400', from: FRA, to: JFK }),
flight({ id: 11 }),
@@ -141,7 +146,7 @@ describe('Tool: list_airtrail_flights', () => {
it('narrows to a date window and keeps undated flights', async () => {
const { user } = createUser(testDb);
connectAirtrail(user.id);
await connectAirtrail(user.id);
listFlightsMock.mockResolvedValue([
flight({ id: 1, date: '2026-08-01', departure: '2026-08-01T06:00:00Z' }),
flight({ id: 2, date: '2026-09-10', departure: null }),
@@ -167,7 +172,7 @@ describe('Tool: list_airtrail_flights', () => {
it('falls back to its own wording when AirTrail fails without a message', async () => {
const { user } = createUser(testDb);
connectAirtrail(user.id);
await connectAirtrail(user.id);
listFlightsMock.mockRejectedValue(new Error(''));
await withHarness(user.id, async (h) => {
@@ -179,7 +184,7 @@ describe('Tool: list_airtrail_flights', () => {
it('caps the result and says it was truncated', async () => {
const { user } = createUser(testDb);
connectAirtrail(user.id);
await connectAirtrail(user.id);
listFlightsMock.mockResolvedValue(
Array.from({ length: 5 }, (_, i) => flight({ id: i + 1, departure: `2026-09-1${i}T06:00:00Z` })),
);
@@ -206,7 +211,7 @@ describe('Tool: list_airtrail_flights', () => {
it('passes an upstream failure through as the tool error', async () => {
const { user } = createUser(testDb);
connectAirtrail(user.id);
await connectAirtrail(user.id);
listFlightsMock.mockRejectedValue(new AirtrailRequestError('AirTrail list failed (HTTP 500)', 500));
await withHarness(user.id, async (h) => {
@@ -218,7 +223,7 @@ describe('Tool: list_airtrail_flights', () => {
it('is hidden while the airtrail addon is off', async () => {
const { user } = createUser(testDb);
connectAirtrail(user.id);
await connectAirtrail(user.id);
setAddonEnabled(testDb, ADDON_IDS.AIRTRAIL, false);
await withHarness(user.id, async (h) => {
@@ -232,7 +237,7 @@ describe('Tool: list_airtrail_flights', () => {
it('rides reservations:read and is hidden from a token without it', async () => {
const { user } = createUser(testDb);
connectAirtrail(user.id);
await connectAirtrail(user.id);
await withHarness(user.id, async (h) => {
expect((await h.client.listTools()).tools.map(t => t.name)).toContain('list_airtrail_flights');
@@ -251,7 +256,7 @@ describe('Tool: list_airtrail_flights', () => {
describe('Tool: import_airtrail_flights', () => {
it('imports the listed flights as linked flight bookings', async () => {
const { user } = createUser(testDb);
connectAirtrail(user.id);
await connectAirtrail(user.id);
const trip = createTrip(testDb, user.id, { start_date: '2026-09-10', end_date: '2026-09-12' });
listFlightsMock.mockResolvedValue([flight({ id: 11 }), flight({ id: 12, flightNumber: 'LH1202' })]);
@@ -268,7 +273,7 @@ describe('Tool: import_airtrail_flights', () => {
expect(data.skipped).toEqual([]);
});
const rows = reservationRows(trip.id);
const rows = await reservationRows(trip.id);
expect(rows).toHaveLength(2);
expect(rows[0]).toMatchObject({
title: 'LH1201', type: 'flight', external_source: 'airtrail', external_id: '11',
@@ -280,7 +285,7 @@ describe('Tool: import_airtrail_flights', () => {
it('skips a flight that is already linked to the trip', async () => {
const { user } = createUser(testDb);
connectAirtrail(user.id);
await connectAirtrail(user.id);
const trip = createTrip(testDb, user.id, { start_date: '2026-09-10', end_date: '2026-09-12' });
listFlightsMock.mockResolvedValue([flight({ id: 11 })]);
@@ -291,12 +296,12 @@ describe('Tool: import_airtrail_flights', () => {
expect(data.imported).toEqual([]);
expect(data.skipped).toEqual([{ flightId: '11', reason: 'already-imported' }]);
});
expect(reservationRows(trip.id)).toHaveLength(1);
expect(await reservationRows(trip.id)).toHaveLength(1);
});
it('joins a connection into one multi-leg booking detached from sync', async () => {
const { user } = createUser(testDb);
connectAirtrail(user.id);
await connectAirtrail(user.id);
const trip = createTrip(testDb, user.id, { start_date: '2026-09-10', end_date: '2026-09-12' });
listFlightsMock.mockResolvedValue([
flight({ id: 11 }),
@@ -315,7 +320,7 @@ describe('Tool: import_airtrail_flights', () => {
expect(data.imported).toEqual(['11', '12']);
});
const rows = reservationRows(trip.id);
const rows = await reservationRows(trip.id);
expect(rows).toHaveLength(1);
expect(rows[0].sync_enabled).toBe(0);
expect(JSON.parse(rows[0].metadata ?? '{}').airtrail_ids).toEqual(['11', '12']);
@@ -323,7 +328,7 @@ describe('Tool: import_airtrail_flights', () => {
it('refuses a connection naming a flight that is not being imported', async () => {
const { user } = createUser(testDb);
connectAirtrail(user.id);
await connectAirtrail(user.id);
const trip = createTrip(testDb, user.id, { start_date: '2026-09-10', end_date: '2026-09-12' });
listFlightsMock.mockResolvedValue([flight({ id: 11 })]);
@@ -335,13 +340,13 @@ describe('Tool: import_airtrail_flights', () => {
expect(result.isError).toBe(true);
expect((result.content as any)[0].text).toContain('flight 99');
});
expect(reservationRows(trip.id)).toHaveLength(0);
expect(await reservationRows(trip.id)).toHaveLength(0);
expect(listFlightsMock).not.toHaveBeenCalled();
});
it('refuses more than fifty flights in one call', async () => {
const { user } = createUser(testDb);
connectAirtrail(user.id);
await connectAirtrail(user.id);
const trip = createTrip(testDb, user.id, { start_date: '2026-09-10', end_date: '2026-09-12' });
await withHarness(user.id, async (h) => {
@@ -352,7 +357,7 @@ describe('Tool: import_airtrail_flights', () => {
expect(result.isError).toBe(true);
expect((result.content as any)[0].text).toContain('at most 50');
});
expect(reservationRows(trip.id)).toHaveLength(0);
expect(await reservationRows(trip.id)).toHaveLength(0);
expect(listFlightsMock).not.toHaveBeenCalled();
});
@@ -368,12 +373,12 @@ describe('Tool: import_airtrail_flights', () => {
expect(result.isError).toBe(true);
expect((result.content as any)[0].text).toContain('AirTrail is not connected');
});
expect(reservationRows(trip.id)).toHaveLength(0);
expect(await reservationRows(trip.id)).toHaveLength(0);
});
it('falls back to its own wording when the import fails without a message', async () => {
const { user } = createUser(testDb);
connectAirtrail(user.id);
await connectAirtrail(user.id);
const trip = createTrip(testDb, user.id, { start_date: '2026-09-10', end_date: '2026-09-12' });
const importMock = vi.spyOn(AirtrailImportService.prototype, 'importAirtrailFlights')
.mockRejectedValue(new Error(''));
@@ -402,7 +407,7 @@ describe('Tool: import_airtrail_flights', () => {
const trip = createTrip(testDb, owner.id, { start_date: '2026-09-10', end_date: '2026-09-12' });
addTripMember(testDb, trip.id, demo.id);
addTripMember(testDb, trip.id, member.id);
for (const u of [owner, demo, stranger, member]) connectAirtrail(u.id);
for (const u of [owner, demo, stranger, member]) await connectAirtrail(u.id);
listFlightsMock.mockResolvedValue([flight({ id: 11 })]);
process.env.DEMO_MODE = 'true';
@@ -426,14 +431,14 @@ describe('Tool: import_airtrail_flights', () => {
expect((result.content as any)[0].text).toContain('permission');
});
expect(reservationRows(trip.id)).toHaveLength(0);
expect(await reservationRows(trip.id)).toHaveLength(0);
expect(broadcastMock).not.toHaveBeenCalled();
expect(listFlightsMock).not.toHaveBeenCalled();
});
it('is hidden while the airtrail addon is off', async () => {
const { user } = createUser(testDb);
connectAirtrail(user.id);
await connectAirtrail(user.id);
const trip = createTrip(testDb, user.id, { start_date: '2026-09-10', end_date: '2026-09-12' });
setAddonEnabled(testDb, ADDON_IDS.AIRTRAIL, false);
@@ -447,12 +452,12 @@ describe('Tool: import_airtrail_flights', () => {
expect(result.isError).toBe(true);
expect((result.content as any)[0].text).toContain('not found');
});
expect(reservationRows(trip.id)).toHaveLength(0);
expect(await reservationRows(trip.id)).toHaveLength(0);
});
it('rides reservations:write and is hidden from a read-only token', async () => {
const { user } = createUser(testDb);
connectAirtrail(user.id);
await connectAirtrail(user.id);
await withHarness(user.id, async (h) => {
expect((await h.client.listTools()).tools.map(t => t.name)).toContain('import_airtrail_flights');
+14 -4
View File
@@ -22,13 +22,23 @@ vi.mock('../../../src/websocket', () => ({ broadcast: vi.fn() }));
import { resetTestDb } from '../../helpers/test-db';
import { createUser, createTrip } from '../../helpers/factories';
import { createMcpHarness, parseToolResult, type McpHarness } from '../../helpers/mcp-harness';
import { createTestOrm, type TestOrm } from '../../helpers/test-orm';
import { findRow } from '../../helpers/factories/rows';
import { ShareTokens } from '../../../src/db/entities/ShareTokens.entity';
beforeEach(() => {
resetTestDb(testDb);
delete process.env.DEMO_MODE;
});
afterAll(() => {
let orm: TestOrm;
beforeAll(async () => {
orm = await createTestOrm(testDb);
});
afterAll(async () => {
await orm.close();
testDb.close();
});
@@ -78,8 +88,8 @@ describe('Tool: create_share_link', () => {
await withHarness(user.id, async (h) => {
const first = parseToolResult(await h.client.callTool({ name: 'create_share_link', arguments: { tripId: trip.id } })) as any;
expect(first.created).toBe(true);
const row = testDb.prepare('SELECT share_map, share_bookings, share_packing, share_budget, share_collab FROM share_tokens WHERE trip_id = ?').get(trip.id) as any;
expect(row).toEqual({ share_map: 1, share_bookings: 1, share_packing: 0, share_budget: 0, share_collab: 0 });
const row = await findRow(orm, ShareTokens, { trip: trip.id });
expect(row).toMatchObject({ share_map: 1, share_bookings: 1, share_packing: 0, share_budget: 0, share_collab: 0 });
const second = parseToolResult(await h.client.callTool({ name: 'create_share_link', arguments: { tripId: trip.id, share_budget: true } })) as any;
expect(second.created).toBe(false);
expect(second.token).toBe(first.token);
@@ -110,7 +120,7 @@ describe('Tool: delete_share_link', () => {
await h.client.callTool({ name: 'create_share_link', arguments: { tripId: trip.id } });
const result = parseToolResult(await h.client.callTool({ name: 'delete_share_link', arguments: { tripId: trip.id } })) as any;
expect(result.success).toBe(true);
expect(testDb.prepare('SELECT id FROM share_tokens WHERE trip_id = ?').get(trip.id)).toBeUndefined();
expect(await findRow(orm, ShareTokens, { trip: trip.id })).toBeNull();
});
const { user: stranger } = createUser(testDb);
await withHarness(stranger.id, async (h) => {
@@ -24,6 +24,7 @@ vi.mock('../../../src/utils/ssrfGuard', () => ({
import { resetTestDb } from '../../helpers/test-db';
import { createTestOrm, type TestOrm } from '../../helpers/test-orm';
import { createUser } from '../../helpers/factories';
import { readUser } from '../../helpers/factories/users';
import { Users } from '../../../src/db/entities/Users.entity';
import { AuditLog } from '../../../src/db/entities/AuditLog.entity';
import type { UsersRepository } from '../../../src/db/repositories/Users.repository';
@@ -50,8 +51,8 @@ beforeEach(() => {
});
afterAll(async () => { await t.close(); testDb.close(); });
function rawAirtrailKey(userId: number): string | null {
return (testDb.prepare('SELECT airtrail_api_key FROM users WHERE id = ?').get(userId) as { airtrail_api_key: string | null }).airtrail_api_key;
async function rawAirtrailKey(userId: number): Promise<string | null> {
return (await readUser(t, userId)).airtrail_api_key ?? null;
}
describe('AirtrailService — at-rest encryption (3h L3)', () => {
@@ -60,7 +61,7 @@ describe('AirtrailService — at-rest encryption (3h L3)', () => {
await svc.saveSettings(user.id, 'https://airtrail.example.test', 'super-secret-key', false, false, null);
const stored = rawAirtrailKey(user.id);
const stored = await rawAirtrailKey(user.id);
expect(stored).toMatch(/^enc:v1:/);
expect(stored).not.toContain('super-secret-key');
@@ -71,10 +72,10 @@ describe('AirtrailService — at-rest encryption (3h L3)', () => {
it('AIRTRAIL-SVC-ENC-002: a second save with a new key re-encrypts to a different ciphertext that still round-trips', async () => {
const { user } = createUser(testDb);
await svc.saveSettings(user.id, 'https://airtrail.example.test', 'first-key', false, false, null);
const first = rawAirtrailKey(user.id);
const first = await rawAirtrailKey(user.id);
await svc.saveSettings(user.id, 'https://airtrail.example.test', 'second-key', false, false, null);
const second = rawAirtrailKey(user.id);
const second = await rawAirtrailKey(user.id);
expect(second).toMatch(/^enc:v1:/);
expect(second).not.toBe(first);
@@ -3,6 +3,7 @@ import { createSnapshotTestDb } from '../../../helpers/db-mock';
import { resetTestDb } from '../../../helpers/test-db';
import { createTestOrm, type TestOrm } from '../../../helpers/test-orm';
import { createTrip, createUser } from '../../../helpers/factories';
import { countRows } from '../../../helpers/factories/rows';
import { Days } from '../../../../src/db/entities/Days.entity';
import type { DaysRepository } from '../../../../src/db/repositories/Days.repository';
import { UnitOfWork } from '../../../../src/nest/database/unit-of-work';
@@ -29,8 +30,7 @@ beforeAll(async () => {
beforeEach(() => { resetTestDb(testDb); t.clear(); });
afterAll(async () => { await t.close(); testDb.close(); });
const countDays = (tripId: number) =>
(testDb.prepare('SELECT COUNT(*) AS n FROM days WHERE trip_id = ?').get(tripId) as { n: number }).n;
const countDays = (tripId: number): Promise<number> => countRows(t, Days, { trip: tripId });
describe('UnitOfWork.transactional', () => {
it('UOW-001: a throw rolls every statement back', async () => {
@@ -42,7 +42,7 @@ describe('UnitOfWork.transactional', () => {
throw new Error('boom');
}),
).rejects.toThrow('boom');
expect(countDays(trip.id)).toBe(0);
expect(await countDays(trip.id)).toBe(0);
});
it('UOW-002: an inner transaction is a savepoint — its failure leaves the outer writes intact', async () => {
@@ -57,7 +57,7 @@ describe('UnitOfWork.transactional', () => {
})
.catch(() => undefined);
});
expect(countDays(trip.id)).toBe(1);
expect(await countDays(trip.id)).toBe(1);
});
it('UOW-003: concurrent transactions serialise on the one connection instead of throwing', async () => {
@@ -72,7 +72,7 @@ describe('UnitOfWork.transactional', () => {
order.push(`${label}:out`);
});
await Promise.all([tx('a', 1), tx('b', 2)]);
expect(countDays(trip.id)).toBe(2);
expect(await countDays(trip.id)).toBe(2);
// The second never enters before the first leaves.
expect(order.indexOf('b:in')).toBeGreaterThan(order.indexOf('a:out'));
});
@@ -104,12 +104,14 @@ describe('Google route import', () => {
const { service, places, assignments } = await setup();
routeTestDb.exec('CREATE TABLE stops (name TEXT)');
places.create.mockImplementation((_trip, stop) => {
// test-sql-allow: the stops probe table exists only for this test; no entity maps it.
routeTestDb.prepare('INSERT INTO stops VALUES (?)').run(stop.name);
return { id: stop.name };
});
assignments.createAssignment.mockImplementationOnce(() => ({ dayId: 3, placeId: 'Munich' })).mockImplementationOnce(() => { throw new Error('write failed'); });
try {
await expect(service.import(1, 7, input)).rejects.toThrow('write failed');
// test-sql-allow: reads back the unmapped stops probe table on the same handle.
expect(routeTestDb.prepare('SELECT * FROM stops').all()).toEqual([]);
expect(places.broadcast).not.toHaveBeenCalled();
expect(assignments.broadcast).not.toHaveBeenCalled();
@@ -6,7 +6,7 @@
* the test. The SSRF guard is mocked the same way maps.service.test.ts mocks it.
*
* Rebuilt on PlaceDetailsCacheRepository/AppSettingsRepository (Plan 3c Task
* 1, R8's rewrite list): the legacy version stubbed `db.prepare(sql).{get,run}`
* 1, R8's rewrite list): the legacy version stubbed the raw `db.prepare` handle (`get`, `run`)
* and branched on whether the SQL text contained `'place_details_cache'` to
* tell the settings read (PE1) apart from the cache read (PE2)/write (PE3).
* That coupling cannot survive the service calling two named repository
@@ -16,6 +16,7 @@ import { UnitOfWork } from '../../../src/nest/database/unit-of-work';
import { createTestOrm, type TestOrm } from '../../helpers/test-orm';
import { createSnapshotTestDb } from '../../helpers/db-mock';
import { createUser } from '../../helpers/factories';
import { updateRows } from '../../helpers/factories/rows';
import { AppSettings } from '../../../src/db/entities/AppSettings.entity';
import { Users } from '../../../src/db/entities/Users.entity';
@@ -38,7 +39,7 @@ const ctx = (actingUserId: number | undefined): PluginRpcContext => ({
* Trip 1 belongs to user 42 and only user 42 may reach it, mirroring rpc-host.test.ts.
*
* `overrides.role` drives the stubbed `UsersRepository.getRole` (PG3/PG4, Plan 3j
* Task 1) the same way it used to drive `db.prepare(...).get()`: omitted means a
* Task 1) the same way it used to drive the raw `db.prepare` stub: omitted means a
* normal row with role 'user'; explicitly `undefined` (or `null`) means "no row",
* which `getRole`'s own `string | null` contract folds into a single `null` — the
* `users` column is `NOT NULL DEFAULT 'user'`, so a present row can never actually
@@ -222,7 +223,7 @@ describe('PluginGuards — canEditAs/canCreateAs against a real UsersRepository
});
it('PGUARD-025 mutation proof: promoting that SAME member to admin in the real users table flips PGUARD-024 to pass', async () => {
testDb.prepare('UPDATE users SET role = ? WHERE id = ?').run('admin', memberId);
await updateRows(orm, Users, { id: memberId }, { role: 'admin' });
expect(await guards.canEditAs('trip_delete', 1, memberId)).toBe(true);
});
@@ -32,7 +32,7 @@ function controller(invoke: () => unknown, providers = ['ev-plug']) {
} as unknown as PluginHooks;
// Plan 3j Task 3: `declaredProfiles` now takes `PluginsRepository`, not the raw
// connection — `findCapabilities` reads the same `capabilitiesRow` fixture the
// old `connection.prepare(...).get()` stub above read.
// old raw `connection.prepare` stub above read.
const plugins = {
findCapabilities: vi.fn(async () => (capabilitiesRow.value === undefined ? null : capabilitiesRow.value)),
} as unknown as PluginsRepository;
@@ -46,7 +46,7 @@ function guardsFor() {
prepare: vi.fn(() => ({ get: () => ({ role: 'user' }), all: () => [] })),
} as unknown as TripsRepository;
// Plan 3c Task 7: TripsRpc's RP1-RP6 resolve `EntityManager.getRepository(...)`
// now, not `db.prepare(...)` — a generic fake repository (same shape regardless
// now, not a raw `db.prepare` stub — a generic fake repository (same shape regardless
// of which entity class was requested) covers every RP method these fallback
// cases exercise, none of which assert on the repository's specific return value.
const em = {
@@ -24,6 +24,7 @@ import { TrekPhotos } from '../../../src/db/entities/TrekPhotos.entity';
import { TripPhotos } from '../../../src/db/entities/TripPhotos.entity';
import { JourneyPhotos } from '../../../src/db/entities/JourneyPhotos.entity';
import { createTestOrm, type TestOrm } from '../../helpers/test-orm';
import { deleteRows } from '../../helpers/factories/rows';
// Was photos.bridge, deleted with the other three that had no consumer outside
// the container. These call the repository directly now.
@@ -38,8 +39,8 @@ beforeAll(async () => {
trekPhotos = new TrekPhotoRegistrationService(t.repo(TrekPhotos), t.repo(TripPhotos), t.repo(JourneyPhotos));
});
beforeEach(() => {
testDb.prepare('DELETE FROM trek_photos').run();
beforeEach(async () => {
await deleteRows(t, TrekPhotos);
});
afterAll(async () => {
@@ -49,6 +50,7 @@ afterAll(async () => {
describe('trek_photos media_type', () => {
it('migration added media_type (default image) and duration_ms', () => {
// test-sql-allow: the column list comes from PRAGMA table_info, which no entity or repository maps.
const cols = (testDb.prepare("PRAGMA table_info('trek_photos')").all() as { name: string }[]).map(c => c.name);
expect(cols).toContain('media_type');
expect(cols).toContain('duration_ms');
+1 -1
View File
@@ -77,7 +77,7 @@ export function build(opts: { allow?: (action: string) => boolean; updateThrows?
// Plan 3c Task 7: RP1-RP6's SQL-text-keyed `db.prepare` stub is gone —
// every raw statement moved to a repository method, so this is a
// repository-level stub, keyed on the entity CLASS `em.getRepository(...)`
// was asked for, matching the shape the legacy `db.prepare(sql)` branch
// was asked for, matching the shape the legacy raw `db.prepare` branch
// covered: `Trips.findRaw` (RP1/getById), `Places.listForTripOrdered`
// (RP2/getPlaces), `TripMembers.listRawUsernameAndDisplayName`
// (RP3/members), `Users.getRole` (RP4/update) and `.findIdAndEmail`