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

The suites with one to three raw statements left now seed and read their
rows through the ORM factories instead of hand-written SQLite on the
better-sqlite3 handle. A legacy statement that serves as a parity oracle,
the schema introspection and the bare backup file keep their raw SQL and
say why with a test-sql-allow marker. Two database mocks still answered a
getPlaceWithTags the module no longer exports; that dead branch goes too,
and the budget seam suite stubs the reservations repository directly
instead of a fake prepare().
This commit is contained in:
Maurice
2026-10-08 15:25:15 +02:00
parent feda6ffdbc
commit b2f2aef016
29 changed files with 489 additions and 403 deletions
+21 -9
View File
@@ -47,7 +47,11 @@ import { PermissionsService } from '../../src/nest/permissions/permissions.servi
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';
import { createTestMikroOrmModule, createTestOrm } from '../helpers/test-orm';
import { makeUser } from '../helpers/factories/users';
import { makeTrip } from '../helpers/factories/trips';
import { upsertRow } from '../helpers/factories/rows';
import { Addons } from '../../src/db/entities/Addons.entity';
/** A one-page PDF whose text layer holds `lines`, in the standard Helvetica. */
function pdfWithText(lines: string[]): Buffer {
@@ -134,15 +138,23 @@ describe('Booking import e2e (#2477): a schema-bound provider on the upload rout
};
beforeAll(async () => {
db.prepare(
"INSERT INTO users (id, username, email, password_hash, role, password_version) VALUES (1, 'e2e-user', 'e2e@example.test', 'x', 'user', 0)",
).run();
tripId = Number(db.prepare("INSERT INTO trips (user_id, title) VALUES (1, 'Albania')").run().lastInsertRowid);
const orm = await createTestOrm(db);
await makeUser(orm, { id: 1, username: 'e2e-user', email: 'e2e@example.test', role: 'user', password_version: 0 });
tripId = (await makeTrip(orm, 1, { title: 'Albania' })).id;
// The instance config the reporter ran: Gemini behind the "openai" provider.
db.prepare(
`INSERT INTO addons (id, name, type, enabled, config) VALUES ('llm_parsing', 'AI Parsing', 'integration', 1, ?)
ON CONFLICT(id) DO UPDATE SET enabled = 1, config = excluded.config`,
).run(JSON.stringify({ provider: 'openai', model: 'gemini-3.5-flash', baseUrl: 'https://generativelanguage.googleapis.com/v1beta/openai' }));
await upsertRow(
orm,
Addons,
{
id: 'llm_parsing',
name: 'AI Parsing',
type: 'integration',
enabled: true,
config: { provider: 'openai', model: 'gemini-3.5-flash', baseUrl: 'https://generativelanguage.googleapis.com/v1beta/openai' },
},
['enabled', 'config'],
);
await orm.close();
app = await build();
vi.spyOn(app.get(PermissionsService), 'checkPermission').mockResolvedValue(true);
server = app.getHttpServer();
+7 -5
View File
@@ -58,7 +58,8 @@ import { db } from '../../src/db/database';
import { createUser } from '../helpers/factories';
import { AuthModule } from '../../src/nest/auth/auth.module';
import { TestUnitOfWorkModule } from '../helpers/test-uow';
import { createTestMikroOrmModule } from '../helpers/test-orm';
import { createTestMikroOrmModule, createTestOrm } from '../helpers/test-orm';
import { setAppSetting } from '../helpers/factories/settings';
import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
import { ZodValidationPipe } from '../../src/nest/common/zod-validation.pipe';
@@ -105,10 +106,11 @@ describe('Mailer e2e: the header logo in a mail on the wire (#2507)', () => {
beforeAll(async () => {
email = createUser(db as never, { username: 'mail-e2e', email: 'mail-e2e@example.test' }).user.email;
const setting = db.prepare('INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)');
setting.run('smtp_host', 'mail.internal.example');
setting.run('smtp_port', '587');
setting.run('smtp_from', 'trek@example.test');
const orm = await createTestOrm(db);
await setAppSetting(orm, 'smtp_host', 'mail.internal.example');
await setAppSetting(orm, 'smtp_port', '587');
await setAppSetting(orm, 'smtp_from', 'trek@example.test');
await orm.close();
app = await build();
server = app.getHttpServer();
+11 -4
View File
@@ -37,7 +37,11 @@ import { RateLimitService } from '../../src/nest/common/rate-limit.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';
import { createTestMikroOrmModule, createTestOrm, type TestOrm } from '../helpers/test-orm';
import { deleteRows } from '../helpers/factories/rows';
import { setAppSetting } from '../helpers/factories/settings';
import { AppSettings } from '../../src/db/entities/AppSettings.entity';
import { PlaceDetailsCache } from '../../src/db/entities/PlaceDetailsCache.entity';
const BODY = { lat: 50.9, lng: 6.96, name: 'Museum Ludwig', placeId: 'way:12345' };
@@ -45,6 +49,7 @@ describe('Place enrichment e2e (real auth guard + real validation pipe)', () =>
let server: Server;
let app: Awaited<ReturnType<typeof build>>;
let maps: MapsService;
let orm: TestOrm;
async function build() {
const moduleRef = await Test.createTestingModule({ imports: [await TestUnitOfWorkModule.forRoot(db), await createTestMikroOrmModule(db), PlaceEnrichmentModule] }).compile();
@@ -60,6 +65,7 @@ describe('Place enrichment e2e (real auth guard + real validation pipe)', () =>
beforeAll(async () => {
seedUser(db as never, { id: 1 });
orm = await createTestOrm(db);
app = await build();
server = app.getHttpServer();
@@ -75,11 +81,12 @@ describe('Place enrichment e2e (real auth guard + real validation pipe)', () =>
afterAll(async () => {
await app.close();
await orm.close();
});
beforeEach(async () => {
db.prepare('DELETE FROM app_settings').run();
db.prepare('DELETE FROM place_details_cache').run();
await deleteRows(orm, AppSettings);
await deleteRows(orm, PlaceDetailsCache);
await app.get(RateLimitService).reset('place_enrichment');
// spyOn hands back the same spy on a second call, so the call counts carry
// over between tests unless they are cleared explicitly.
@@ -159,7 +166,7 @@ describe('Place enrichment e2e (real auth guard + real validation pipe)', () =>
});
it('200 with the disabled envelope once an admin switches enrichment off', async () => {
db.prepare("INSERT INTO app_settings (key, value) VALUES ('places_enrich_enabled', 'false')").run();
await setAppSetting(orm, 'places_enrich_enabled', 'false');
const res = await request(server).post('/api/maps/enrichment').set('Cookie', sessionCookie(1)).send(BODY);
+13 -9
View File
@@ -42,7 +42,9 @@ vi.mock('../../src/nest/plugins/kill-switch', () => ({ pluginsEnabled: () => plu
import { db } from '../../src/db/database';
import { TestUnitOfWorkModule } from '../helpers/test-uow';
import { createTestMikroOrmModule } from '../helpers/test-orm';
import { createTestMikroOrmModule, createTestOrm } from '../helpers/test-orm';
import { makeUser } from '../helpers/factories/users';
import { makePlugin } from '../helpers/factories/plugins';
const trailheads = { id: 'trailheads', label: 'Trailheads', icon: 'Signpost', color: '#2f855a' };
const query = { pluginId: 'trail-finder', category: 'trailheads', south: '47', west: '11', north: '47.5', east: '11.5', lang: 'de' };
@@ -66,14 +68,16 @@ describe('Plugin POIs e2e (real guard chain + temp SQLite)', () => {
}
beforeAll(async () => {
// harness.ts's seedUser() omits password_hash, which the migrated schema
// requires NOT NULL (addons.e2e.test.ts does the same) — the same SeededUser
// shape sessionCookie(1) needs, with the hash filled in.
db.prepare(
"INSERT INTO users (id, username, email, password_hash, role, password_version) VALUES (1, 'e2e-user', 'e2e@example.test', 'x', 'user', 0)",
).run();
db.prepare("INSERT INTO plugins (id, name, status, capabilities, granted_permissions) VALUES (?, ?, 'active', ?, ?)")
.run('trail-finder', 'Trail Finder', JSON.stringify({ poiCategories: [trailheads] }), JSON.stringify(['hook:poi-category-provider']));
// The user sessionCookie(1) is signed for, pinned to id 1.
const orm = await createTestOrm(db);
await makeUser(orm, { id: 1, username: 'e2e-user', email: 'e2e@example.test', role: 'user', password_version: 0 });
await makePlugin(orm, 'trail-finder', {
name: 'Trail Finder',
status: 'active',
capabilities: JSON.stringify({ poiCategories: [trailheads] }),
granted_permissions: JSON.stringify(['hook:poi-category-provider']),
});
await orm.close();
app = await build();
const hooks = app.get(PluginHooks, { strict: false });
providersOf = vi.spyOn(hooks, 'providersOf');
@@ -81,6 +81,7 @@
*/
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
import type { INestApplication } from '@nestjs/common';
import { MikroORM } from '@mikro-orm/core';
vi.mock('../../src/db/database', async () => {
const { createSnapshotTestDb, buildDbMock } = await import('../helpers/db-mock');
@@ -103,6 +104,7 @@ import { db as testDb } from '../../src/db/database';
import { buildApp } from '../../src/bootstrap';
import { createDay, createDayAssignment, createPlace, createTrip, createUser } from '../helpers/factories';
import { generateToken } from '../helpers/auth';
import { setAddonEnabled } from '../helpers/factories/settings';
describe('R1 async-closure truthiness guards (RB2/RS21/RS23) — driven through the real routes', () => {
let app: INestApplication;
@@ -111,7 +113,7 @@ describe('R1 async-closure truthiness guards (RB2/RS21/RS23) — driven through
beforeAll(async () => {
app = await buildApp();
httpApp = app.getHttpAdapter().getInstance() as Application;
testDb.prepare("UPDATE addons SET enabled = 1 WHERE id = 'roadtrip'").run();
await setAddonEnabled(app.get(MikroORM), 'roadtrip', true);
});
afterAll(async () => {
@@ -53,9 +53,15 @@ import { resetTestDb } from '../helpers/test-db';
import { createUser, createTrip, createPlace } from '../helpers/factories';
import { authCookie } from '../helpers/auth';
import { PlaceRegionsRepository } from '../../src/db/repositories/PlaceRegions.repository';
import { PlaceRegions } from '../../src/db/entities/PlaceRegions.entity';
import { findRow, findRows } from '../helpers/factories/rows';
import type { FactoryOrm } from '../helpers/factories/context';
import { MikroORM } from '@mikro-orm/core';
let nestApp: INestApplication;
let app: Application;
/** The app's own ORM, for reading back what the background geocode wrote. */
const orm = (): FactoryOrm => nestApp.get(MikroORM);
const errors: unknown[] = [];
beforeAll(async () => {
const orig = PlaceRegionsRepository.prototype.upsertRegionWhileUnmoved;
@@ -83,10 +89,10 @@ describe('AT4/AT29 background geocode request context', () => {
const p = createPlace(testDb, trip.id, { lat: 48.85, lng: 2.35 });
const res = await request(app).get('/api/addons/atlas/stats').set('Cookie', authCookie(user.id));
expect(res.status).toBe(200);
expect(testDb.prepare('SELECT * FROM place_regions WHERE place_id=?').get(p.id)).toBeUndefined();
expect(await findRow(orm(), PlaceRegions, { place: p.id })).toBeNull();
await new Promise((r) => setTimeout(r, 2500));
expect(errors).toEqual([]);
expect(testDb.prepare('SELECT * FROM place_regions WHERE place_id=?').get(p.id)).toMatchObject({ country_code: 'FR', region_code: 'FR-IDF' });
expect(await findRow(orm(), PlaceRegions, { place: p.id })).toMatchObject({ country_code: 'FR', region_code: 'FR-IDF' });
}, 15_000);
it('IIFE-CTX-AT29 — /regions IIFE writes two places sequentially (2nd after ~2.4s)', async () => {
@@ -99,7 +105,10 @@ describe('AT4/AT29 background geocode request context', () => {
expect(res.status).toBe(200);
await new Promise((r) => setTimeout(r, 3500));
expect(errors).toEqual([]);
const rows = testDb.prepare('SELECT place_id, region_code FROM place_regions ORDER BY place_id').all();
const rows = (await findRows(orm(), PlaceRegions, {}, { place: 'asc' })).map((r) => ({
place_id: r.place_id,
region_code: r.region_code,
}));
expect(rows).toEqual([
{ place_id: p1.id, region_code: 'FR-IDF' },
{ place_id: p2.id, region_code: 'FR-PAC' },
@@ -31,12 +31,14 @@
* callback it schedules (`() => config.getLink(linkId)`) is what needs the
* fork, and it only runs once the timer fires, inside `schedule()`'s own
* `withRequestContext` — exactly the wrap under test. The link id comes from
* a raw `testDb` read (no ORM, no context needed) rather than through
* `nudge()`/`getLinkByToken`, so nothing before the `schedule()` call ever
* touches the EntityManager.
* the fixture `beforeAll` seeds, whose factory calls each open and close a
* request context of their own, rather than through
* `nudge()`/`getLinkByToken`, so nothing in the test body before the
* `schedule()` call ever touches the EntityManager.
*/
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
import type { INestApplication } from '@nestjs/common';
import { MikroORM } from '@mikro-orm/core';
vi.mock('../../src/db/database', async () => {
const { createSnapshotTestDb, buildDbMock } = await import('../helpers/db-mock');
@@ -56,6 +58,11 @@ vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.
import { db as testDb } from '../../src/db/database';
import { buildApp } from '../../src/bootstrap';
import { createUser, createTrip } from '../helpers/factories';
import { insertRow, updateRows } from '../helpers/factories/rows';
import { setAddonEnabled } from '../helpers/factories/settings';
import { DocumentConnections } from '../../src/db/entities/DocumentConnections.entity';
import { DocumentProviders } from '../../src/db/entities/DocumentProviders.entity';
import { TripDocumentLinks } from '../../src/db/entities/TripDocumentLinks.entity';
import { WEBHOOK_NUDGE_DEBOUNCE_SECONDS } from '../../src/nest/doc-sync/doc-sync.constants';
import { DocSyncWebhookController } from '../../src/nest/doc-sync/doc-sync-webhook.controller';
import { DocSyncConfigService } from '../../src/nest/doc-sync/doc-sync-config.service';
@@ -74,28 +81,35 @@ describe('DocSyncWebhookController#schedule() runs its detached timer body insid
// The addon gate and the provider both have to be genuinely on: schedule()'s
// own reload callback (`syncIsOn`) skips the run otherwise, before ever
// reaching the assertion below.
testDb.exec("UPDATE addons SET enabled = 1 WHERE id = 'documents'");
testDb.exec("UPDATE document_providers SET enabled = 1 WHERE id = 'paperless'");
const orm = app.get(MikroORM);
await setAddonEnabled(orm, 'documents', true);
await updateRows(orm, DocumentProviders, { id: 'paperless' }, { enabled: 1 });
const owner = createUser(testDb, { username: 'docsync-webhook-ctx', email: 'docsync-webhook-ctx@test.local' }).user;
const trip = createTrip(testDb, owner.id, { title: 'Japan' });
const connInfo = testDb
.prepare(
`INSERT INTO document_connections (trip_id, provider_id, owner_user_id, base_url, secrets, settings)
VALUES (?, 'paperless', ?, 'https://paperless.example.com', NULL, '{}')`,
)
.run(trip.id, owner.id);
const linkInfo = testDb
.prepare(
`INSERT INTO trip_document_links
(trip_id, connection_id, provider_id, remote_scope_key, remote_label, direction, delete_policy,
conflict_policy, sync_enabled, webhook_token, created_by)
VALUES (?, ?, 'paperless', 'tag:1', 'Japan', 'both', 'unlink', 'manual', 1, 'webhook-ctx-ratchet-token', ?)`,
)
.run(trip.id, connInfo.lastInsertRowid, owner.id);
// A plain better-sqlite3 read — no ORM, no request context — so nothing
// before the `schedule()` call below ever touches the EntityManager.
linkId = Number(linkInfo.lastInsertRowid);
const connectionId = await insertRow(orm, DocumentConnections, {
trip: trip.id,
provider: 'paperless',
ownerUser: owner.id,
base_url: 'https://paperless.example.com',
secrets: null,
settings: '{}',
});
// Each factory call above closed its own request context before returning,
// so the test body below starts with no EntityManager fork around it.
linkId = await insertRow(orm, TripDocumentLinks, {
trip: trip.id,
connection: connectionId,
provider_id: 'paperless',
remote_scope_key: 'tag:1',
remote_label: 'Japan',
direction: 'both',
delete_policy: 'unlink',
conflict_policy: 'manual',
sync_enabled: 1,
webhook_token: 'webhook-ctx-ratchet-token',
createdByRef: owner.id,
});
});
afterAll(async () => {
@@ -67,7 +67,9 @@ import { db as testDb } from '../../src/db/database';
import { buildApp } from '../../src/bootstrap';
import { CalendarService } from '../../src/nest/calendar/calendar.service';
import { Trips } from '../../src/db/entities/Trips.entity';
import { Users } from '../../src/db/entities/Users.entity';
import { createTrip, createUser } from '../helpers/factories';
import { updateRows } from '../helpers/factories/rows';
describe('Anonymous ICS feed routes run inside a request context', () => {
let app: INestApplication;
@@ -86,7 +88,7 @@ describe('Anonymous ICS feed routes run inside a request context', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Context-Proof Trip' });
const token = randomUUID();
testDb.prepare('UPDATE trips SET feed_token = ? WHERE id = ?').run(token, trip.id);
await updateRows(app.get(MikroORM), Trips, { id: trip.id }, { feed_token: token });
let repoRead: unknown;
let caught: unknown;
@@ -136,7 +138,7 @@ describe('Anonymous ICS feed routes run inside a request context', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Mutation-Proof Trip' });
const token = randomUUID();
testDb.prepare('UPDATE trips SET feed_token = ? WHERE id = ?').run(token, trip.id);
await updateRows(app.get(MikroORM), Trips, { id: trip.id }, { feed_token: token });
const calendar = app.get(CalendarService);
const spy = vi.spyOn(calendar, 'buildTripCalendar').mockImplementation(async () => {
@@ -184,7 +186,7 @@ describe('Anonymous ICS feed routes run inside a request context', () => {
const { user } = createUser(testDb);
createTrip(testDb, user.id, { title: 'User-Feed Context-Proof Trip' });
const token = randomUUID();
testDb.prepare('UPDATE users SET feed_token = ? WHERE id = ?').run(token, user.id);
await updateRows(app.get(MikroORM), Users, { id: user.id }, { feed_token: token });
let repoRead: unknown;
let caught: unknown;
@@ -58,6 +58,8 @@ import { PluginRuntimeService } from '../../src/nest/plugins/plugin-runtime.serv
import { PlaceShadowRetentionJob } from '../../src/nest/place-shadow/place-shadow.job';
import { StorageService } from '../../src/nest/storage/storage.service';
import { Users } from '../../src/db/entities/Users.entity';
import { PlaceShadowPicks } from '../../src/db/entities/PlaceShadowPicks.entity';
import { countRows, insertRow } from '../helpers/factories/rows';
import { createUser } from '../helpers/factories';
import { generateToken } from '../helpers/auth';
@@ -161,10 +163,18 @@ describe('ORM request-context seams populated in production', () => {
const isEnabledSpy = vi.spyOn(registrar, 'isEnabled').mockReturnValue(true);
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
try {
testDb.prepare(`
INSERT INTO place_shadow_picks (created_at, query, source, live_rank, live_count, picked_name, picked_lat, picked_lng)
VALUES (datetime('now', '-999 days'), 'seam-003', 'nominatim', 1, 1, 'expired pick', 0, 0)
`).run();
// 999 days old, in the `datetime('now')` text form the column defaults to.
const expiredAt = new Date(Date.now() - 999 * 86_400_000).toISOString().slice(0, 19).replace('T', ' ');
await insertRow(orm, PlaceShadowPicks, {
created_at: expiredAt,
query: 'seam-003',
source: 'nominatim',
live_rank: 1,
live_count: 1,
picked_name: 'expired pick',
picked_lat: 0,
picked_lng: 0,
});
// Real bug found converting `purgeExpired` to a genuinely async
// repository call (0b security review F-B4): the `cron` package's
@@ -199,8 +209,7 @@ describe('ORM request-context seams populated in production', () => {
// `withRequestContext` wrap in `cron-registrar.service.ts`'s `register()`
// makes the repository call throw inside `PlaceShadowRetentionJob.tick`'s
// own try/catch, and the row survives (verified by hand, reverted).
const remaining = testDb.prepare("SELECT COUNT(*) as n FROM place_shadow_picks WHERE query = 'seam-003'").get() as { n: number };
expect(remaining.n).toBe(0);
expect(await countRows(orm, PlaceShadowPicks, { query: 'seam-003' })).toBe(0);
} finally {
registrar.unregister('place-shadow-retention');
isEnabledSpy.mockRestore();
+14 -2
View File
@@ -6,6 +6,7 @@ import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vites
import request from 'supertest';
import type { Application } from 'express';
import type { INestApplication } from '@nestjs/common';
import { MikroORM } from '@mikro-orm/core';
vi.mock('../../src/db/database', async () => {
const { createSnapshotTestDb, buildDbMock } = await import('../helpers/db-mock');
@@ -44,15 +45,26 @@ import { buildApp } from '../../src/bootstrap';
import { resetTestDb, resetRateLimits } from '../helpers/test-db';
import { createUser } from '../helpers/factories';
import { authCookie } from '../helpers/auth';
import { findRows } from '../helpers/factories/rows';
import type { FactoryOrm } from '../helpers/factories/context';
import { VacayHolidayCalendars } from '../../src/db/entities/VacayHolidayCalendars.entity';
let nestApp: INestApplication;
let app: Application;
let orm: FactoryOrm;
beforeAll(async () => {
nestApp = await buildApp();
app = nestApp.getHttpAdapter().getInstance();
orm = nestApp.get(MikroORM);
});
/** The id of the holiday calendar created last. */
async function latestCalendarId(): Promise<number | undefined> {
const [latest] = await findRows(orm, VacayHolidayCalendars, {}, { id: 'desc' });
return latest?.id;
}
beforeEach(async () => {
resetTestDb(testDb);
await resetRateLimits(nestApp);
@@ -299,7 +311,7 @@ describe('Vacay holiday calendar CRUD', () => {
.send({ region: 'US', label: 'US Holidays' });
expect(createRes.status).toBe(200);
const calId = createRes.body.plan?.holiday_calendars?.at(-1)?.id
?? (testDb.prepare('SELECT id FROM vacay_holiday_calendars ORDER BY id DESC LIMIT 1').get() as any)?.id;
?? (await latestCalendarId());
const res = await request(app)
.put(`/api/addons/vacay/plan/holiday-calendars/${calId}`)
@@ -317,7 +329,7 @@ describe('Vacay holiday calendar CRUD', () => {
.set('Cookie', authCookie(user.id))
.send({ region: 'FR', label: 'French Holidays' });
expect(createRes.status).toBe(200);
const calId = (testDb.prepare('SELECT id FROM vacay_holiday_calendars ORDER BY id DESC LIMIT 1').get() as any)?.id;
const calId = await latestCalendarId();
const res = await request(app)
.delete(`/api/addons/vacay/plan/holiday-calendars/${calId}`)
+11 -8
View File
@@ -17,6 +17,10 @@ import {
createTrip,
createUser,
} from '../../helpers/factories';
import { readTripDays } from '../../helpers/factories/trips';
import { countRows } from '../../helpers/factories/rows';
import { Places } from '../../../src/db/entities/Places.entity';
import { Trips } from '../../../src/db/entities/Trips.entity';
// #2518: a container with a read-only root filesystem and no tmpfs on /tmp
// gives SQLite nowhere to put a temp file, so deleting a trip failed with
@@ -124,7 +128,8 @@ async function deleteTripAndWatchTempFiles(db: Database.Database): Promise<{
start_date: '2026-06-01',
end_date: '2026-06-14',
});
const days = db.prepare('SELECT id FROM days WHERE trip_id = ? ORDER BY day_number').all(trip.id) as { id: number }[];
const t = await createTestOrm(db);
const days = await readTripDays(t, trip.id);
for (let i = 0; i < 25; i++) {
const place = createPlace(db, trip.id, { name: `Ort ${i}`, description: 'Grachten und Tulpen. '.repeat(10) });
createDayAssignment(db, days[i % days.length].id, place.id);
@@ -141,19 +146,17 @@ async function deleteTripAndWatchTempFiles(db: Database.Database): Promise<{
db.exec('CREATE TRIGGER trek_watch_trip_delete AFTER DELETE ON trips BEGIN SELECT trek_watch_temp_files(); END');
const none = undefined as never;
const t = await createTestOrm(db);
try {
const trips = new TripsService(none, none, none, none, none, none, none, none, new UnitOfWork(t.em), t.em, none);
await trips.remove(trip.id, user.id, 'user');
return {
tempFiles: [...seen],
tripLeft: await countRows(t, Trips, { id: trip.id }),
placesLeft: await countRows(t, Places, { trip: trip.id }),
};
} finally {
await t.close();
}
return {
tempFiles: [...seen],
tripLeft: (db.prepare('SELECT COUNT(*) AS n FROM trips WHERE id = ?').get(trip.id) as { n: number }).n,
placesLeft: (db.prepare('SELECT COUNT(*) AS n FROM places WHERE trip_id = ?').get(trip.id) as { n: number }).n,
};
}
describe.runIf(canSeeTempFiles)('deleting a trip with content (#2518)', () => {
@@ -14,32 +14,42 @@ import { createSnapshotTestDb } from '../../../helpers/db-mock';
import { resetTestDb } from '../../../helpers/test-db';
import { createTrip, createUser } from '../../../helpers/factories';
import { createTestCollabMessagesRepo } from '../../../helpers/collab-repos';
import { createTestOrm, type TestOrm } from '../../../helpers/test-orm';
import { makeCollabMessage } from '../../../helpers/factories/collab';
import { updateRows } from '../../../helpers/factories/rows';
import { Users } from '../../../../src/db/entities/Users.entity';
import type { CollabMessagesRepository } from '../../../../src/db/repositories/CollabMessages.repository';
const testDb = createSnapshotTestDb();
let collabMessagesRepo: CollabMessagesRepository;
let t: TestOrm;
beforeAll(async () => {
collabMessagesRepo = await createTestCollabMessagesRepo(testDb);
t = await createTestOrm(testDb);
});
beforeEach(() => resetTestDb(testDb));
afterAll(() => testDb.close());
afterAll(async () => {
await t.close();
testDb.close();
});
function insertMessage(
async function insertMessage(
tripId: number,
userId: number,
overrides: Partial<{ text: string; reply_to: number | null; deleted: number; created_at: string }> = {},
): number {
const result = testDb.prepare(
'INSERT INTO collab_messages (trip_id, user_id, text, reply_to, deleted, created_at) VALUES (?, ?, ?, ?, ?, ?)',
).run(
tripId, userId, overrides.text ?? 'hi', overrides.reply_to ?? null, overrides.deleted ?? 0,
overrides.created_at ?? '2026-09-01T00:00:00.000Z',
);
return Number(result.lastInsertRowid);
): Promise<number> {
const message = await makeCollabMessage(t, tripId, userId, {
text: overrides.text ?? 'hi',
replyToRef: overrides.reply_to ?? null,
deleted: overrides.deleted ?? 0,
created_at: overrides.created_at ?? '2026-09-01T00:00:00.000Z',
});
return message.id;
}
function legacyPublicForShare(tripId: number): unknown {
// test-sql-allow: the legacy statement is the oracle the repository read is held to.
return testDb.prepare(
'SELECT m.*, u.username, u.avatar FROM collab_messages m JOIN users u ON m.user_id = u.id WHERE m.trip_id = ? AND m.deleted = 0 ORDER BY m.created_at ASC',
).all(tripId);
@@ -48,15 +58,15 @@ function legacyPublicForShare(tripId: number): unknown {
describe('CollabMessagesRepository — share.service.ts SH16 read', () => {
it('listPublicForShare — matches the legacy statement, deleted excluded, every nullable column both null and set', async () => {
const { user: author } = createUser(testDb, { username: 'alice' });
testDb.prepare('UPDATE users SET avatar = ? WHERE id = ?').run('alice.png', author.id);
await updateRows(t, Users, { id: author.id }, { avatar: 'alice.png' });
const { user: replier } = createUser(testDb, { username: 'bob' });
const trip = createTrip(testDb, author.id);
const other = createTrip(testDb, author.id);
const rootId = insertMessage(trip.id, author.id, { text: 'root', reply_to: null, created_at: '2026-09-01T10:00:00.000Z' });
const replyId = insertMessage(trip.id, replier.id, { text: 'reply', reply_to: rootId, created_at: '2026-09-01T11:00:00.000Z' });
insertMessage(trip.id, author.id, { text: 'gone', deleted: 1, created_at: '2026-09-01T12:00:00.000Z' });
insertMessage(other.id, author.id, { text: 'foreign' });
const rootId = await insertMessage(trip.id, author.id, { text: 'root', reply_to: null, created_at: '2026-09-01T10:00:00.000Z' });
const replyId = await insertMessage(trip.id, replier.id, { text: 'reply', reply_to: rootId, created_at: '2026-09-01T11:00:00.000Z' });
await insertMessage(trip.id, author.id, { text: 'gone', deleted: 1, created_at: '2026-09-01T12:00:00.000Z' });
await insertMessage(other.id, author.id, { text: 'foreign' });
const legacy = legacyPublicForShare(trip.id);
const typed = await collabMessagesRepo.listPublicForShare(trip.id);
@@ -3,6 +3,8 @@ import { createSnapshotTestDb } from '../../../helpers/db-mock';
import { resetTestDb } from '../../../helpers/test-db';
import { createTestOrm, type TestOrm } from '../../../helpers/test-orm';
import { PhotoProviderFields } from '../../../../src/db/entities/PhotoProviderFields.entity';
import { PhotoProviders } from '../../../../src/db/entities/PhotoProviders.entity';
import { deleteRows, insertRow } from '../../../helpers/factories/rows';
import type { PhotoProviderFieldsRepository } from '../../../../src/db/repositories/PhotoProviderFields.repository';
const testDb = createSnapshotTestDb();
@@ -13,10 +15,10 @@ beforeAll(async () => {
t = await createTestOrm(testDb);
fields = t.repo(PhotoProviderFields);
});
beforeEach(() => {
beforeEach(async () => {
resetTestDb(testDb);
testDb.exec('DELETE FROM photo_provider_fields');
testDb.exec('DELETE FROM photo_providers');
await deleteRows(t, PhotoProviderFields);
await deleteRows(t, PhotoProviders);
t.clear();
});
afterAll(async () => {
@@ -24,11 +26,11 @@ afterAll(async () => {
testDb.close();
});
function insertProvider(id: string): void {
testDb.prepare("INSERT INTO photo_providers (id, name, icon, enabled, sort_order) VALUES (?, ?, 'Image', 1, 0)").run(id, id);
async function insertProvider(id: string): Promise<void> {
await insertRow(t, PhotoProviders, { id, name: id, icon: 'Image', enabled: 1, sort_order: 0 });
}
function insertField(row: {
async function insertField(row: {
provider_id: string;
field_key: string;
label: string;
@@ -40,35 +42,29 @@ function insertField(row: {
settings_key?: string | null;
payload_key?: string | null;
sort_order?: number;
}): void {
testDb
.prepare(
`INSERT INTO photo_provider_fields
(provider_id, field_key, label, input_type, placeholder, hint, required, secret, settings_key, payload_key, sort_order)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
)
.run(
row.provider_id,
row.field_key,
row.label,
row.input_type ?? 'text',
row.placeholder ?? null,
row.hint ?? null,
row.required ?? 0,
row.secret ?? 0,
row.settings_key ?? null,
row.payload_key ?? null,
row.sort_order ?? 0,
);
}): Promise<void> {
await insertRow(t, PhotoProviderFields, {
provider: row.provider_id,
field_key: row.field_key,
label: row.label,
input_type: row.input_type ?? 'text',
placeholder: row.placeholder ?? null,
hint: row.hint ?? null,
required: row.required ?? 0,
secret: row.secret ?? 0,
settings_key: row.settings_key ?? null,
payload_key: row.payload_key ?? null,
sort_order: row.sort_order ?? 0,
});
}
describe('PhotoProviderFieldsRepository.listAllOrdered', () => {
it('ADDONSPPFREPO-001: returns every field, ordered by sort_order then id, across all providers', async () => {
insertProvider('immich');
insertProvider('synology');
insertField({ provider_id: 'synology', field_key: 'url', label: 'URL', sort_order: 1 });
insertField({ provider_id: 'immich', field_key: 'url', label: 'URL', sort_order: 0 });
insertField({ provider_id: 'immich', field_key: 'token', label: 'Token', sort_order: 0 });
await insertProvider('immich');
await insertProvider('synology');
await insertField({ provider_id: 'synology', field_key: 'url', label: 'URL', sort_order: 1 });
await insertField({ provider_id: 'immich', field_key: 'url', label: 'URL', sort_order: 0 });
await insertField({ provider_id: 'immich', field_key: 'token', label: 'Token', sort_order: 0 });
const rows = await fields.listAllOrdered();
// Two rows share sort_order 0 — insertion order (ascending id) breaks the tie.
@@ -80,15 +76,15 @@ describe('PhotoProviderFieldsRepository.listAllOrdered', () => {
});
it('ADDONSPPFREPO-002: the provider_id twin reads back from find(), same as trip_id on Days', async () => {
insertProvider('immich');
insertField({ provider_id: 'immich', field_key: 'url', label: 'URL' });
await insertProvider('immich');
await insertField({ provider_id: 'immich', field_key: 'url', label: 'URL' });
const [row] = await fields.listAllOrdered();
expect(row.provider_id).toBe('immich');
});
it('ADDONSPPFREPO-003: required/secret come back as the raw stored integer, matching the entity type (not a coerced boolean)', async () => {
insertProvider('immich');
insertField({ provider_id: 'immich', field_key: 'token', label: 'Token', required: 1, secret: 1 });
await insertProvider('immich');
await insertField({ provider_id: 'immich', field_key: 'token', label: 'Token', required: 1, secret: 1 });
const [row] = await fields.listAllOrdered();
expect(row.required).toBe(1);
expect(row.secret).toBe(1);
@@ -96,8 +92,8 @@ describe('PhotoProviderFieldsRepository.listAllOrdered', () => {
});
it('ADDONSPPFREPO-004: nullable columns (placeholder, hint, settings_key, payload_key) come back null when unset', async () => {
insertProvider('immich');
insertField({ provider_id: 'immich', field_key: 'k', label: 'L' });
await insertProvider('immich');
await insertField({ provider_id: 'immich', field_key: 'k', label: 'L' });
const [row] = await fields.listAllOrdered();
expect(row.placeholder).toBeNull();
expect(row.hint).toBeNull();
@@ -124,11 +120,11 @@ describe('PhotoProviderFieldsRepository.listAllOrderedForAdminShelf', () => {
// just `provider_id`, so a future narrowed-projection regression on any
// other column fails here too.
it('AD28: returns the full legacy row shape, ordered by sort_order then id, across all providers', async () => {
insertProvider('immich');
insertProvider('synology');
insertField({ provider_id: 'synology', field_key: 'url', label: 'Server URL', input_type: 'text', placeholder: 'https://photos.example.com', hint: 'ignored: not in the AD28 shape', required: 1, secret: 0, settings_key: 'synology_url', payload_key: 'url', sort_order: 1 });
insertField({ provider_id: 'immich', field_key: 'url', label: 'Server URL', input_type: 'text', placeholder: 'https://immich.example.com', required: 1, secret: 0, settings_key: 'immich_url', payload_key: 'url', sort_order: 0 });
insertField({ provider_id: 'immich', field_key: 'api_key', label: 'API Key', input_type: 'password', required: 1, secret: 1, settings_key: 'immich_api_key', payload_key: 'apiKey', sort_order: 1 });
await insertProvider('immich');
await insertProvider('synology');
await insertField({ provider_id: 'synology', field_key: 'url', label: 'Server URL', input_type: 'text', placeholder: 'https://photos.example.com', hint: 'ignored: not in the AD28 shape', required: 1, secret: 0, settings_key: 'synology_url', payload_key: 'url', sort_order: 1 });
await insertField({ provider_id: 'immich', field_key: 'url', label: 'Server URL', input_type: 'text', placeholder: 'https://immich.example.com', required: 1, secret: 0, settings_key: 'immich_url', payload_key: 'url', sort_order: 0 });
await insertField({ provider_id: 'immich', field_key: 'api_key', label: 'API Key', input_type: 'password', required: 1, secret: 1, settings_key: 'immich_api_key', payload_key: 'apiKey', sort_order: 1 });
const rows = await fields.listAllOrderedForAdminShelf();
// sort_order 0 first; the sort_order-1 tie breaks by id (insertion order:
@@ -17,6 +17,7 @@ import { createTestOrm, type TestOrm } from '../../../helpers/test-orm';
import { PluginOauthTokens } from '../../../../src/db/entities/PluginOauthTokens.entity';
import type { PluginOauthTokensRepository } from '../../../../src/db/repositories/PluginOauthTokens.repository';
import { currentTimestampKysely } from '../../../../src/db/dialect/kysely-functions';
import { findRow } from '../../../helpers/factories/rows';
const testDb = createSnapshotTestDb();
let t: TestOrm;
@@ -30,9 +31,7 @@ beforeEach(() => { resetTestDb(testDb); t.clear(); });
afterAll(async () => { await t.close(); testDb.close(); });
function storedRow(pluginId: string, userId: number) {
return testDb
.prepare('SELECT plugin_id, user_id, access_token, refresh_token, expires_at, scope, updated_at FROM plugin_oauth_tokens WHERE plugin_id = ? AND user_id = ?')
.get(pluginId, userId) as { plugin_id: string; user_id: number; access_token: string | null; refresh_token: string | null; expires_at: number | null; scope: string | null; updated_at: string } | undefined;
return findRow(t, PluginOauthTokens, { plugin_id: pluginId, user_id: userId });
}
describe('PluginOauthTokensRepository', () => {
@@ -75,12 +74,12 @@ describe('PluginOauthTokensRepository', () => {
// Seed an existing token row with a non-null refresh_token — the state a
// previously-connected user is in when a later refresh comes back without one.
await repo.storeToken({ plugin_id: 'p', user_id: 42, access_token: 'AT-1', refresh_token: 'RT-original', expires_at: 1000, scope: 'read' });
expect(storedRow('p', 42)).toMatchObject({ access_token: 'AT-1', refresh_token: 'RT-original' });
expect(await storedRow('p', 42)).toMatchObject({ access_token: 'AT-1', refresh_token: 'RT-original' });
// A refresh response that omits refresh_token — the repository call the
// service makes when it has NOT pre-filled the field itself.
await repo.storeToken({ plugin_id: 'p', user_id: 42, access_token: 'AT-2', refresh_token: null, expires_at: 2000, scope: 'read write' });
const row = storedRow('p', 42)!;
const row = (await storedRow('p', 42))!;
// access_token/expires_at/scope always take the new value (plain excluded.* reassignment)...
expect(row.access_token).toBe('AT-2');
expect(row.expires_at).toBe(2000);
@@ -93,14 +92,14 @@ describe('PluginOauthTokensRepository', () => {
it('PO11REPO-003 (R-oauth-upsert, the other COALESCE direction): a refresh response that DOES supply a new refresh_token overwrites the stored one', async () => {
await repo.storeToken({ plugin_id: 'p', user_id: 42, access_token: 'AT-1', refresh_token: 'RT-original', expires_at: 1000, scope: 'read' });
await repo.storeToken({ plugin_id: 'p', user_id: 42, access_token: 'AT-2', refresh_token: 'RT-rotated', expires_at: 2000, scope: 'read' });
const row = storedRow('p', 42)!;
const row = (await storedRow('p', 42))!;
expect(row.refresh_token).toBe('RT-rotated');
});
it('PO11REPO-004: a brand-new row (no prior state) inserts plainly — no COALESCE fallback target to speak of yet', async () => {
expect(storedRow('fresh', 7)).toBeUndefined();
expect(await storedRow('fresh', 7)).toBeNull();
await repo.storeToken({ plugin_id: 'fresh', user_id: 7, access_token: 'AT', refresh_token: null, expires_at: null, scope: null });
const row = storedRow('fresh', 7)!;
const row = (await storedRow('fresh', 7))!;
expect(row.access_token).toBe('AT');
expect(row.refresh_token).toBeNull();
});
@@ -108,8 +107,8 @@ describe('PluginOauthTokensRepository', () => {
it('PO11REPO-005: the composite key isolates rows — a second user of the same plugin gets its own row, the first is untouched', async () => {
await repo.storeToken({ plugin_id: 'p', user_id: 1, access_token: 'AT-1', refresh_token: 'RT-1', expires_at: null, scope: null });
await repo.storeToken({ plugin_id: 'p', user_id: 2, access_token: 'AT-2', refresh_token: 'RT-2', expires_at: null, scope: null });
expect(storedRow('p', 1)).toMatchObject({ access_token: 'AT-1', refresh_token: 'RT-1' });
expect(storedRow('p', 2)).toMatchObject({ access_token: 'AT-2', refresh_token: 'RT-2' });
expect(await storedRow('p', 1)).toMatchObject({ access_token: 'AT-1', refresh_token: 'RT-1' });
expect(await storedRow('p', 2)).toMatchObject({ access_token: 'AT-2', refresh_token: 'RT-2' });
});
});
});
@@ -10,6 +10,7 @@ import { resetTestDb } from '../../../helpers/test-db';
import { createTestOrm, type TestOrm } from '../../../helpers/test-orm';
import { createUser } from '../../../helpers/factories';
import { VacayPlans } from '../../../../src/db/entities/VacayPlans.entity';
import { makeVacayPlan } from '../../../helpers/factories/vacay';
import type { VacayPlansRepository } from '../../../../src/db/repositories/VacayPlans.repository';
const testDb = createSnapshotTestDb();
@@ -23,20 +24,16 @@ beforeAll(async () => {
beforeEach(() => { resetTestDb(testDb); t.clear(); });
afterAll(async () => { await t.close(); testDb.close(); });
function insertPlan(ownerId: number, overrides: Partial<{ block_weekends: number; holidays_enabled: number; holidays_region: string; school_holidays_enabled: number; company_holidays_enabled: number; carry_over_enabled: number; weekend_days: string; week_start: number }> = {}): number {
const cols = Object.keys(overrides);
const placeholders = cols.map(() => '?').join(', ');
const extra = cols.length > 0 ? `, ${cols.join(', ')}` : '';
const result = testDb.prepare(`INSERT INTO vacay_plans (owner_id${extra}) VALUES (?${cols.length > 0 ? ', ' + placeholders : ''})`)
.run(ownerId, ...cols.map((c) => (overrides as Record<string, unknown>)[c]));
return Number(result.lastInsertRowid);
async function insertPlan(ownerId: number, overrides: Partial<{ block_weekends: number; holidays_enabled: number; holidays_region: string; school_holidays_enabled: number; company_holidays_enabled: number; carry_over_enabled: number; weekend_days: string; week_start: number }> = {}): Promise<number> {
return (await makeVacayPlan(t, ownerId, overrides)).id;
}
describe('VacayPlansRepository.findByOwner (VC7/9/78)', () => {
it('VACAYPLANREPO-001: matches SELECT * FROM vacay_plans WHERE owner_id = ? run raw, every nullable column set', async () => {
const { user } = createUser(testDb);
const id = insertPlan(user.id, { holidays_region: 'DE', weekend_days: '["sat","sun"]', week_start: 1 });
const id = await insertPlan(user.id, { holidays_region: 'DE', weekend_days: '["sat","sun"]', week_start: 1 });
// test-sql-allow: the legacy statement is the oracle the repository read is held to.
const legacy = testDb.prepare('SELECT * FROM vacay_plans WHERE owner_id = ?').get(user.id);
expect(await repo.findByOwner(user.id)).toEqual(legacy);
expect((await repo.findByOwner(user.id))!.id).toBe(id);
@@ -51,8 +48,9 @@ describe('VacayPlansRepository.findByOwner (VC7/9/78)', () => {
describe('VacayPlansRepository.findById (VC14, the hottest statement in this file)', () => {
it('VACAYPLANREPO-003: matches SELECT * FROM vacay_plans WHERE id = ? run raw, defaulted columns included', async () => {
const { user } = createUser(testDb);
const id = insertPlan(user.id);
const id = await insertPlan(user.id);
// test-sql-allow: the legacy statement is the oracle the repository read is held to.
const legacy = testDb.prepare('SELECT * FROM vacay_plans WHERE id = ?').get(id);
expect(await repo.findById(id)).toEqual(legacy);
});
@@ -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 { createReservation, createTrip, createUser } from '../../../../helpers/factories';
import { addReservationTraveler } from '../../../../helpers/factories/reservations';
import { Reservations } from '../../../../../src/db/entities/Reservations.entity';
import {
travelerOwnsCondition,
@@ -32,12 +33,13 @@ beforeAll(async () => { t = await createTestOrm(testDb); });
beforeEach(() => { resetTestDb(testDb); t.clear(); });
afterAll(async () => { await t.close(); testDb.close(); });
function addTraveler(reservationId: number, userId: number): void {
testDb.prepare('INSERT INTO reservation_travelers (reservation_id, user_id) VALUES (?, ?)').run(reservationId, userId);
async function addTraveler(reservationId: number, userId: number): Promise<void> {
await addReservationTraveler(t, reservationId, userId);
}
async function legacyOwnedIds(tripId: number, userId: number): Promise<number[]> {
const rows = testDb
// test-sql-allow: the legacy TRAVELER_OWNS fragment is the oracle, so it has to run as the raw SQL it was.
.prepare(`SELECT id FROM reservations r WHERE r.trip_id = ? AND ${TRAVELER_OWNS} ORDER BY id ASC`)
.all(tripId, userId) as { id: number }[];
return rows.map((row) => row.id);
@@ -91,7 +93,7 @@ describe('reservation-travelers-owns parity (TRAVELER_OWNS: the two cases that m
const { user: stranger } = createUser(testDb);
const trip = createTrip(testDb, traveler.id);
const assigned = createReservation(testDb, trip.id, { title: 'Flight with named travelers' });
addTraveler(assigned.id, traveler.id);
await addTraveler(assigned.id, traveler.id);
const legacyForTraveler = await legacyOwnedIds(trip.id, traveler.id);
const typedQbForTraveler = await typedQbOwnedIds(trip.id, traveler.id);
@@ -114,8 +116,8 @@ describe('reservation-travelers-owns parity (TRAVELER_OWNS: the two cases that m
const { user: stranger } = createUser(testDb);
const trip = createTrip(testDb, travelerA.id);
const shared = createReservation(testDb, trip.id, { title: 'Family flight' });
addTraveler(shared.id, travelerA.id);
addTraveler(shared.id, travelerB.id);
await addTraveler(shared.id, travelerA.id);
await addTraveler(shared.id, travelerB.id);
for (const userId of [travelerA.id, travelerB.id]) {
const legacy = await legacyOwnedIds(trip.id, userId);
@@ -141,7 +143,7 @@ describe('reservation-travelers-owns parity (TRAVELER_OWNS: the two cases that m
const trip = createTrip(testDb, owner.id);
const unassigned = createReservation(testDb, trip.id, { title: 'Unassigned' });
const assignedToOther = createReservation(testDb, trip.id, { title: 'Assigned to someone else' });
addTraveler(assignedToOther.id, travelerB.id);
await addTraveler(assignedToOther.id, travelerB.id);
const legacy = await legacyOwnedIds(trip.id, viewer.id);
const typedQb = await typedQbOwnedIds(trip.id, viewer.id);
@@ -12,6 +12,7 @@ import { createSnapshotTestDb } from '../../../helpers/db-mock';
import { resetTestDb } from '../../../helpers/test-db';
import { createTestOrm, type TestOrm } from '../../../helpers/test-orm';
import { Addons } from '../../../../src/db/entities/Addons.entity';
import { deleteRows, insertRow } from '../../../helpers/factories/rows';
import type { AddonsRepository } from '../../../../src/db/repositories/Addons.repository';
import { prepareLlmAddonConfigForWrite } from '../../../../src/nest/llm-parse/llm-config';
import { decrypt_api_key } from '../../../../src/nest/common/crypto/apiKeyCrypto';
@@ -24,12 +25,18 @@ beforeAll(async () => {
t = await createTestOrm(testDb);
addons = t.repo(Addons);
});
beforeEach(() => {
beforeEach(async () => {
resetTestDb(testDb);
testDb.exec('DELETE FROM addons');
testDb
.prepare(`INSERT INTO addons (id, name, description, type, icon, enabled, sort_order) VALUES ('llm_parsing', 'AI Parsing', NULL, 'global', 'Sparkles', 0, 0)`)
.run();
await deleteRows(t, Addons);
await insertRow(t, Addons, {
id: 'llm_parsing',
name: 'AI Parsing',
description: null,
type: 'global',
icon: 'Sparkles',
enabled: false,
sort_order: 0,
});
t.clear();
});
afterAll(async () => {
@@ -4,7 +4,7 @@
* counterparts of the GET /api/plugins feed and GET /api/plugin-pois. Both go through
* PluginPoisService, so the gate, the window and the normalization are the REST ones.
*/
import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
const { pluginsEnabled } = vi.hoisted(() => ({ pluginsEnabled: vi.fn(() => true) }));
@@ -25,6 +25,10 @@ import { createUser } from '../../helpers/factories';
import { createMcpHarness, parseToolResult, type McpHarness } from '../../helpers/mcp-harness';
import { resetTestDb } from '../../helpers/test-db';
import { PluginHooks } from '../../../src/nest/plugins/plugin-hooks.service';
import { Plugins } from '../../../src/db/entities/Plugins.entity';
import { createTestOrm, type TestOrm } from '../../helpers/test-orm';
import { deleteRows } from '../../helpers/factories/rows';
import { makePlugin } from '../../helpers/factories/plugins';
const providersOfMock = vi.spyOn(PluginHooks.prototype, 'providersOf');
const categoryPoisMock = vi.spyOn(PluginHooks.prototype, 'categoryPois');
@@ -32,18 +36,28 @@ const categoryPoisMock = vi.spyOn(PluginHooks.prototype, 'categoryPois');
const trailheads = { id: 'trailheads', label: 'Trailheads', labels: { de: 'Wanderparkplätze' }, icon: 'Signpost', color: '#2f855a' };
const bbox = { south: 47, west: 11, north: 47.5, east: 11.5 };
beforeEach(() => {
let orm: TestOrm;
beforeAll(async () => {
orm = await createTestOrm(testDb);
});
beforeEach(async () => {
resetTestDb(testDb);
testDb.prepare('DELETE FROM plugins').run();
testDb
.prepare("INSERT INTO plugins (id, name, status, capabilities, granted_permissions) VALUES (?, ?, 'active', ?, ?)")
.run('trail-finder', 'Trail Finder', JSON.stringify({ poiCategories: [trailheads] }), JSON.stringify(['hook:poi-category-provider']));
await deleteRows(orm, Plugins);
await makePlugin(orm, 'trail-finder', {
name: 'Trail Finder',
status: 'active',
capabilities: JSON.stringify({ poiCategories: [trailheads] }),
granted_permissions: JSON.stringify(['hook:poi-category-provider']),
});
pluginsEnabled.mockReturnValue(true);
providersOfMock.mockReset().mockReturnValue(['trail-finder']);
categoryPoisMock.mockReset().mockResolvedValue([]);
});
afterAll(() => {
afterAll(async () => {
await orm.close();
testDb.close();
});
@@ -19,6 +19,7 @@ describe('checkBackupDatabase and the release that made the backup', () => {
const db = new Database(path.join(dir, 'travel.db'));
for (const table of ['users', 'trips', 'trip_members', 'places', 'days']) db.exec(`CREATE TABLE ${table} (id INTEGER)`);
db.exec('CREATE TABLE mikro_orm_migrations (id INTEGER PRIMARY KEY, name TEXT, executed_at TEXT)');
// test-sql-allow: a bare backup file before any boot, which no ORM is bound to.
const insert = db.prepare('INSERT INTO mikro_orm_migrations (name) VALUES (?)');
for (const name of recorded) insert.run(name);
db.close();
+37 -36
View File
@@ -4,18 +4,14 @@
* freeze the FX rate before the raw write — incl. the #1445 stored-currency
* thread-through) and the wrapper-only syncReservationPrice SQL. The SQL-heavy
* paths themselves are covered by budget.service.db.test.ts (real :memory: DB)
* and budget.service.calc.test.ts (settlement math over a prepare-stub mock).
* and budget.service.calc.test.ts (settlement math over a stubbed database).
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
// Mock the data + side-effect dependencies the service reaches into directly.
const { dbMock } = vi.hoisted(() => {
const stmt = { get: vi.fn(), all: vi.fn(() => []), run: vi.fn() };
return { dbMock: { prepare: vi.fn(() => stmt), _stmt: stmt } };
});
const { canAccessTrip } = vi.hoisted(() => ({ canAccessTrip: vi.fn() }));
vi.mock('../../../src/db/database', () => ({
db: dbMock,
db: {},
closeDb: () => {},
reinitialize: () => {},
canAccessTrip,
@@ -49,9 +45,9 @@ import type { TripsRepository } from '../../../src/db/repositories/Trips.reposit
import type { TripMembersRepository } from '../../../src/db/repositories/TripMembers.repository';
/**
* There is no database behind this suite — every statement is served by the
* prepare stub above — so the UnitOfWork is a pass-through: it runs the callback
* as MikroORM would, with no transaction to open on.
* There is no database behind this suite (every repository is a stub), so the
* UnitOfWork is a pass-through: it runs the callback as MikroORM would, with no
* transaction to open on.
*/
const uowStub = { transactional: <T>(fn: () => Promise<T>) => fn() } as unknown as UnitOfWork;
@@ -61,16 +57,19 @@ const uowStub = { transactional: <T>(fn: () => Promise<T>) => fn() } as unknown
* and update and delete wrappers, setItemPayers, insertSettlement,
* getSettlement, applySettlementUpdate — the composite-wrapper tests only
* assert the freeze-then-write ORDER, never the SQL itself) or, for
* `syncReservationPrice`, still needs the `dbMock`/`prepare` stub above to
* answer — so this repo proxies to it instead of a real
* `ReservationsRepository`, keeping that describe block's assertions
* (incl. "prepare throws" leading to a swallowed error) unchanged.
* `syncReservationPrice`, answered by these three repository stubs: the
* lookup, the metadata write and the reload the service makes, in that order.
* A lookup that rejects stands for the database being gone.
*/
const reservationsRepoStub = {
getIdAndMetadata: async (id: number, tripId: unknown) => dbMock.prepare().get(id, tripId),
setMetadata: async (id: number, metadata: string) => { dbMock.prepare().run(metadata, id); },
getFull: async (id: number) => dbMock.prepare().get(id),
} as unknown as ReservationsRepository;
const reservationsRepo = {
getIdAndMetadata: vi.fn(),
setMetadata: vi.fn<(id: number, metadata: string) => Promise<void>>(async () => {}),
getFull: vi.fn(),
};
const reservationsRepoStub = reservationsRepo as unknown as ReservationsRepository;
/** The metadata JSON the service wrote on its first setMetadata call. */
const writtenMetadata = (): unknown => JSON.parse(reservationsRepo.setMetadata.mock.calls[0][1]);
function svc(
tripMembersRepo: TripMembersRepository = {} as unknown as TripMembersRepository,
@@ -101,7 +100,7 @@ describe('BudgetService', () => {
// Plan 4 Task 2: `verifyTripAccess` no longer rides `DatabaseService
// .canAccessTrip` — it reads through the injected `TripsRepository`
// directly, consistent with this suite's "no database behind it"
// design (everything else here is a `prepare` stub).
// design (everything else here is a stub).
const findAccessible = vi.fn(async () => ({ id: 5, user_id: 2 }));
const fakeTripsRepo = { findAccessible } as unknown as TripsRepository;
expect(await svc(undefined, fakeTripsRepo).verifyTripAccess('5', 2)).toEqual({ id: 5, user_id: 2 });
@@ -261,52 +260,54 @@ describe('BudgetService', () => {
describe('syncReservationPrice', () => {
it('returns early when the reservation is not found', async () => {
dbMock._stmt.get.mockReturnValueOnce(undefined);
reservationsRepo.getIdAndMetadata.mockResolvedValueOnce(undefined);
await svc().syncReservationPrice('5', 42, 250, 'sock');
expect(dbMock._stmt.run).not.toHaveBeenCalled();
expect(reservationsRepo.setMetadata).not.toHaveBeenCalled();
expect(broadcast).not.toHaveBeenCalled();
});
it('merges into existing metadata and broadcasts reservation:updated', async () => {
dbMock._stmt.get
.mockReturnValueOnce({ id: 42, metadata: '{"vendor":"ACME"}' }) // lookup
.mockReturnValueOnce({ id: 42, metadata: '{"vendor":"ACME","price":"250"}' }); // reload
reservationsRepo.getIdAndMetadata.mockResolvedValueOnce({ id: 42, metadata: '{"vendor":"ACME"}' }); // lookup
reservationsRepo.getFull.mockResolvedValueOnce({ id: 42, metadata: '{"vendor":"ACME","price":"250"}' }); // reload
await svc().syncReservationPrice('5', 42, 250, 'sock');
const writtenMeta = JSON.parse(dbMock._stmt.run.mock.calls[0][0] as string);
expect(writtenMeta).toEqual({ vendor: 'ACME', price: '250' });
expect(reservationsRepo.setMetadata.mock.calls[0][0]).toBe(42);
expect(writtenMetadata()).toEqual({ vendor: 'ACME', price: '250' });
expect(broadcast).toHaveBeenCalledWith('5', 'reservation:updated', { reservation: { id: 42, metadata: '{"vendor":"ACME","price":"250"}' } }, 'sock');
});
it('starts from an empty object when the reservation has no metadata', async () => {
dbMock._stmt.get.mockReturnValueOnce({ id: 42, metadata: null }).mockReturnValueOnce({ id: 42 });
reservationsRepo.getIdAndMetadata.mockResolvedValueOnce({ id: 42, metadata: null });
reservationsRepo.getFull.mockResolvedValueOnce({ id: 42 });
await svc().syncReservationPrice('5', 42, 99, undefined);
const writtenMeta = JSON.parse(dbMock._stmt.run.mock.calls[0][0] as string);
expect(writtenMeta).toEqual({ price: '99' });
expect(writtenMetadata()).toEqual({ price: '99' });
});
it('swallows errors so a sync failure never breaks the budget update', async () => {
dbMock.prepare.mockImplementationOnce(() => { throw new Error('db gone'); });
reservationsRepo.getIdAndMetadata.mockRejectedValueOnce(new Error('db gone'));
await expect(svc().syncReservationPrice('5', 42, 250, 'sock')).resolves.toBeUndefined();
expect(broadcast).not.toHaveBeenCalled();
});
// #2084: the card names the currency beside the mirrored figure.
it('writes the currency beside the price, upper-cased', async () => {
dbMock._stmt.get.mockReturnValueOnce({ id: 42, metadata: '{"seat":"1A"}' }).mockReturnValueOnce({ id: 42 });
reservationsRepo.getIdAndMetadata.mockResolvedValueOnce({ id: 42, metadata: '{"seat":"1A"}' });
reservationsRepo.getFull.mockResolvedValueOnce({ id: 42 });
await svc().syncReservationPrice('5', 42, 99.5, 'sock', 'usd');
expect(JSON.parse(dbMock._stmt.run.mock.calls[0][0] as string)).toEqual({ seat: '1A', price: '99.5', priceCurrency: 'USD' });
expect(writtenMetadata()).toEqual({ seat: '1A', price: '99.5', priceCurrency: 'USD' });
});
it('drops a stored currency when the expense is in the trip currency (null)', async () => {
dbMock._stmt.get.mockReturnValueOnce({ id: 42, metadata: '{"price":"10","priceCurrency":"CNY"}' }).mockReturnValueOnce({ id: 42 });
reservationsRepo.getIdAndMetadata.mockResolvedValueOnce({ id: 42, metadata: '{"price":"10","priceCurrency":"CNY"}' });
reservationsRepo.getFull.mockResolvedValueOnce({ id: 42 });
await svc().syncReservationPrice('5', 42, 12, undefined, null);
expect(JSON.parse(dbMock._stmt.run.mock.calls[0][0] as string)).toEqual({ price: '12' });
expect(writtenMetadata()).toEqual({ price: '12' });
});
it('leaves a stored currency alone when none is passed at all', async () => {
dbMock._stmt.get.mockReturnValueOnce({ id: 42, metadata: '{"price":"10","priceCurrency":"CNY"}' }).mockReturnValueOnce({ id: 42 });
reservationsRepo.getIdAndMetadata.mockResolvedValueOnce({ id: 42, metadata: '{"price":"10","priceCurrency":"CNY"}' });
reservationsRepo.getFull.mockResolvedValueOnce({ id: 42 });
await svc().syncReservationPrice('5', 42, 12, undefined);
expect(JSON.parse(dbMock._stmt.run.mock.calls[0][0] as string)).toEqual({ price: '12', priceCurrency: 'CNY' });
expect(writtenMetadata()).toEqual({ price: '12', priceCurrency: 'CNY' });
});
});
+116 -134
View File
@@ -72,6 +72,8 @@ vi.mock('../../../src/utils/ssrfGuard', () => ({
import { DAWARICH_KEY_MASK, type DawarichCapabilities } from '@trek/shared';
import { resetTestDb } from '../../helpers/test-db';
import { createUser } from '../../helpers/factories';
import { DawarichConnections } from '../../../src/db/entities/DawarichConnections.entity';
import { findRow, upsertRow } from '../../helpers/factories/rows';
import type { DawarichConnectionsRepository } from '../../../src/db/repositories/DawarichConnections.repository';
import { DawarichService } from '../../../src/nest/integrations/dawarich.service';
import type { TestOrm } from '../../helpers/test-orm';
@@ -121,7 +123,7 @@ interface ConnFixture {
capabilities: string | null;
}
function connect(userId: number, over: Partial<ConnFixture> = {}): void {
async function connect(userId: number, over: Partial<ConnFixture> = {}): Promise<void> {
const row: ConnFixture = {
url: HOST,
apiKey: 'stored-key',
@@ -133,41 +135,21 @@ function connect(userId: number, over: Partial<ConnFixture> = {}): void {
capabilities: null,
...over,
};
testDb
.prepare(
`INSERT OR REPLACE INTO dawarich_connections
(user_id, url, api_key, allow_insecure_tls, sync_enabled,
last_sync_at, last_sync_state, last_sync_error, capabilities)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
)
.run(
userId,
row.url,
row.apiKey,
row.allowInsecureTls,
row.syncEnabled,
row.lastSyncAt,
row.lastSyncState,
row.lastSyncError,
row.capabilities,
);
await upsertRow(t, DawarichConnections, {
user: userId,
url: row.url,
api_key: row.apiKey,
allow_insecure_tls: row.allowInsecureTls,
sync_enabled: row.syncEnabled,
last_sync_at: row.lastSyncAt,
last_sync_state: row.lastSyncState,
last_sync_error: row.lastSyncError,
capabilities: row.capabilities,
});
}
interface StoredRow {
url: string | null;
api_key: string | null;
allow_insecure_tls: number;
sync_enabled: number;
last_sync_at: string | null;
last_sync_state: string;
last_sync_error: string | null;
capabilities: string | null;
}
function row(userId = USER): StoredRow | undefined {
return testDb
.prepare('SELECT * FROM dawarich_connections WHERE user_id = ?')
.get(userId) as StoredRow | undefined;
function row(userId = USER) {
return findRow(t, DawarichConnections, { user: userId });
}
const FULL_CAPS: DawarichCapabilities = {
@@ -271,24 +253,24 @@ describe('DawarichService getCredentials', () => {
});
it('DAWARICH-SVC-002: a row without an address is not a connection', async () => {
connect(USER, { url: null });
await connect(USER, { url: null });
expect(await svc.getCredentials(USER)).toBeNull();
});
it('DAWARICH-SVC-003: a row without a key is not a connection', async () => {
connect(USER, { apiKey: null });
await connect(USER, { apiKey: null });
expect(await svc.getCredentials(USER)).toBeNull();
});
it('DAWARICH-SVC-004: a key that cannot be decrypted reads as "not connected" rather than travelling upstream as garbage', async () => {
// What a rotated ENCRYPTION_KEY or a half-restored backup leaves behind:
// the envelope is intact, the ciphertext is not ours.
connect(USER, { apiKey: 'enc:v1:bm90LWEtcmVhbC1ibG9i' });
await connect(USER, { apiKey: 'enc:v1:bm90LWEtcmVhbC1ibG9i' });
expect(await svc.getCredentials(USER)).toBeNull();
});
it('DAWARICH-SVC-005: a legacy plaintext key still works, with the TLS flag off', async () => {
connect(USER, { apiKey: 'plain-legacy-key', allowInsecureTls: 0 });
await connect(USER, { apiKey: 'plain-legacy-key', allowInsecureTls: 0 });
expect(await svc.getCredentials(USER)).toEqual({
baseUrl: HOST,
apiKey: 'plain-legacy-key',
@@ -312,12 +294,12 @@ describe('DawarichService getCredentials', () => {
describe('DawarichService isConnected', () => {
it('DAWARICH-SVC-010: both halves present means connected', async () => {
connect(USER);
await connect(USER);
expect(await svc.isConnected(USER)).toBe(true);
});
it('DAWARICH-SVC-011: an address without a key is not connected', async () => {
connect(USER, { apiKey: null });
await connect(USER, { apiKey: null });
expect(await svc.isConnected(USER)).toBe(false);
});
@@ -329,7 +311,7 @@ describe('DawarichService isConnected', () => {
// Deliberate difference from getCredentials: the settings card has to keep
// showing a connection the user can repair, and only the outbound path
// cares that the ciphertext is unusable.
connect(USER, { apiKey: 'enc:v1:bm90LWEtcmVhbC1ibG9i' });
await connect(USER, { apiKey: 'enc:v1:bm90LWEtcmVhbC1ibG9i' });
expect(await svc.isConnected(USER)).toBe(true);
expect(await svc.getCredentials(USER)).toBeNull();
});
@@ -343,14 +325,14 @@ describe('DawarichService listSyncableUserIds', () => {
const blankUrl = createUser(testDb).user.id;
const noKey = createUser(testDb).user.id;
connect(syncable);
connect(pollOff, { syncEnabled: 0 });
connect(noUrl, { url: null });
await connect(syncable);
await connect(pollOff, { syncEnabled: 0 });
await connect(noUrl, { url: null });
// An empty string is a distinct case from NULL: the save path stores NULL,
// but an older row or a hand-edited one can hold '' and it must not be
// polled, because the client would then request /api/v1 on no host at all.
connect(blankUrl, { url: '' });
connect(noKey, { apiKey: null });
await connect(blankUrl, { url: '' });
await connect(noKey, { apiKey: null });
expect(await svc.listSyncableUserIds()).toEqual([syncable]);
});
@@ -365,9 +347,9 @@ describe('DawarichService listSyncableUserIds', () => {
// settings card would still say the poll was on for all of them.
const second = createUser(testDb).user.id;
const pollOff = createUser(testDb).user.id;
connect(USER);
connect(second);
connect(pollOff, { syncEnabled: 0 });
await connect(USER);
await connect(second);
await connect(pollOff, { syncEnabled: 0 });
const ids = await svc.listSyncableUserIds();
@@ -397,7 +379,7 @@ describe('DawarichService getConnection', () => {
});
it('DAWARICH-SVC-031: a stored connection is reported with a mask where the key is, never the key', async () => {
connect(USER, {
await connect(USER, {
apiKey: 'stored-key',
allowInsecureTls: 1,
syncEnabled: 0,
@@ -423,24 +405,24 @@ describe('DawarichService getConnection', () => {
});
it('DAWARICH-SVC-032: an address without a key reports the address and no mask', async () => {
connect(USER, { apiKey: null });
await connect(USER, { apiKey: null });
expect(await svc.getConnection(USER)).toMatchObject({ url: HOST, apiKeyMasked: '', connected: false });
});
it('DAWARICH-SVC-033: a null address reads as an empty string, so the form binds to a value', async () => {
connect(USER, { url: null, apiKey: null });
await connect(USER, { url: null, apiKey: null });
expect(await svc.getConnection(USER)).toMatchObject({ url: '', connected: false, syncEnabled: true });
});
it.each(['ok', 'partial', 'failed'])('DAWARICH-SVC-034: the known sync state %s passes through', async (state) => {
connect(USER, { lastSyncState: state });
await connect(USER, { lastSyncState: state });
expect((await svc.getConnection(USER)).lastSyncState).toBe(state);
});
it.each(['syncing', 'OK', 'success', ''])(
'DAWARICH-SVC-035: the unrecognised sync state %j reads as "never" rather than reaching a client that has no branch for it',
async (state) => {
connect(USER, { lastSyncState: state });
await connect(USER, { lastSyncState: state });
expect((await svc.getConnection(USER)).lastSyncState).toBe('never');
},
);
@@ -448,7 +430,7 @@ describe('DawarichService getConnection', () => {
it.each(['{not json', '', 'undefined'])(
'DAWARICH-SVC-036: the malformed capabilities blob %j reads as "not probed yet", never as a crash on the settings page',
async (raw) => {
connect(USER, { capabilities: raw });
await connect(USER, { capabilities: raw });
expect((await svc.getConnection(USER)).capabilities).toBeNull();
},
);
@@ -456,7 +438,7 @@ describe('DawarichService getConnection', () => {
it.each(['null', '42', '"visits"', 'true'])(
'DAWARICH-SVC-037: the JSON scalar %s is not a capabilities object and reads as not probed',
async (raw) => {
connect(USER, { capabilities: raw });
await connect(USER, { capabilities: raw });
expect((await svc.getConnection(USER)).capabilities).toBeNull();
},
);
@@ -465,7 +447,7 @@ describe('DawarichService getConnection', () => {
// The column survives an upgrade, so the shape is whatever was current when
// the last probe ran. Missing flags are off and a missing probe timestamp
// is the epoch, which reads as "long ago" everywhere it is compared.
connect(USER, { capabilities: JSON.stringify({ visits: true, serverVersion: 17, extra: 'ignored' }) });
await connect(USER, { capabilities: JSON.stringify({ visits: true, serverVersion: 17, extra: 'ignored' }) });
expect((await svc.getConnection(USER)).capabilities).toEqual({
visits: true,
@@ -481,7 +463,7 @@ describe('DawarichService getConnection', () => {
});
it('DAWARICH-SVC-039: a complete blob round-trips unchanged', async () => {
connect(USER, { capabilities: JSON.stringify(FULL_CAPS) });
await connect(USER, { capabilities: JSON.stringify(FULL_CAPS) });
expect((await svc.getConnection(USER)).capabilities).toEqual(FULL_CAPS);
});
});
@@ -492,12 +474,12 @@ describe('DawarichService getCapabilities', () => {
});
it('DAWARICH-SVC-041: null when the connection has never been probed', async () => {
connect(USER);
await connect(USER);
expect(await svc.getCapabilities(USER)).toBeNull();
});
it('DAWARICH-SVC-042: the stored probe otherwise', async () => {
connect(USER, { capabilities: JSON.stringify(FULL_CAPS) });
await connect(USER, { capabilities: JSON.stringify(FULL_CAPS) });
expect(await svc.getCapabilities(USER)).toEqual(FULL_CAPS);
});
});
@@ -511,7 +493,7 @@ describe('DawarichService saveSettings', () => {
const out = await svc.saveSettings(USER, ` ${HOST} `, 'super-secret', true, true, IP);
expect(out).toEqual({ success: true });
const stored = row();
const stored = await row();
expect(stored?.url).toBe(HOST);
expect(stored?.allow_insecure_tls).toBe(1);
expect(stored?.sync_enabled).toBe(1);
@@ -524,31 +506,31 @@ describe('DawarichService saveSettings', () => {
it('DAWARICH-SVC-051: turning the background poll off is persisted, and the card reports it', async () => {
await svc.saveSettings(USER, HOST, 'k', false, false, IP);
expect(row()?.sync_enabled).toBe(0);
expect(row()?.allow_insecure_tls).toBe(0);
expect((await row())?.sync_enabled).toBe(0);
expect((await row())?.allow_insecure_tls).toBe(0);
expect((await svc.getConnection(USER)).syncEnabled).toBe(false);
expect(await svc.listSyncableUserIds()).toEqual([]);
});
it('DAWARICH-SVC-052: a blank key field keeps the stored key, because the field is never prefilled', async () => {
await svc.saveSettings(USER, HOST, 'first-key', false, true, IP);
const encrypted = row()?.api_key;
const encrypted = (await row())?.api_key;
await svc.saveSettings(USER, `${HOST}`, undefined, true, true, IP);
expect(row()?.api_key).toBe(encrypted);
expect(row()?.allow_insecure_tls).toBe(1);
expect((await row())?.api_key).toBe(encrypted);
expect((await row())?.allow_insecure_tls).toBe(1);
});
it.each([DAWARICH_KEY_MASK, ' ', ''])(
'DAWARICH-SVC-053: the key field %j means "keep what is stored"',
async (typed) => {
await svc.saveSettings(USER, HOST, 'first-key', false, true, IP);
const encrypted = row()?.api_key;
const encrypted = (await row())?.api_key;
await svc.saveSettings(USER, HOST, typed, false, true, IP);
expect(row()?.api_key).toBe(encrypted);
expect((await row())?.api_key).toBe(encrypted);
expect((await svc.getCredentials(USER))?.apiKey).toBe('first-key');
},
);
@@ -561,7 +543,7 @@ describe('DawarichService saveSettings', () => {
});
it('DAWARICH-SVC-055: moving to a different host without retyping the key drops the key and every artefact of the old instance', async () => {
connect(USER, {
await connect(USER, {
url: 'https://old.example',
apiKey: 'stored-key',
lastSyncAt: '2026-09-12T04:15:00.000Z',
@@ -572,7 +554,7 @@ describe('DawarichService saveSettings', () => {
await svc.saveSettings(USER, 'https://new.example', undefined, false, true, IP);
const stored = row();
const stored = await row();
expect(stored?.url).toBe('https://new.example');
expect(stored?.api_key).toBeNull();
expect(stored?.capabilities).toBeNull();
@@ -582,9 +564,9 @@ describe('DawarichService saveSettings', () => {
});
it('DAWARICH-SVC-056: a different port is a different instance, so the key goes with it', async () => {
connect(USER, { url: 'https://d.example' });
await connect(USER, { url: 'https://d.example' });
await svc.saveSettings(USER, 'https://d.example:8443', undefined, false, true, IP);
expect(row()?.api_key).toBeNull();
expect((await row())?.api_key).toBeNull();
});
it.each([
@@ -594,15 +576,15 @@ describe('DawarichService saveSettings', () => {
])(
'DAWARICH-SVC-057: %s to %s is the same instance and keeps the key',
async (before, after) => {
connect(USER, { url: before, apiKey: 'stored-key' });
await connect(USER, { url: before, apiKey: 'stored-key' });
await svc.saveSettings(USER, after, undefined, false, true, IP);
expect(row()?.api_key).toBe('stored-key');
expect(row()?.url).toBe(after);
expect((await row())?.api_key).toBe('stored-key');
expect((await row())?.url).toBe(after);
},
);
it('DAWARICH-SVC-058: a key typed at the same time as the host change survives, because it was minted for the new host', async () => {
connect(USER, { url: 'https://old.example', apiKey: 'stored-key' });
await connect(USER, { url: 'https://old.example', apiKey: 'stored-key' });
await svc.saveSettings(USER, 'https://new.example', 'minted-for-new', false, true, IP);
@@ -617,32 +599,32 @@ describe('DawarichService saveSettings', () => {
// No scheme, so the URL constructor throws and the comparison falls back to
// string equality. Erring towards keeping the key here is safe precisely
// because the text did not change.
connect(USER, { url: 'dawarich.example', apiKey: 'stored-key' });
await connect(USER, { url: 'dawarich.example', apiKey: 'stored-key' });
await svc.saveSettings(USER, 'dawarich.example', undefined, false, true, IP);
expect(row()?.api_key).toBe('stored-key');
expect((await row())?.api_key).toBe('stored-key');
});
it('DAWARICH-SVC-060: an unparseable stored address against a different one drops the key', async () => {
connect(USER, { url: 'dawarich.example', apiKey: 'stored-key' });
await connect(USER, { url: 'dawarich.example', apiKey: 'stored-key' });
await svc.saveSettings(USER, 'https://new.example', undefined, false, true, IP);
expect(row()?.api_key).toBeNull();
expect((await row())?.api_key).toBeNull();
});
it('DAWARICH-SVC-061: a row whose address column is null counts as no previous host, so nothing is dropped', async () => {
connect(USER, { url: null, apiKey: 'stored-key' });
await connect(USER, { url: null, apiKey: 'stored-key' });
await svc.saveSettings(USER, HOST, undefined, false, true, IP);
expect(row()?.api_key).toBe('stored-key');
expect(row()?.url).toBe(HOST);
expect((await row())?.api_key).toBe('stored-key');
expect((await row())?.url).toBe(HOST);
});
it('DAWARICH-SVC-062: clearing the address wipes the key, because a live credential for a server nobody named is just a stored secret', async () => {
connect(USER, {
await connect(USER, {
apiKey: 'stored-key',
lastSyncAt: '2026-09-12T04:15:00.000Z',
lastSyncState: 'ok',
@@ -653,7 +635,7 @@ describe('DawarichService saveSettings', () => {
const out = await svc.saveSettings(USER, ' ', undefined, false, true, IP);
expect(out).toEqual({ success: true });
const stored = row();
const stored = await row();
expect(stored?.url).toBeNull();
expect(stored?.api_key).toBeNull();
expect(stored?.capabilities).toBeNull();
@@ -663,9 +645,9 @@ describe('DawarichService saveSettings', () => {
});
it('DAWARICH-SVC-063: a key typed while the address is cleared is discarded with it', async () => {
connect(USER, { apiKey: 'stored-key' });
await connect(USER, { apiKey: 'stored-key' });
await svc.saveSettings(USER, '', 'pointless-key', false, true, IP);
expect(row()?.api_key).toBeNull();
expect((await row())?.api_key).toBeNull();
});
it('DAWARICH-SVC-064: an empty address is never resolved, so clearing the connection cannot fail on DNS', async () => {
@@ -683,7 +665,7 @@ describe('DawarichService saveSettings', () => {
code: 'invalid_url',
error: 'Only HTTP and HTTPS URLs are allowed',
});
expect(row()).toBeUndefined();
expect(await row()).toBeNull();
expect(audit.writeAudit).not.toHaveBeenCalled();
});
@@ -708,7 +690,7 @@ describe('DawarichService saveSettings', () => {
warningCode: 'private_ip',
warningIp: '192.168.0.5',
});
expect(row()?.url).toBe('http://dawarich.lan:3000');
expect((await row())?.url).toBe('http://dawarich.lan:3000');
expect(audit.writeAudit).toHaveBeenCalledWith({
userId: USER,
action: 'dawarich.private_ip_configured',
@@ -730,7 +712,7 @@ describe('DawarichService saveSettings', () => {
const out = await svc.saveSettings(USER, 'http://10.0.0.9:3000', 'k', false, true, IP);
expect(out).toMatchObject({ success: true, warningCode: 'private_ip', warningIp: '10.0.0.9' });
expect(row()?.url).toBe('http://10.0.0.9:3000');
expect((await row())?.url).toBe('http://10.0.0.9:3000');
});
it('DAWARICH-SVC-069: a private result the resolver could not name still warns, with no IP in the warning payload', async () => {
@@ -744,7 +726,7 @@ describe('DawarichService saveSettings', () => {
});
it('DAWARICH-SVC-070: the address write and the key write are one transaction, so a failed key write cannot leave the old key pointed at the new host', async () => {
connect(USER, { url: 'https://old.example', apiKey: 'stored-key' });
await connect(USER, { url: 'https://old.example', apiKey: 'stored-key' });
// Rollback proof (R7's transaction, one of the plan's eight): spy the
// SECOND write (the key) to throw and confirm the FIRST write (the
// address, already committed to the transactional connection) is rolled
@@ -760,7 +742,7 @@ describe('DawarichService saveSettings', () => {
spy.mockRestore();
}
const stored = row();
const stored = await row();
expect(stored?.url).toBe('https://old.example');
expect(stored?.api_key).toBe('stored-key');
});
@@ -770,8 +752,8 @@ describe('DawarichService saveSettings', () => {
// file and then logs the whole install out of Dawarich the first time one
// person edits their own URL. Two victims, one bystander, both statements.
const bystander = createUser(testDb).user.id;
connect(USER, { url: 'https://old.example', apiKey: 'stored-key' });
connect(bystander, {
await connect(USER, { url: 'https://old.example', apiKey: 'stored-key' });
await connect(bystander, {
apiKey: 'bystander-key',
capabilities: JSON.stringify(FULL_CAPS),
lastSyncState: 'ok',
@@ -780,8 +762,8 @@ describe('DawarichService saveSettings', () => {
await svc.saveSettings(USER, 'https://new.example', undefined, false, true, IP);
await svc.saveSettings(USER, '', undefined, false, true, IP);
expect(row()?.api_key).toBeNull();
expect(row(bystander)).toMatchObject({
expect((await row())?.api_key).toBeNull();
expect(await row(bystander)).toMatchObject({
url: HOST,
api_key: 'bystander-key',
last_sync_state: 'ok',
@@ -794,7 +776,7 @@ describe('DawarichService saveSettings', () => {
// point at. Carrying them over would show the old instance's version and
// "last sync ok" on a connection that has never been asked anything, and
// the tracks layer would skip an endpoint the new instance does have.
connect(USER, {
await connect(USER, {
url: 'https://old.example',
apiKey: 'stored-key',
lastSyncAt: '2026-09-12T04:15:00.000Z',
@@ -806,7 +788,7 @@ describe('DawarichService saveSettings', () => {
await svc.saveSettings(USER, 'https://new.example', 'minted-for-new', false, true, IP);
expect((await svc.getCredentials(USER))?.apiKey).toBe('minted-for-new');
const stored = row();
const stored = await row();
expect(stored?.url).toBe('https://new.example');
expect(stored?.capabilities).toBeNull();
expect(stored?.last_sync_state).toBe('never');
@@ -815,7 +797,7 @@ describe('DawarichService saveSettings', () => {
});
it('DAWARICH-SVC-073: a new key on the same host keeps the probe, because it describes the host and not the key', async () => {
connect(USER, {
await connect(USER, {
url: 'https://d.example/api',
apiKey: 'stored-key',
lastSyncState: 'ok',
@@ -825,8 +807,8 @@ describe('DawarichService saveSettings', () => {
await svc.saveSettings(USER, 'https://d.example/', 'rotated-key', false, true, IP);
expect((await svc.getCredentials(USER))?.apiKey).toBe('rotated-key');
expect(row()?.capabilities).toBe(JSON.stringify(FULL_CAPS));
expect(row()?.last_sync_state).toBe('ok');
expect((await row())?.capabilities).toBe(JSON.stringify(FULL_CAPS));
expect((await row())?.last_sync_state).toBe('ok');
});
});
@@ -836,11 +818,11 @@ describe('DawarichService saveSettings', () => {
describe('DawarichService disconnect', () => {
it('DAWARICH-SVC-080: forgets the credential and says so in the audit log', async () => {
connect(USER);
await connect(USER);
await svc.disconnect(USER, '198.51.100.7');
expect(row()).toBeUndefined();
expect(await row()).toBeNull();
expect((await svc.getConnection(USER)).connected).toBe(false);
expect(audit.writeAudit).toHaveBeenCalledWith({
userId: USER,
@@ -853,18 +835,18 @@ describe('DawarichService disconnect', () => {
it('DAWARICH-SVC-081: disconnecting something that was never connected is a no-op that still leaves a trail', async () => {
await svc.disconnect(USER, null);
expect(row()).toBeUndefined();
expect(await row()).toBeNull();
expect(audit.writeAudit).toHaveBeenCalledWith(expect.objectContaining({ ip: null }));
});
it('DAWARICH-SVC-082: only the calling user loses their connection', async () => {
const other = createUser(testDb).user.id;
connect(USER);
connect(other);
await connect(USER);
await connect(other);
await svc.disconnect(USER, null);
expect(row(other)).toBeDefined();
expect(await row(other)).not.toBeNull();
});
});
@@ -890,7 +872,7 @@ describe('DawarichService testConnection', () => {
});
it('DAWARICH-SVC-092: a key typed with no address falls back to the stored address', async () => {
connect(USER, { apiKey: 'stored-key' });
await connect(USER, { apiKey: 'stored-key' });
await svc.testConnection(USER, '', 'typed-key', false);
@@ -898,7 +880,7 @@ describe('DawarichService testConnection', () => {
});
it('DAWARICH-SVC-093: the mask means "use the stored key", which is what a form that only changed the TLS flag posts back', async () => {
connect(USER, { apiKey: 'stored-key' });
await connect(USER, { apiKey: 'stored-key' });
await svc.testConnection(USER, HOST, DAWARICH_KEY_MASK, true);
@@ -906,7 +888,7 @@ describe('DawarichService testConnection', () => {
});
it('DAWARICH-SVC-094: a stored connection tested as it stands persists the freshly probed capabilities', async () => {
connect(USER, { apiKey: 'stored-key' });
await connect(USER, { apiKey: 'stored-key' });
const out = await svc.testConnection(USER, HOST, undefined, false);
@@ -915,7 +897,7 @@ describe('DawarichService testConnection', () => {
});
it('DAWARICH-SVC-095: a test run against a different address than the stored one must not overwrite what is on file', async () => {
connect(USER, { apiKey: 'stored-key', capabilities: JSON.stringify(FULL_CAPS) });
await connect(USER, { apiKey: 'stored-key', capabilities: JSON.stringify(FULL_CAPS) });
await svc.testConnection(USER, 'https://someone-elses.example', 'their-key', false);
@@ -926,11 +908,11 @@ describe('DawarichService testConnection', () => {
const out = await svc.testConnection(USER, HOST, 'typed-key', false);
expect(out.connected).toBe(true);
expect(row()).toBeUndefined();
expect(await row()).toBeNull();
});
it('DAWARICH-SVC-097: the visit count is the last 30 days, and it is only asked for when the visits endpoint exists', async () => {
connect(USER, { apiKey: 'stored-key' });
await connect(USER, { apiKey: 'stored-key' });
allEndpointsAnswer([visit({ id: 1 }), visit({ id: 2 }), visit({ id: 3 })]);
const out = await svc.testConnection(USER, HOST, undefined, false);
@@ -942,7 +924,7 @@ describe('DawarichService testConnection', () => {
});
it('DAWARICH-SVC-098: an instance without a visits endpoint reports zero rather than asking a route that is not there', async () => {
connect(USER, { apiKey: 'stored-key' });
await connect(USER, { apiKey: 'stored-key' });
client.listVisits.mockRejectedValue(notFound());
const out = await svc.testConnection(USER, HOST, undefined, false);
@@ -953,7 +935,7 @@ describe('DawarichService testConnection', () => {
});
it('DAWARICH-SVC-099: a count that fails after the connection is already proven is not a failed connection', async () => {
connect(USER, { apiKey: 'stored-key' });
await connect(USER, { apiKey: 'stored-key' });
client.listVisits
.mockResolvedValueOnce({ visits: [visit()], truncated: false, version: '1.14.4' })
.mockRejectedValueOnce(new DawarichError('rate_limited', 'HTTP 429', 429));
@@ -964,7 +946,7 @@ describe('DawarichService testConnection', () => {
});
it('DAWARICH-SVC-100: a rejected key is a 200 the form can render, carrying the code the client translates', async () => {
connect(USER, { apiKey: 'stored-key' });
await connect(USER, { apiKey: 'stored-key' });
client.probe.mockRejectedValue(new DawarichError('unauthorized', 'Dawarich answered HTTP 401', 401));
const out = await svc.testConnection(USER, HOST, undefined, false);
@@ -974,7 +956,7 @@ describe('DawarichService testConnection', () => {
});
it('DAWARICH-SVC-101: the free-text detail rides along when there is one, for the person debugging their own reverse proxy', async () => {
connect(USER, { apiKey: 'stored-key' });
await connect(USER, { apiKey: 'stored-key' });
client.probe.mockRejectedValue(
new DawarichError('invalid_response', 'Not JSON', 200, '<html>login</html>'),
);
@@ -987,7 +969,7 @@ describe('DawarichService testConnection', () => {
});
it('DAWARICH-SVC-102: anything that is not a DawarichError reads as unreachable rather than leaking the message', async () => {
connect(USER, { apiKey: 'stored-key' });
await connect(USER, { apiKey: 'stored-key' });
client.probe.mockRejectedValue(new Error('ECONNRESET on https://dawarich.example?api_key=secret'));
expect(await svc.testConnection(USER, HOST, undefined, false)).toEqual({
@@ -997,7 +979,7 @@ describe('DawarichService testConnection', () => {
});
it('DAWARICH-SVC-103: a failed probe leaves the stored capabilities untouched', async () => {
connect(USER, { apiKey: 'stored-key', capabilities: JSON.stringify(FULL_CAPS) });
await connect(USER, { apiKey: 'stored-key', capabilities: JSON.stringify(FULL_CAPS) });
client.probe.mockRejectedValue(new DawarichError('unauthorized', 'HTTP 401', 401));
await svc.testConnection(USER, HOST, undefined, false);
@@ -1009,7 +991,7 @@ describe('DawarichService testConnection', () => {
// The rotated-ENCRYPTION_KEY case reached from the outbound side rather than
// through getCredentials: the row still reads as connected on the card, and
// the test button has to say what is wrong without sending the blob anywhere.
connect(USER, { apiKey: 'enc:v1:bm90LWEtcmVhbC1ibG9i' });
await connect(USER, { apiKey: 'enc:v1:bm90LWEtcmVhbC1ibG9i' });
expect(await svc.testConnection(USER, HOST, undefined, false)).toEqual({
connected: false,
@@ -1023,7 +1005,7 @@ describe('DawarichService testConnection', () => {
// press Test" is the ordinary way to move an instance. Falling back to the
// stored key here would carry it to whatever host was just typed, which is
// exactly what saveSettings refuses to persist.
connect(USER, { url: 'https://old.example', apiKey: 'stored-key' });
await connect(USER, { url: 'https://old.example', apiKey: 'stored-key' });
const out = await svc.testConnection(USER, 'https://someone-elses.example', undefined, false);
@@ -1033,7 +1015,7 @@ describe('DawarichService testConnection', () => {
});
it('DAWARICH-SVC-106: the mask against a different host is a blank field, not a key', async () => {
connect(USER, { url: 'https://old.example', apiKey: 'stored-key' });
await connect(USER, { url: 'https://old.example', apiKey: 'stored-key' });
const out = await svc.testConnection(USER, 'https://old.example:8443', DAWARICH_KEY_MASK, false);
@@ -1045,7 +1027,7 @@ describe('DawarichService testConnection', () => {
// The mirror of 105: the same instance under a corrected path is where the
// key was issued, and refusing it would make every trailing-slash edit
// demand the key again.
connect(USER, { url: 'https://d.example/api', apiKey: 'stored-key' });
await connect(USER, { url: 'https://d.example/api', apiKey: 'stored-key' });
const out = await svc.testConnection(USER, 'https://d.example/', undefined, false);
@@ -1054,7 +1036,7 @@ describe('DawarichService testConnection', () => {
});
it('DAWARICH-SVC-108: a key typed for the new host is used as typed, so moving an instance and testing it first still works', async () => {
connect(USER, { url: 'https://old.example', apiKey: 'stored-key' });
await connect(USER, { url: 'https://old.example', apiKey: 'stored-key' });
const out = await svc.testConnection(USER, 'https://new.example', 'minted-for-new', false);
@@ -1176,33 +1158,33 @@ describe('DawarichService probeCapabilities', () => {
describe('DawarichService storeCapabilities', () => {
it('DAWARICH-SVC-130: writes the probe where getCapabilities reads it', async () => {
connect(USER);
await connect(USER);
await svc.storeCapabilities(USER, FULL_CAPS);
expect(await svc.getCapabilities(USER)).toEqual(FULL_CAPS);
expect(JSON.parse(row()?.capabilities ?? 'null')).toEqual(FULL_CAPS);
expect(JSON.parse((await row())?.capabilities ?? 'null')).toEqual(FULL_CAPS);
});
it('DAWARICH-SVC-131: storing against a user with no connection changes nothing instead of creating a row without a credential', async () => {
await svc.storeCapabilities(USER, FULL_CAPS);
expect(row()).toBeUndefined();
expect(await row()).toBeNull();
});
it('DAWARICH-SVC-132: only the named user is touched', async () => {
const other = createUser(testDb).user.id;
connect(USER);
connect(other);
await connect(USER);
await connect(other);
await svc.storeCapabilities(USER, FULL_CAPS);
expect(row(other)?.capabilities).toBeNull();
expect((await row(other))?.capabilities).toBeNull();
});
});
describe('DawarichService recordSyncResult', () => {
it('DAWARICH-SVC-140: a failure is stored with its reason and a timestamp the card can show', async () => {
connect(USER);
await connect(USER);
const before = Date.now();
await svc.recordSyncResult(USER, 'failed', 'unreachable');
@@ -1214,7 +1196,7 @@ describe('DawarichService recordSyncResult', () => {
});
it('DAWARICH-SVC-141: a later success clears the error rather than leaving the old one on screen', async () => {
connect(USER, { lastSyncState: 'failed', lastSyncError: 'unreachable' });
await connect(USER, { lastSyncState: 'failed', lastSyncError: 'unreachable' });
await svc.recordSyncResult(USER, 'ok', null);
@@ -1222,13 +1204,13 @@ describe('DawarichService recordSyncResult', () => {
});
it('DAWARICH-SVC-142: a partial run is its own state, distinct from both ok and failed', async () => {
connect(USER);
await connect(USER);
await svc.recordSyncResult(USER, 'partial', 'too_large');
expect((await svc.getConnection(USER)).lastSyncState).toBe('partial');
});
it('DAWARICH-SVC-143: recording against a user with no connection changes nothing', async () => {
await svc.recordSyncResult(USER, 'ok', null);
expect(row()).toBeUndefined();
expect(await row()).toBeNull();
});
});
@@ -16,6 +16,7 @@ import { DayBoundariesController } from '../../../src/nest/roadtrip/day-boundari
import { DayBoundariesMcp } from '../../../src/nest/roadtrip/day-boundaries.mcp';
import { DayAssignments } from '../../../src/db/entities/DayAssignments.entity';
import { RoadtripDayBoundaries } from '../../../src/db/entities/RoadtripDayBoundaries.entity';
import { deleteRows } from '../../helpers/factories/rows';
import type { McpContext } from '../../../src/nest-mcp';
const testDb = createSnapshotTestDb();
@@ -71,7 +72,7 @@ it('replaces one day only, persists place snapping and cascades deleted visits',
expect(await service.list(tripB.id)).toEqual([]);
await service.remove(tripB.id, 1);
expect(await service.list(tripA.id)).toHaveLength(2);
testDb.prepare('DELETE FROM day_assignments WHERE id = ?').run(toId);
await deleteRows(t, DayAssignments, { id: toId });
expect(await service.list(tripA.id)).toEqual([{ ...boundary, to_assignment_id: null, fraction: 1 }]);
});
@@ -12,18 +12,8 @@ vi.mock('../../../src/db/database', async () => {
const { createSnapshotTestDb } = await import('../../helpers/db-mock');
const db = createSnapshotTestDb();
// DatabaseService delegates the joined read straight through to the module, so the
// mock has to answer it: without this the insert half of every import throws.
const mock = {
db,
closeDb: () => {},
reinitialize: () => {},
getPlaceWithTags: (placeId: any) => {
const place: any = db.prepare('SELECT * FROM places WHERE id = ?').get(placeId);
return place ? { ...place, category: null, tags: [] } : null;
},
};
return mock;
// The joined place read goes through PlacesRepository now; the module only hands out the handle.
return { db, closeDb: () => {}, reinitialize: () => {} };
});
@@ -4,6 +4,7 @@ import { resetTestDb } from '../../helpers/test-db';
import { createTestOrm, type TestOrm } from '../../helpers/test-orm';
import { createDay, createPlace, createTrip, createUser } from '../../helpers/factories';
import { createTestUnitOfWork } from '../../helpers/test-uow';
import { updateRows } from '../../helpers/factories/rows';
import { RoadtripService } from '../../../src/nest/roadtrip/roadtrip.service';
import { Days } from '../../../src/db/entities/Days.entity';
import { Places } from '../../../src/db/entities/Places.entity';
@@ -33,7 +34,7 @@ async function makeService() {
);
}
function fixture() {
async function fixture() {
const { user } = createUser(testDb);
const tripA = createTrip(testDb, user.id);
const tripB = createTrip(testDb, user.id);
@@ -43,17 +44,17 @@ function fixture() {
const track = createPlace(testDb, tripA.id, { name: 'Atlantic Road' });
const hotel = createPlace(testDb, tripA.id, { name: 'A hotel' });
const otherTrack = createPlace(testDb, tripB.id, { name: 'Somebody else’s track' });
testDb.prepare('UPDATE places SET route_geometry = ? WHERE id = ?').run('[[52,13],[52,14]]', track.id);
testDb.prepare('UPDATE places SET route_geometry = ? WHERE id = ?').run('[[40,2],[40,3]]', otherTrack.id);
await updateRows(t, Places, { id: track.id }, { route_geometry: '[[52,13],[52,14]]' });
await updateRows(t, Places, { id: otherTrack.id }, { route_geometry: '[[40,2],[40,3]]' });
return { tripA, tripB, dayA1, dayA2, dayB1, track, hotel, otherTrack };
}
describe('RoadtripService', () => {
let service: RoadtripService;
let f: ReturnType<typeof fixture>;
let f: Awaited<ReturnType<typeof fixture>>;
beforeEach(async () => {
service = await makeService();
f = fixture();
f = await fixture();
});
it('ROADTRIP-SVC-001: a day of another trip is not this trip’s day', async () => {
@@ -24,6 +24,9 @@ import { StorageRegistryService, BACKENDS_KEY, CATEGORIES_KEY } from '../../../.
import { StorageService } from '../../../../src/nest/storage/storage.service';
import { StorageConflictError } from '../../../../src/nest/storage/storage.types';
import { createTestUnitOfWork, createTestAppSettingsRepo, sharedTestOrm } from '../../../helpers/test-uow';
import { readAppSetting, setAppSetting } from '../../../helpers/factories/settings';
import { deleteRows } from '../../../helpers/factories/rows';
import { AppSettings } from '../../../../src/db/entities/AppSettings.entity';
const tmpDirs: string[] = [];
function makeTmpDir(): string {
@@ -31,12 +34,11 @@ function makeTmpDir(): string {
tmpDirs.push(dir);
return dir;
}
function setSetting(key: string, value: string): void {
testDb.prepare('INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)').run(key, value);
async function setSetting(key: string, value: string): Promise<void> {
await setAppSetting(await sharedTestOrm(testDb), key, value);
}
function readRow(key: string): string | undefined {
const row = testDb.prepare('SELECT value FROM app_settings WHERE key = ?').get(key) as { value: string } | undefined;
return row?.value;
async function readRow(key: string): Promise<string | undefined> {
return (await readAppSetting(await sharedTestOrm(testDb), key)) ?? undefined;
}
const S3_OPTIONS = {
@@ -53,7 +55,7 @@ const S3_OPTIONS = {
/** Real registry + real service over the in-memory DB. */
async function makeService(opts: { uploadsRoot?: string } = {}) {
const uploadsRoot = opts.uploadsRoot ?? makeTmpDir();
setSetting(BACKENDS_KEY, JSON.stringify([{ name: 'uploads-local', type: 'local', options: { root: uploadsRoot } }]));
await setSetting(BACKENDS_KEY, JSON.stringify([{ name: 'uploads-local', type: 'local', options: { root: uploadsRoot } }]));
const env = { placePhotoDir: undefined };
const uow = await createTestUnitOfWork(testDb);
const appSettings = await createTestAppSettingsRepo(testDb);
@@ -92,8 +94,8 @@ async function put(service: StorageAdminService, config: StorageConfig): Promise
await service.applyConfig({ ...config, version: (await (await service.state())).version });
}
beforeEach(() => {
testDb.prepare("DELETE FROM app_settings WHERE key LIKE 'storage.%'").run();
beforeEach(async () => {
await deleteRows(await sharedTestOrm(testDb), AppSettings, { key: { $like: 'storage.%' } });
});
afterEach(() => {
vi.restoreAllMocks();
@@ -138,7 +140,7 @@ describe('StorageAdminService.state', () => {
const { service, registry } = await makeService();
expect((await service.state()).configError).toBeNull();
setSetting(CATEGORIES_KEY, 'garbage {');
await setSetting(CATEGORIES_KEY, 'garbage {');
await registry.reload();
expect((await service.state()).configError).toBe(registry.lastLoadError());
expect((await service.state()).configError).not.toBeNull();
@@ -163,7 +165,7 @@ describe('StorageAdminService.applyConfig', () => {
categories: { backups: 'nas-backups' },
}));
expect(registry.resolve('backups').backendName).toBe('nas-backups'); // reload() ran
expect(JSON.parse(readRow(CATEGORIES_KEY)!)).toEqual({ backups: 'nas-backups' });
expect(JSON.parse((await readRow(CATEGORIES_KEY))!)).toEqual({ backups: 'nas-backups' });
});
it('STORADM-011 encrypts plaintext secrets at rest', async () => {
@@ -172,7 +174,7 @@ describe('StorageAdminService.applyConfig', () => {
backends: [{ name: 'off-box', type: 's3', options: S3_OPTIONS }],
categories: { backups: 'off-box' },
}));
const stored = JSON.parse(readRow(BACKENDS_KEY)!) as Array<{ name: string; options: Record<string, unknown> }>;
const stored = JSON.parse((await readRow(BACKENDS_KEY))!) as Array<{ name: string; options: Record<string, unknown> }>;
const secret = String(stored.find((b) => b.name === 'off-box')!.options.secretAccessKey);
expect(secret.startsWith('enc:v1:')).toBe(true);
});
@@ -183,27 +185,27 @@ describe('StorageAdminService.applyConfig', () => {
backends: [{ name: 'off-box', type: 's3', options: S3_OPTIONS }],
categories: { backups: 'off-box' },
}));
const before = JSON.parse(readRow(BACKENDS_KEY)!) as Array<{ name: string; options: Record<string, unknown> }>;
const before = JSON.parse((await readRow(BACKENDS_KEY))!) as Array<{ name: string; options: Record<string, unknown> }>;
const cipherBefore = before.find((b) => b.name === 'off-box')!.options.secretAccessKey;
await put(service, configWith(uploadsRoot, {
backends: [{ name: 'off-box', type: 's3', options: { ...S3_OPTIONS, secretAccessKey: MASKED_SETTING_VALUE } }],
categories: { backups: 'off-box' },
}));
const after = JSON.parse(readRow(BACKENDS_KEY)!) as Array<{ name: string; options: Record<string, unknown> }>;
const after = JSON.parse((await readRow(BACKENDS_KEY))!) as Array<{ name: string; options: Record<string, unknown> }>;
expect(after.find((b) => b.name === 'off-box')!.options.secretAccessKey).toBe(cipherBefore);
});
it('STORADM-013 a mask on a renamed/new backend throws the re-enter error, persists nothing', async () => {
const { service, uploadsRoot } = await makeService();
const before = readRow(BACKENDS_KEY);
const before = await readRow(BACKENDS_KEY);
await expect(
put(service, configWith(uploadsRoot, {
backends: [{ name: 'brand-new', type: 's3', options: { ...S3_OPTIONS, secretAccessKey: MASKED_SETTING_VALUE } }],
categories: {},
})),
).rejects.toThrow("re-enter the secret 'secretAccessKey' for 'brand-new'");
expect(readRow(BACKENDS_KEY)).toBe(before);
expect(await readRow(BACKENDS_KEY)).toBe(before);
});
it('STORADM-014 a plaintext secret saves without an explicit ENCRYPTION_KEY and is still encrypted at rest', async () => {
@@ -213,7 +215,7 @@ describe('StorageAdminService.applyConfig', () => {
backends: [{ name: 'off-box', type: 's3', options: S3_OPTIONS }],
categories: {},
}));
const stored = JSON.parse(readRow(BACKENDS_KEY)!) as Array<{ name: string; options: Record<string, string> }>;
const stored = JSON.parse((await readRow(BACKENDS_KEY))!) as Array<{ name: string; options: Record<string, string> }>;
const offBox = stored.find((b) => b.name === 'off-box')!;
expect(offBox.options.secretAccessKey.startsWith('enc:v1:')).toBe(true);
});
@@ -225,29 +227,29 @@ describe('StorageAdminService.applyConfig', () => {
backends: [{ name: 'off-box', type: 's3', options: { ...S3_OPTIONS, secretAccessKey: encrypt_api_key('sk') } }],
categories: { backups: 'off-box' },
}));
expect(readRow(BACKENDS_KEY)).toBeDefined();
expect(await readRow(BACKENDS_KEY)).toBeDefined();
});
it('STORADM-016 preview() refusals surface verbatim and persist nothing', async () => {
const { service, uploadsRoot, registry } = await makeService();
const before = readRow(CATEGORIES_KEY);
const before = await readRow(CATEGORIES_KEY);
await expect(
put(service, configWith(uploadsRoot, { categories: { backups: 'nope' } })),
).rejects.toThrow("category 'backups' maps to unknown backend 'nope'");
expect(readRow(CATEGORIES_KEY)).toBe(before);
expect(await readRow(CATEGORIES_KEY)).toBe(before);
expect(registry.resolve('backups').backendName).toBe('backups-local'); // live state untouched
});
it('STORADM-018 a mask sentinel in a NON-secret field throws and persists nothing', async () => {
const { service, uploadsRoot } = await makeService();
const before = readRow(BACKENDS_KEY);
const before = await readRow(BACKENDS_KEY);
await expect(
put(service, configWith(uploadsRoot, {
backends: [{ name: 'off-box', type: 's3', options: { ...S3_OPTIONS, accessKeyId: MASKED_SETTING_VALUE } }],
categories: {},
})),
).rejects.toThrow("backend 'off-box' field 'accessKeyId' is the mask sentinel — a mask can never become a stored value");
expect(readRow(BACKENDS_KEY)).toBe(before);
expect(await readRow(BACKENDS_KEY)).toBe(before);
});
it('STORADM-017 persists both rows in ONE transaction and reloads once', async () => {
@@ -284,8 +286,8 @@ describe('StorageAdminService.applyConfig', () => {
it('STORADM-031 a stale submitted version throws StorageConflictError and persists nothing (→ 409, audit #7)', async () => {
const { service, uploadsRoot } = await makeService();
await put(service, configWith(uploadsRoot)); // version is now 1
const beforeBackends = readRow(BACKENDS_KEY);
const beforeCategories = readRow(CATEGORIES_KEY);
const beforeBackends = await readRow(BACKENDS_KEY);
const beforeCategories = await readRow(CATEGORIES_KEY);
await expect(
service.applyConfig({ ...configWith(uploadsRoot), version: 0 }), // stale — current is 1
).rejects.toThrow(StorageConflictError);
@@ -297,8 +299,8 @@ describe('StorageAdminService.applyConfig', () => {
expect((err as StorageConflictError).currentVersion).toBe(1);
expect((err as StorageConflictError).submittedVersion).toBe(0);
}
expect(readRow(BACKENDS_KEY)).toBe(beforeBackends);
expect(readRow(CATEGORIES_KEY)).toBe(beforeCategories);
expect(await readRow(BACKENDS_KEY)).toBe(beforeBackends);
expect(await readRow(CATEGORIES_KEY)).toBe(beforeCategories);
expect((await service.state()).version).toBe(1); // unchanged — the conflicting save never wrote
});
@@ -452,7 +454,7 @@ describe('StorageAdminService.testBackend', () => {
// applyConfig encrypted the secret at rest — the snapshot the mirror
// expansion reads holds ciphertext, so this probe exercises the full
// snapshot → decryptBackendSecrets → ephemeral S3Driver chain.
expect(readRow(BACKENDS_KEY)).toContain('enc:v1:');
expect(await readRow(BACKENDS_KEY)).toContain('enc:v1:');
const result = await service.testBackend({
name: 'cand-mirror',
type: 'mirror',
@@ -19,6 +19,7 @@ import { createSnapshotTestDb } from '../../helpers/db-mock';
import { resetTestDb } from '../../helpers/test-db';
import { createTestOrm, type TestOrm } from '../../helpers/test-orm';
import { TrekPhotos } from '../../../src/db/entities/TrekPhotos.entity';
import { deleteRows, insertRow } from '../../helpers/factories/rows';
import { makeStorageFixture } from '../../helpers/storage-fixture';
// Category-addressed since slice 4: originals + thumbs are ('journey', <name>)
@@ -136,13 +137,13 @@ describe('sweepOrphanThumbs (spec fix #2)', () => {
return fp;
};
beforeEach(() => {
thumbsDb.prepare('DELETE FROM trek_photos').run();
beforeEach(async () => {
await deleteRows(t, TrekPhotos);
fs.rmSync(thumbsDir, { recursive: true, force: true });
});
it('THUMB-SWEEP-001: deletes strays, spares thumbs derivable from live journey rows', async () => {
thumbsDb.prepare("INSERT INTO trek_photos (provider, file_path) VALUES ('local', ?)").run('journey/live.jpg');
await insertRow(t, TrekPhotos, { provider: 'local', file_path: 'journey/live.jpg' });
const liveName = journeyThumbName('journey/live.jpg'); // 'thumbs/<hash>.jpg'
const livePath = writeThumb(path.basename(liveName));
const strayPath = writeThumb('deadbeefdeadbeef.jpg');
@@ -155,8 +156,11 @@ describe('sweepOrphanThumbs (spec fix #2)', () => {
});
it('THUMB-SWEEP-002: spares a recorded thumbnail_path even without a matching file_path hash', async () => {
thumbsDb.prepare("INSERT INTO trek_photos (provider, file_path, thumbnail_path) VALUES ('local', ?, ?)")
.run('elsewhere/x.jpg', 'journey/thumbs/recorded00000000.jpg');
await insertRow(t, TrekPhotos, {
provider: 'local',
file_path: 'elsewhere/x.jpg',
thumbnail_path: 'journey/thumbs/recorded00000000.jpg',
});
const recorded = writeThumb('recorded00000000.jpg');
const removed = await svc.sweepOrphanThumbs();
@@ -20,6 +20,7 @@ import { PluginUserSettingsService } from '../../../src/nest/plugins/plugin-user
import { createTestAddonsService } from '../../helpers/test-addons';
import { createSnapshotTestDb } from '../../helpers/db-mock';
import { createTestOrm, type TestOrm } from '../../helpers/test-orm';
import { makePlugin } from '../../helpers/factories/plugins';
import { AuditLog } from '../../../src/db/entities/AuditLog.entity';
import { Users } from '../../../src/db/entities/Users.entity';
import { Plugins } from '../../../src/db/entities/Plugins.entity';
@@ -51,13 +52,17 @@ afterAll(async () => { await t.close(); testDb.close(); });
beforeEach(() => vi.clearAllMocks());
/** installedDepRows() reads the `plugins` table directly — no discovery needed. */
function installEnabledPlugin(id: string): void {
testDb
.prepare(
`INSERT INTO plugins (id, name, status, enabled, version, api_version, permissions, granted_permissions, capabilities, config, dependencies)
VALUES (?, ?, 'inactive', 1, '1.0.0', 1, '[]', '[]', '{}', '{}', '{}')`,
)
.run(id, id);
async function installEnabledPlugin(id: string): Promise<void> {
await makePlugin(t, id, {
status: 'inactive',
enabled: 1,
api_version: 1,
permissions: '[]',
granted_permissions: '[]',
capabilities: '{}',
config: '{}',
dependencies: '{}',
});
}
async function buildRuntime(withOrm: boolean): Promise<PluginRuntimeService> {
@@ -94,7 +99,7 @@ async function buildRuntime(withOrm: boolean): Promise<PluginRuntimeService> {
describe('PluginRuntimeService.onApplicationBootstrap — absent-ORM boot branch (task-6-rereview2.md I-A)', () => {
it('RT-BOOT-NOORM-001: without an ORM, boot logs the skip line and never calls activate', async () => {
installEnabledPlugin('rtboot-noorm');
await installEnabledPlugin('rtboot-noorm');
const rt = await buildRuntime(false);
const activateSpy = vi.spyOn(rt, 'activate');
@@ -113,7 +118,7 @@ describe('PluginRuntimeService.onApplicationBootstrap — absent-ORM boot branch
});
it('RT-BOOT-NOORM-002: WITH an ORM, the same arrangement activates the plugin — proves case 001 is not vacuous', async () => {
installEnabledPlugin('rtboot-withorm');
await installEnabledPlugin('rtboot-withorm');
const rt = await buildRuntime(true);
const activateSpy = vi.spyOn(rt, 'activate').mockResolvedValue(undefined);
@@ -41,7 +41,7 @@ const tripsRegistry = () => {
} as unknown as TripsRepository;
const guards = new PluginGuards(db, {} as never, {} as never, {} as never);
// Plan 3c Task 7: trips.getById (RP1) now reads through
// `EntityManager.getRepository(Trips).findRaw(...)`, not `db.prepare(...)`.
// `EntityManager.getRepository(Trips).findRaw(...)`, not a raw statement on the handle.
const em = {
getRepository: () => ({ findRaw: async () => ({ id: 1, title: 'Japan', feed_token: null }) }),
} as unknown as EntityManager;
@@ -8,16 +8,10 @@ import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vites
vi.mock('../../../src/db/database', async () => {
const { createSnapshotTestDb } = await import('../../helpers/db-mock');
const db = createSnapshotTestDb();
function getPlaceWithTags(placeId: number | string) {
const p = db.prepare('SELECT * FROM places WHERE id = ?').get(placeId);
if (!p) return null;
return { ...(p as object), category: null, tags: [] };
}
return {
db,
closeDb: () => {},
reinitialize: () => {},
getPlaceWithTags,
canAccessTrip: async () => null,
isOwner: async () => false,
};
@@ -30,6 +24,8 @@ vi.mock('../../../src/config', () => ({
import { db as testDb } from '../../../src/db/database';
import { resetTestDb } from '../../helpers/test-db';
import { findRow } from '../../helpers/factories/rows';
import { Places } from '../../../src/db/entities/Places.entity';
import { createUser, createTrip } from '../../helpers/factories';
import { accommodationsOver } from '../../helpers/accommodations-service';
import { isUpdateConflict } from '../../../src/nest/common/conflictResult';
@@ -190,8 +186,8 @@ describe('PlacesService.update — optimistic concurrency', () => {
expect((result.server as { name: string }).name).toBe('Original');
}
// The row must NOT have been overwritten.
const row = testDb.prepare('SELECT name FROM places WHERE id = ?').get(place.id) as { name: string };
expect(row.name).toBe('Original');
const row = await findRow(await sharedTestOrm(testDb), Places, { id: place.id });
expect(row?.name).toBe('Original');
});
it('returns null for a place that does not exist', async () => {
@@ -203,6 +199,7 @@ describe('PlacesService.update — optimistic concurrency', () => {
describe('updateItem (packing) — optimistic concurrency', () => {
it('migration added updated_at and createItem stamps it', async () => {
// test-sql-allow: schema introspection, which no entity or repository maps.
const cols = testDb.prepare("PRAGMA table_info('packing_items')").all() as { name: string }[];
expect(cols.map(c => c.name)).toContain('updated_at');