test(server): seed the service, MCP and route specs through the ORM factories

Service, MCP, integration and e2e suites with up to a dozen raw
statements now seed and read through the factories. A buildApp() suite
uses the app's own ORM, a partial-module e2e or a unit suite a
createTestOrm() or sharedTestOrm() handle on its database. Reads that
used to come back undefined now come back null, and the assertions say
so; where a projected SELECT was compared whole, the same columns are
picked off the row.

Two reads inside the scheduler's delivery callback stay synchronous raw
statements, marked, so they keep seeing the row at the moment of
delivery. Two more database mocks lose a canAccessTrip/isOwner stand-in
the module no longer exports.
This commit is contained in:
Maurice
2026-10-08 15:49:34 +02:00
parent d28e91d056
commit e2ce2ad05c
25 changed files with 727 additions and 555 deletions
+29 -20
View File
@@ -12,6 +12,7 @@ import { createTestOrm, type TestOrm } from '../helpers/test-orm';
import { purgeExpiredIdempotencyKeys } from '../../src/nest/common/idempotency-cleanup';
import { IdempotencyCleanupJob } from '../../src/nest/common/idempotency-cleanup.job';
import { IdempotencyKeys } from '../../src/db/entities/IdempotencyKeys.entity';
import { countRows, deleteRows, findRows, insertRow } from '../helpers/factories/rows';
import type { IdempotencyKeysRepository } from '../../src/db/repositories/IdempotencyKeys.repository';
import type { CronRegistrarService } from '../../src/nest/scheduling/cron-registrar.service';
@@ -29,46 +30,55 @@ beforeAll(async () => {
});
afterAll(async () => { await t.close(); testDb.close(); });
function insertKey(key: string, ageSeconds: number, nowSec = NOW_SEC): void {
testDb.prepare(
`INSERT INTO idempotency_keys (key, user_id, method, path, status_code, response_body, created_at)
VALUES (?, 1, 'POST', '/x', 200, '{}', ?)`,
).run(key, nowSec - ageSeconds);
async function insertKey(key: string, ageSeconds: number, nowSec = NOW_SEC): Promise<void> {
await insertRow(t, IdempotencyKeys, {
key,
user: 1,
method: 'POST',
path: '/x',
status_code: 200,
response_body: '{}',
created_at: nowSec - ageSeconds,
});
}
beforeEach(() => {
/** The keys left in the table. */
async function remainingKeys(): Promise<string[]> {
return (await findRows(t, IdempotencyKeys)).map((r) => r.key);
}
beforeEach(async () => {
testDb.pragma('foreign_keys = OFF'); // fixtures reference a user we don't seed here
testDb.prepare('DELETE FROM idempotency_keys').run();
await deleteRows(t, IdempotencyKeys);
t.clear();
});
afterEach(() => {
testDb.prepare('DELETE FROM idempotency_keys').run();
afterEach(async () => {
await deleteRows(t, IdempotencyKeys);
testDb.pragma('foreign_keys = ON');
delete process.env.IDEMPOTENCY_TTL_SECONDS;
});
describe('purgeExpiredIdempotencyKeys', () => {
it('removes keys older than the 30-day default, keeps recent ones', async () => {
insertKey('old', 31 * DAY);
insertKey('fresh', 5 * DAY);
await insertKey('old', 31 * DAY);
await insertKey('fresh', 5 * DAY);
const removed = await purgeExpiredIdempotencyKeys(NOW, undefined, idempotencyKeys);
expect(removed).toBe(1);
const keys = testDb.prepare('SELECT key FROM idempotency_keys').all().map((r: { key: string }) => r.key);
expect(keys).toEqual(['fresh']);
expect(await remainingKeys()).toEqual(['fresh']);
});
it('keeps a 25-day-old key that the old 24h TTL would have dropped', async () => {
insertKey('offline-trip', 25 * DAY);
await insertKey('offline-trip', 25 * DAY);
expect(await purgeExpiredIdempotencyKeys(NOW, undefined, idempotencyKeys)).toBe(0);
expect(testDb.prepare('SELECT COUNT(*) c FROM idempotency_keys').get()).toMatchObject({ c: 1 });
expect(await countRows(t, IdempotencyKeys)).toBe(1);
});
it('respects the IDEMPOTENCY_TTL_SECONDS override', async () => {
process.env.IDEMPOTENCY_TTL_SECONDS = String(DAY);
insertKey('twoDays', 2 * DAY);
await insertKey('twoDays', 2 * DAY);
expect(await purgeExpiredIdempotencyKeys(NOW, undefined, idempotencyKeys)).toBe(1);
});
});
@@ -106,13 +116,12 @@ describe('IdempotencyCleanupJob', () => {
// The tick uses the live clock, so these fixtures age against Date.now()
// (the pure-function cases above pin their own fixed NOW instead).
const liveNowSec = Math.floor(Date.now() / 1000);
insertKey('old', 31 * DAY, liveNowSec);
insertKey('fresh', 5 * DAY, liveNowSec);
await insertKey('old', 31 * DAY, liveNowSec);
await insertKey('fresh', 5 * DAY, liveNowSec);
const { job } = makeJob();
await job.tick();
const keys = testDb.prepare('SELECT key FROM idempotency_keys').all().map((r: { key: string }) => r.key);
expect(keys).toEqual(['fresh']);
expect(await remainingKeys()).toEqual(['fresh']);
});
it('a failing purge is contained to the Idempotency cleanup log line', async () => {
@@ -22,6 +22,10 @@ vi.mock('../../../src/websocket', () => ({ broadcast: broadcastMock }));
import { resetTestDb } from '../../helpers/test-db';
import { createUser, createTrip, createDay, createPlace, createDayAssignment } from '../../helpers/factories';
import { createMcpHarness, parseToolResult, type McpHarness } from '../../helpers/mcp-harness';
import { createTestOrm, type TestOrm } from '../../helpers/test-orm';
import { findRow, updateRows } from '../../helpers/factories/rows';
import { DayAssignments } from '../../../src/db/entities/DayAssignments.entity';
import { Days } from '../../../src/db/entities/Days.entity';
beforeEach(() => {
resetTestDb(testDb);
@@ -29,7 +33,14 @@ beforeEach(() => {
delete process.env.DEMO_MODE;
});
afterAll(() => {
let orm: TestOrm;
beforeAll(async () => {
orm = await createTestOrm(testDb);
});
afterAll(async () => {
await orm.close();
testDb.close();
});
@@ -52,7 +63,7 @@ describe('Tool: set_assignment_route_excluded (#2532)', () => {
await withHarness(user.id, async (h) => {
const out = parseToolResult(await h.client.callTool({ name: 'set_assignment_route_excluded', arguments: { tripId: trip.id, assignmentId: assignment.id, excluded: true } })) as any;
expect(out.assignment.route_excluded).toBe(true);
expect(testDb.prepare('SELECT route_excluded FROM day_assignments WHERE id = ?').get(assignment.id)).toEqual({ route_excluded: 1 });
expect((await findRow(orm, DayAssignments, { id: assignment.id }))?.route_excluded).toBe(1);
const back = parseToolResult(await h.client.callTool({ name: 'set_assignment_route_excluded', arguments: { tripId: trip.id, assignmentId: assignment.id, excluded: false } })) as any;
expect(back.assignment.route_excluded).toBe(false);
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'assignment:updated', expect.any(Object));
@@ -82,7 +93,7 @@ describe('Tool: set_leg_transport_mode', () => {
const assignment = createDayAssignment(testDb, day.id, place.id);
// Seed the incoming column so the assertion below proves it survives; the factory
// leaves it NULL, so without this it would pass whether or not the tool touched it.
testDb.prepare('UPDATE day_assignments SET incoming_leg_transport_mode = ? WHERE id = ?').run('walking', assignment.id);
await updateRows(orm, DayAssignments, { id: assignment.id }, { incoming_leg_transport_mode: 'walking' });
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
@@ -103,7 +114,7 @@ describe('Tool: set_leg_transport_mode', () => {
const place = createPlace(testDb, trip.id);
const assignment = createDayAssignment(testDb, day.id, place.id);
// Seed the outgoing column so we prove incoming does NOT touch it (panel item 4).
testDb.prepare('UPDATE day_assignments SET leg_transport_mode = ? WHERE id = ?').run('driving', assignment.id);
await updateRows(orm, DayAssignments, { id: assignment.id }, { leg_transport_mode: 'driving' });
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
@@ -139,7 +150,7 @@ describe('Tool: set_leg_transport_mode', () => {
const day = createDay(testDb, trip.id);
const place = createPlace(testDb, trip.id);
const assignment = createDayAssignment(testDb, day.id, place.id);
testDb.prepare('UPDATE day_assignments SET leg_transport_mode = ? WHERE id = ?').run('cycling', assignment.id);
await updateRows(orm, DayAssignments, { id: assignment.id }, { leg_transport_mode: 'cycling' });
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
@@ -221,7 +232,7 @@ describe('Tool: set_day_default_transport_mode', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const day = createDay(testDb, trip.id);
testDb.prepare('UPDATE days SET default_transport_mode = ? WHERE id = ?').run('driving', day.id);
await updateRows(orm, Days, { id: day.id }, { default_transport_mode: 'driving' });
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
+21 -9
View File
@@ -29,6 +29,12 @@ import { resetTestDb } from '../../helpers/test-db';
import { createUser, createTrip, createTodoItem } from '../../helpers/factories';
import { createMcpHarness, parseToolResult, parseResourceResult, type McpHarness } from '../../helpers/mcp-harness';
import { ADDON_IDS } from '../../../src/addons';
import { createTestOrm, type TestOrm } from '../../helpers/test-orm';
import { findRow, insertRow } from '../../helpers/factories/rows';
import { makeTodoItem } from '../../helpers/factories/todos';
import { setAddonEnabled } from '../../helpers/factories/settings';
import { TodoItems } from '../../../src/db/entities/TodoItems.entity';
import { TodoCategoryAssignees } from '../../../src/db/entities/TodoCategoryAssignees.entity';
beforeEach(() => {
resetTestDb(testDb);
@@ -36,7 +42,14 @@ beforeEach(() => {
delete process.env.DEMO_MODE;
});
afterAll(() => {
let orm: TestOrm;
beforeAll(async () => {
orm = await createTestOrm(testDb);
});
afterAll(async () => {
await orm.close();
testDb.close();
});
@@ -180,8 +193,7 @@ describe('Tool: update_todo', () => {
it('clears due_date when passed null', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
testDb.prepare("INSERT INTO todo_items (trip_id, name, checked, sort_order, due_date) VALUES (?, 'Task', 0, 0, '2025-01-01')").run(trip.id);
const item = testDb.prepare('SELECT * FROM todo_items WHERE trip_id = ? ORDER BY id DESC LIMIT 1').get(trip.id) as any;
const item = await makeTodoItem(orm, trip.id, { name: 'Task', checked: 0, sort_order: 0, due_date: '2025-01-01' });
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'update_todo',
@@ -289,7 +301,7 @@ describe('Tool: delete_todo', () => {
const result = await h.client.callTool({ name: 'delete_todo', arguments: { tripId: trip.id, itemId: item.id } });
const data = parseToolResult(result) as any;
expect(data.success).toBe(true);
expect(testDb.prepare('SELECT id FROM todo_items WHERE id = ?').get(item.id)).toBeUndefined();
expect(await findRow(orm, TodoItems, { id: item.id })).toBeNull();
});
});
@@ -342,8 +354,8 @@ describe('Tool: reorder_todos', () => {
const data = parseToolResult(result) as any;
expect(data.success).toBe(true);
// item2 should now have sort_order 0
const updated = testDb.prepare('SELECT sort_order FROM todo_items WHERE id = ?').get(item2.id) as any;
expect(updated.sort_order).toBe(0);
const updated = await findRow(orm, TodoItems, { id: item2.id });
expect(updated?.sort_order).toBe(0);
});
});
@@ -397,7 +409,7 @@ describe('Tool: set_todo_category_assignees', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
// Set then clear
testDb.prepare('INSERT INTO todo_category_assignees (trip_id, category_name, user_id) VALUES (?, ?, ?)').run(trip.id, 'Booking', user.id);
await insertRow(orm, TodoCategoryAssignees, { trip: trip.id, category_name: 'Booking', user: user.id });
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'set_todo_category_assignees',
@@ -468,7 +480,7 @@ describe('Todo tools — packing addon gating', () => {
it('registers nothing (tools or resource) when the packing addon is disabled', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
testDb.prepare('UPDATE addons SET enabled = 0 WHERE id = ?').run(ADDON_IDS.PACKING);
await setAddonEnabled(orm, ADDON_IDS.PACKING, false);
try {
await withHarness(user.id, async (h) => {
const names = (await h.client.listTools()).tools.map((t) => t.name);
@@ -477,7 +489,7 @@ describe('Todo tools — packing addon gating', () => {
await expect(h.client.readResource({ uri: `trek://trips/${trip.id}/todos` })).rejects.toThrow();
});
} finally {
testDb.prepare('UPDATE addons SET enabled = 1 WHERE id = ?').run(ADDON_IDS.PACKING);
await setAddonEnabled(orm, ADDON_IDS.PACKING, true);
}
});
});
+35 -23
View File
@@ -27,6 +27,7 @@ import { Users } from '../../../src/db/entities/Users.entity';
import type { AuditLogRepository } from '../../../src/db/repositories/AuditLog.repository';
import type { UsersRepository } from '../../../src/db/repositories/Users.repository';
import { AuditService } from '../../../src/nest/audit/audit.service';
import { deleteRows, findRow, insertRow } from '../../helpers/factories/rows';
import { getClientIp } from '../../../src/nest/audit/client-ip';
import { logInfo, logDebug, logError } from '../../../src/nest/audit/audit-log.logger';
@@ -44,10 +45,10 @@ beforeAll(async () => {
svc = new AuditService(auditLogRepo, usersRepo);
});
beforeEach(() => {
beforeEach(async () => {
vi.clearAllMocks();
testDb.prepare('DELETE FROM audit_log').run();
testDb.prepare('DELETE FROM users').run();
await deleteRows(t, AuditLog);
await deleteRows(t, Users);
t.clear();
});
@@ -109,37 +110,48 @@ describe('getClientIp', () => {
// ── writeAudit (real DB, through the repositories) ────────────────────────────
function seedUser(id: number, email: string): void {
testDb.prepare(
"INSERT INTO users (id, username, email, password_hash, role) VALUES (?, ?, ?, 'x', 'user')"
).run(id, `u${id}`, email);
async function seedUser(id: number, email: string): Promise<void> {
await insertRow(t, Users, { id, username: `u${id}`, email, password_hash: 'x', role: 'user' });
}
/** The one audit row the case wrote. */
async function auditRow() {
const row = await findRow(t, AuditLog, {});
if (!row) throw new Error('no audit row was written');
return row;
}
describe('writeAudit', () => {
it('AUDIT-SVC-008: inserts the row and logs the labeled summary line', async () => {
seedUser(1, 'a@b.c');
await seedUser(1, 'a@b.c');
await svc.writeAudit({ userId: 1, action: 'trip.create', resource: 'trip', details: { title: 'Rome' }, ip: '1.2.3.4' });
const row = testDb.prepare('SELECT user_id, action, resource, details, ip FROM audit_log').get();
expect(row).toEqual({ user_id: 1, action: 'trip.create', resource: 'trip', details: '{"title":"Rome"}', ip: '1.2.3.4' });
const row = await auditRow();
expect({ user_id: row.user_id, action: row.action, resource: row.resource, details: row.details, ip: row.ip }).toEqual({
user_id: 1,
action: 'trip.create',
resource: 'trip',
details: '{"title":"Rome"}',
ip: '1.2.3.4',
});
expect(logInfo).toHaveBeenCalledWith('a@b.c created trip "Rome" ip=1.2.3.4');
});
it('AUDIT-SVC-009: empty details object stores NULL details and skips the debug fallback', async () => {
seedUser(1, 'a@b.c');
await seedUser(1, 'a@b.c');
await svc.writeAudit({ userId: 1, action: 'user.login', details: {}, ip: '1.2.3.4' });
const row = testDb.prepare('SELECT details FROM audit_log').get() as { details: string | null };
const row = await auditRow();
expect(row.details).toBeNull();
expect(logDebug).not.toHaveBeenCalled();
});
it('AUDIT-SVC-010: unknown action keeps the raw key; empty-email/zero/null userIds resolve', async () => {
seedUser(1, 'a@b.c');
await seedUser(1, 'a@b.c');
await svc.writeAudit({ userId: 1, action: 'custom.thing', ip: '9.9.9.9' });
expect(logInfo).toHaveBeenLastCalledWith('a@b.c custom.thing ip=9.9.9.9');
seedUser(42, ''); // falsy email → the `row?.email || uid:N` fallback
await seedUser(42, ''); // falsy email → the `row?.email || uid:N` fallback
await svc.writeAudit({ userId: 42, action: 'user.login', ip: '9.9.9.9' });
expect(logInfo).toHaveBeenLastCalledWith('uid:42 logged in ip=9.9.9.9');
seedUser(0, 'zero@b.c'); // since the quirk fix, a real id 0 resolves via the DB
await seedUser(0, 'zero@b.c'); // since the quirk fix, a real id 0 resolves via the DB
await svc.writeAudit({ userId: 0, action: 'user.login', ip: '9.9.9.9' });
expect(logInfo).toHaveBeenLastCalledWith('zero@b.c logged in ip=9.9.9.9');
await svc.writeAudit({ userId: null, action: 'user.login', ip: '9.9.9.9' });
@@ -147,7 +159,7 @@ describe('writeAudit', () => {
});
it('AUDIT-SVC-019: a trip title with a line break cannot forge a second log line', async () => {
seedUser(1, 'a@b.c');
await seedUser(1, 'a@b.c');
await svc.writeAudit({
userId: 1,
action: 'trip.create',
@@ -160,15 +172,15 @@ describe('writeAudit', () => {
});
it('AUDIT-SVC-011: omitted resource/ip store NULL and the log line ends ip=-', async () => {
seedUser(1, 'a@b.c');
await seedUser(1, 'a@b.c');
await svc.writeAudit({ userId: 1, action: 'user.login' });
const row = testDb.prepare('SELECT resource, ip FROM audit_log').get();
expect(row).toEqual({ resource: null, ip: null });
const row = await auditRow();
expect({ resource: row.resource, ip: row.ip }).toEqual({ resource: null, ip: null });
expect(logInfo).toHaveBeenCalledWith('a@b.c logged in ip=-');
});
it('AUDIT-SVC-012: debugDetails wins the debug line; detailsJson is the fallback', async () => {
seedUser(1, 'a@b.c');
await seedUser(1, 'a@b.c');
await svc.writeAudit({ userId: 1, action: 'settings.app_update', details: { require_mfa: true }, debugDetails: { raw: 1 } });
expect(logDebug).toHaveBeenLastCalledWith('AUDIT settings.app_update userId=1 {"raw":1}');
await svc.writeAudit({ userId: 1, action: 'settings.app_update', details: { require_mfa: true } });
@@ -191,7 +203,7 @@ describe('writeAudit', () => {
});
it('AUDIT-SVC-021: a MikroORM ValidationError from the email lookup still resolves to uid:<id>, but is logged with a distinct message', async () => {
seedUser(1, 'a@b.c');
await seedUser(1, 'a@b.c');
const spy = vi.spyOn(usersRepo, 'getEmail').mockRejectedValueOnce(ValidationError.cannotUseGlobalContext());
await svc.writeAudit({ userId: 1, action: 'user.login' });
expect(logInfo).toHaveBeenCalledWith(expect.stringContaining('uid:1 logged in'));
@@ -200,7 +212,7 @@ describe('writeAudit', () => {
});
it('AUDIT-SVC-018: settings.api_keys_update names the changed keys and nothing else (#1939)', async () => {
seedUser(1, 'admin@b.c');
await seedUser(1, 'admin@b.c');
await svc.writeAudit({
userId: 1,
action: 'settings.api_keys_update',
@@ -218,7 +230,7 @@ describe('writeAudit', () => {
});
it('AUDIT-SVC-014: buildInfoSummary variants (settings parts, login empty brief)', async () => {
seedUser(1, 'a@b.c');
await seedUser(1, 'a@b.c');
await svc.writeAudit({ userId: 1, action: 'settings.app_update', details: { notification_channel: 'smtp', require_mfa: false }, ip: '1.1.1.1' });
expect(logInfo).toHaveBeenLastCalledWith('a@b.c updated settings (channel=smtp, mfa=false) ip=1.1.1.1');
await svc.writeAudit({ userId: 1, action: 'user.login', details: { anything: true }, ip: '1.1.1.1' });
@@ -40,7 +40,11 @@ import type { FilesService } from '../../../../src/nest/files/files.service';
import type { StorageService } from '../../../../src/nest/storage/storage.service';
import type { RealtimeService } from '../../../../src/nest/realtime/realtime.service';
import { createTrip, createUser } from '../../../helpers/factories';
import { createTestUnitOfWork, createTestAppSettingsRepo, createTestTripsRepo } from '../../../helpers/test-uow';
import { createTestUnitOfWork, createTestAppSettingsRepo, createTestTripsRepo, sharedTestOrm } from '../../../helpers/test-uow';
import { findRow, insertRow, updateRows } from '../../../helpers/factories/rows';
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 {
createTestDocumentConnectionsRepo,
createTestDocumentProviderFieldsRepo,
@@ -443,9 +447,9 @@ describe('DocSyncJob and a provider switched off in the admin panel', () => {
/** A job of its own per pass, so the interval never decides whether a tick runs. */
const tick = () => new DocSyncJob(appSettingsRepo, service, config, addons, registrar).tick();
const switchProvider = (id: string, on: boolean) =>
testDb.prepare('UPDATE document_providers SET enabled = ? WHERE id = ?').run(on ? 1 : 0, id);
const linkRow = (id: number) => testDb.prepare('SELECT * FROM trip_document_links WHERE id = ?').get(id);
const switchProvider = async (id: string, on: boolean) =>
updateRows(await sharedTestOrm(testDb), DocumentProviders, { id }, { enabled: on ? 1 : 0 });
const linkRow = async (id: number) => findRow(await sharedTestOrm(testDb), TripDocumentLinks, { id });
beforeAll(async () => {
appSettingsRepo = await createTestAppSettingsRepo(testDb);
@@ -474,53 +478,60 @@ describe('DocSyncJob and a provider switched off in the admin panel', () => {
);
const ownerId = createUser(testDb, { username: 'owner', email: 'owner@docsync-job.test' }).user.id;
const tripId = createTrip(testDb, ownerId, { title: 'Japan' }).id;
const bind = (providerId: string) => {
const conn = testDb
.prepare(
`INSERT INTO document_connections (trip_id, provider_id, owner_user_id, base_url, secrets, settings)
VALUES (?, ?, ?, 'https://docs.example.com', NULL, '{}')`,
)
.run(tripId, providerId, ownerId);
return Number(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, created_by)
VALUES (?, ?, ?, 'tag:1', 'Japan', 'both', 'unlink', 'manual', 1, ?)`,
)
.run(tripId, conn.lastInsertRowid, providerId, ownerId).lastInsertRowid);
const orm = await sharedTestOrm(testDb);
const bind = async (providerId: string) => {
const connectionId = await insertRow(orm, DocumentConnections, {
trip: tripId,
provider: providerId,
ownerUser: ownerId,
base_url: 'https://docs.example.com',
secrets: null,
settings: '{}',
});
return insertRow(orm, TripDocumentLinks, {
trip: tripId,
connection: connectionId,
provider_id: providerId,
remote_scope_key: 'tag:1',
remote_label: 'Japan',
direction: 'both',
delete_policy: 'unlink',
conflict_policy: 'manual',
sync_enabled: 1,
createdByRef: ownerId,
});
};
paperlessLink = bind('paperless');
nextcloudLink = bind('nextcloud');
paperlessLink = await bind('paperless');
nextcloudLink = await bind('nextcloud');
});
afterAll(() => testDb.close());
beforeEach(() => {
switchProvider('paperless', false);
switchProvider('nextcloud', true);
beforeEach(async () => {
await switchProvider('paperless', false);
await switchProvider('nextcloud', true);
});
it('runs the bindings that may run and leaves the switched-off one exactly as it was', async () => {
const before = linkRow(paperlessLink);
const before = await linkRow(paperlessLink);
await tick();
expect(nextcloud.list).toHaveBeenCalledTimes(1);
expect(paperless.resolveScope).not.toHaveBeenCalled();
expect(paperless.list).not.toHaveBeenCalled();
expect(linkRow(paperlessLink)).toEqual(before);
expect(linkRow(nextcloudLink)).toMatchObject({ last_sync_state: 'ok' });
expect(await linkRow(paperlessLink)).toEqual(before);
expect(await linkRow(nextcloudLink)).toMatchObject({ last_sync_state: 'ok' });
});
it('picks the binding up again on the next tick once the provider is back on', async () => {
await tick();
expect(paperless.list).not.toHaveBeenCalled();
switchProvider('paperless', true);
await switchProvider('paperless', true);
await tick();
expect(paperless.list).toHaveBeenCalledTimes(1);
expect(linkRow(paperlessLink)).toMatchObject({ last_sync_state: 'ok', failure_count: 0 });
expect(await linkRow(paperlessLink)).toMatchObject({ last_sync_state: 'ok', failure_count: 0 });
});
});
+57 -40
View File
@@ -48,6 +48,10 @@ import fs from 'node:fs';
import path from 'node:path';
import { makeStorageFixture } from '../../helpers/storage-fixture';
import { createTestUnitOfWork, createTestUsersRepo } from '../../helpers/test-uow';
import { createTestUsersRepo, sharedTestOrm } from '../../helpers/test-uow';
import { deleteRows, upsertRow } from '../../helpers/factories/rows';
import { readUser } from '../../helpers/factories/users';
import { Users } from '../../../src/db/entities/Users.entity';
import { PROVIDER_SELECT_ALL_MAX_PAGES } from '@trek/shared';
const audit = { writeAudit: vi.fn() };
@@ -57,9 +61,23 @@ let svc: ImmichService;
const USER = 1;
function seedUser(id: number, url: string | null, key: string | null, autoUpload = 0, allowInsecureTls = 0): void {
testDb.prepare("INSERT OR REPLACE INTO users (id, username, email, password_hash, immich_url, immich_api_key, immich_auto_upload, immich_allow_insecure_tls) VALUES (?, ?, ?, 'x', ?, ?, ?, ?)")
.run(id, `u${id}`, `u${id}@example.test`, url, key, autoUpload, allowInsecureTls);
async function seedUser(id: number, url: string | null, key: string | null, autoUpload = 0, allowInsecureTls = 0): Promise<void> {
await upsertRow(await sharedTestOrm(testDb), Users, {
id,
username: `u${id}`,
email: `u${id}@example.test`,
password_hash: 'x',
immich_url: url,
immich_api_key: key,
immich_auto_upload: autoUpload,
immich_allow_insecure_tls: allowInsecureTls,
});
}
/** The user's stored Immich URL and TLS switch, as the settings page round-trips them. */
async function storedConnection(id: number): Promise<{ immich_url: string | null | undefined; immich_allow_insecure_tls: number }> {
const row = await readUser(await sharedTestOrm(testDb), id);
return { immich_url: row.immich_url, immich_allow_insecure_tls: row.immich_allow_insecure_tls };
}
/** A Response-ish object with only what the service reads. */
@@ -79,13 +97,13 @@ beforeAll(async () => {
svc = new ImmichService(audit as unknown as AuditService, access as unknown as MemoriesAccessService, journeyFx.storage, users, await createTestUnitOfWork(testDb));
});
beforeEach(() => {
beforeEach(async () => {
vi.clearAllMocks();
decryptMock.mockImplementation((v: string) => v);
maybeEncryptMock.mockImplementation((v: string) => v);
checkSsrf.mockResolvedValue({ allowed: true, isPrivate: false, resolvedIp: '1.2.3.4' });
testDb.prepare('DELETE FROM users').run();
seedUser(USER, 'https://immich.test', 'key-1');
await deleteRows(await sharedTestOrm(testDb), Users);
await seedUser(USER, 'https://immich.test', 'key-1');
});
afterAll(() => testDb.close());
@@ -96,12 +114,12 @@ describe('getImmichCredentials', () => {
});
it('IMMICH-002: returns null without a URL', async () => {
seedUser(2, null, 'key');
await seedUser(2, null, 'key');
expect(await svc.getImmichCredentials(2)).toBeNull();
});
it('IMMICH-003: returns null without an API key', async () => {
seedUser(3, 'https://immich.test', null);
await seedUser(3, 'https://immich.test', null);
expect(await svc.getImmichCredentials(3)).toBeNull();
});
@@ -129,7 +147,7 @@ describe('getConnectionSettings / setImmichAutoUpload', () => {
});
it('IMMICH-008: reports an empty URL and not connected when it is not', async () => {
seedUser(4, null, null);
await seedUser(4, null, null);
expect(await svc.getConnectionSettings(4)).toEqual({ immich_url: '', connected: false, auto_upload: false, allow_insecure_tls: false });
});
@@ -220,9 +238,9 @@ describe('saveImmichSettings', () => {
const plaintext = 'synthetic-test-immich-key-001';
await svc.saveImmichSettings(USER, 'https://immich.test', plaintext, null);
const row = testDb.prepare('SELECT immich_api_key FROM users WHERE id = ?').get(USER) as { immich_api_key: string };
const row = await readUser(await sharedTestOrm(testDb), USER);
expect(row.immich_api_key).not.toBe(plaintext);
expect(row.immich_api_key.startsWith('enc:v1:')).toBe(true);
expect(row.immich_api_key?.startsWith('enc:v1:')).toBe(true);
});
});
@@ -275,7 +293,7 @@ describe('testConnection', () => {
describe('getConnectionStatus', () => {
it('IMMICH-022: says "Not configured" without credentials', async () => {
seedUser(5, null, null);
await seedUser(5, null, null);
expect(await svc.getConnectionStatus(5)).toEqual({ connected: false, error: 'Not configured' });
});
@@ -293,7 +311,7 @@ describe('getConnectionStatus', () => {
describe('browseTimeline', () => {
it('IMMICH-024: 400s without credentials', async () => {
seedUser(6, null, null);
await seedUser(6, null, null);
expect(await svc.browseTimeline(6)).toEqual({ error: 'Immich not configured', status: 400 });
});
@@ -331,7 +349,7 @@ const DAY_TAIL = [
describe('searchPhotos', () => {
it('IMMICH-027: 400s without credentials and 502s when the server is unreachable', async () => {
seedUser(7, null, null);
await seedUser(7, null, null);
expect(await svc.searchPhotos(7)).toEqual({ error: 'Immich not configured', status: 400 });
safeFetch.mockRejectedValue(new Error('down'));
@@ -622,12 +640,12 @@ describe('searchPhotos', () => {
describe('getAssetInfo', () => {
it('IMMICH-033: 404s when the owner has no credentials', async () => {
seedUser(8, null, null);
await seedUser(8, null, null);
expect(await svc.getAssetInfo(USER, 'a1', 8)).toEqual({ error: 'Not found', status: 404 });
});
it('IMMICH-034: reads the OWNER\'s credentials, not the requester\'s', async () => {
seedUser(9, 'https://owner.test', 'owner-key');
await seedUser(9, 'https://owner.test', 'owner-key');
safeFetch.mockResolvedValue(upstream({ json: { id: 'a1' } }));
await svc.getAssetInfo(USER, 'a1', 9);
@@ -677,7 +695,7 @@ describe('getAssetInfo', () => {
describe('getAlbumPhotos', () => {
it('IMMICH-037c: 400s without credentials', async () => {
seedUser(11, null, null);
await seedUser(11, null, null);
expect(await svc.getAlbumPhotos(11, 'alb-1')).toEqual({ error: 'Immich not configured', status: 400 });
});
@@ -707,7 +725,7 @@ describe('getAlbumPhotos', () => {
describe('fetchImmichThumbnailBytes', () => {
it('IMMICH-038: 404s without credentials for the owner', async () => {
seedUser(10, null, null);
await seedUser(10, null, null);
expect(await svc.fetchImmichThumbnailBytes(USER, 'a1', 10)).toEqual({ error: 'Not found', status: 404 });
});
@@ -741,7 +759,7 @@ describe('streamImmichAsset', () => {
// It used to return { error, status } and touch nothing. All four callers
// ignored that return, so the client got no status line at all and waited
// out its own timeout. Synology's counterpart always wrote; this matches.
seedUser(11, null, null);
await seedUser(11, null, null);
const res = makeRes();
await svc.streamImmichAsset(res as never, USER, 'a1', 'thumbnail', 11);
@@ -755,8 +773,8 @@ describe('streamImmichAsset', () => {
// A shared album is proxied with the owner's key on the viewer's behalf, so
// a viewer who happens to have Immich configured must not paper over an
// owner who does not.
seedUser(USER, 'https://immich.example', 'viewer-key');
seedUser(12, null, null);
await seedUser(USER, 'https://immich.example', 'viewer-key');
await seedUser(12, null, null);
const res = makeRes();
await svc.streamImmichAsset(res as never, USER, 'a1', 'original', 12);
@@ -766,7 +784,7 @@ describe('streamImmichAsset', () => {
});
it('IMMICH-041d: proxies with the owner key once a connection exists', async () => {
seedUser(13, 'https://immich.example', 'owner-key');
await seedUser(13, 'https://immich.example', 'owner-key');
safeFetch.mockResolvedValue(upstream({ contentType: 'image/jpeg', body: 'bytes' }));
const res = makeRes();
@@ -787,7 +805,7 @@ describe('collectAlbumSelection', () => {
it('IMMICH-043: 400s when the user has no Immich credentials', async () => {
access.getAlbumIdFromLink.mockReturnValue({ success: true, data: 'album-1' });
seedUser(11, null, null);
await seedUser(11, null, null);
expect(await svc.collectAlbumSelection('1', 'l1', 11)).toEqual({ error: 'Immich not configured', status: 400 });
});
@@ -827,19 +845,19 @@ describe('uploadToImmich', () => {
});
it('IMMICH-UP-002: returns null for a path outside the journey category', async () => {
seedUser(USER, 'https://immich.test', 'key-1');
await seedUser(USER, 'https://immich.test', 'key-1');
expect(await svc.uploadToImmich(USER, 'files/doc.pdf', 'doc.pdf')).toBeNull();
expect(safeFetch).not.toHaveBeenCalled();
});
it('IMMICH-UP-003: returns null when the object is missing (old existsSync guard)', async () => {
seedUser(USER, 'https://immich.test', 'key-1');
await seedUser(USER, 'https://immich.test', 'key-1');
expect(await svc.uploadToImmich(USER, 'journey/gone.jpg', 'gone.jpg')).toBeNull();
expect(safeFetch).not.toHaveBeenCalled();
});
it('IMMICH-UP-004: posts the bytes as multipart and answers the created asset id', async () => {
seedUser(USER, 'https://immich.test', 'key-1');
await seedUser(USER, 'https://immich.test', 'key-1');
writeJourneyObject('up.jpg', 'jpeg-bytes-here');
safeFetch.mockResolvedValueOnce({ ok: true, json: async () => ({ id: 'immich-42' }) });
@@ -853,7 +871,7 @@ describe('uploadToImmich', () => {
});
it('IMMICH-UP-005: a rejected upstream answers null, not a throw', async () => {
seedUser(USER, 'https://immich.test', 'key-1');
await seedUser(USER, 'https://immich.test', 'key-1');
writeJourneyObject('rej.jpg', 'bytes');
safeFetch.mockResolvedValueOnce({ ok: false, status: 500 });
expect(await svc.uploadToImmich(USER, 'journey/rej.jpg', 'rej.jpg')).toBeNull();
@@ -910,19 +928,19 @@ describe('self-signed certificates', () => {
const EVERY_PATH_CALLS = 13;
it('IMMICH-TLS-001: only a stored 1 turns the switch on', async () => {
seedUser(USER, 'https://immich.test', 'key-1', 0, 1);
await seedUser(USER, 'https://immich.test', 'key-1', 0, 1);
expect((await svc.getImmichCredentials(USER))!.allow_insecure_tls).toBe(true);
seedUser(USER, 'https://immich.test', 'key-1', 0, 0);
await seedUser(USER, 'https://immich.test', 'key-1', 0, 0);
expect((await svc.getImmichCredentials(USER))!.allow_insecure_tls).toBe(false);
// Anything the column should never hold reads as off, not as truthy.
seedUser(USER, 'https://immich.test', 'key-1', 0, 2);
await seedUser(USER, 'https://immich.test', 'key-1', 0, 2);
expect((await svc.getImmichCredentials(USER))!.allow_insecure_tls).toBe(false);
});
it('IMMICH-TLS-002: with the switch on, every request to the server skips the certificate check', async () => {
seedUser(USER, 'https://immich.test', 'key-1', 0, 1);
await seedUser(USER, 'https://immich.test', 'key-1', 0, 1);
await exerciseEveryPath(USER);
@@ -940,8 +958,8 @@ describe('self-signed certificates', () => {
it('IMMICH-TLS-004: a shared photo follows the switch of its owner, not of the viewer', async () => {
// The request goes to the owner's server with the owner's key, so the
// owner decided whether that server's certificate is trusted.
seedUser(USER, 'https://viewer.test', 'viewer-key', 0, 0);
seedUser(20, 'https://owner.test', 'owner-key', 0, 1);
await seedUser(USER, 'https://viewer.test', 'viewer-key', 0, 0);
await seedUser(20, 'https://owner.test', 'owner-key', 0, 1);
safeFetch.mockResolvedValue(upstream({ json: { id: 'a1' } }));
await svc.getAssetInfo(USER, 'a1', 20);
@@ -955,8 +973,8 @@ describe('self-signed certificates', () => {
}
safeFetch.mockClear();
seedUser(USER, 'https://viewer.test', 'viewer-key', 0, 1);
seedUser(20, 'https://owner.test', 'owner-key', 0, 0);
await seedUser(USER, 'https://viewer.test', 'viewer-key', 0, 1);
await seedUser(20, 'https://owner.test', 'owner-key', 0, 0);
await svc.getAssetInfo(USER, 'a1', 20);
expect(safeFetch.mock.calls[0][2]).toMatchObject(STRICT);
});
@@ -974,19 +992,18 @@ describe('self-signed certificates', () => {
await svc.saveImmichSettings(USER, 'https://immich.test', 'key-1', null, true);
await svc.saveImmichSettings(USER, undefined, undefined, null, true);
const row = testDb.prepare('SELECT immich_url, immich_allow_insecure_tls FROM users WHERE id = ?').get(USER);
expect(row).toEqual({ immich_url: null, immich_allow_insecure_tls: 0 });
expect(await storedConnection(USER)).toEqual({ immich_url: null, immich_allow_insecure_tls: 0 });
});
it('IMMICH-TLS-010: the switch trusts one server, so a new URL saved without it starts off', async () => {
seedUser(USER, 'https://nas.local', 'key-1', 0, 1);
await seedUser(USER, 'https://nas.local', 'key-1', 0, 1);
// Same server with a trailing slash: still the same connection, the switch stays.
await svc.saveImmichSettings(USER, 'https://nas.local/', 'key-1', null);
expect((await svc.getConnectionSettings(USER)).allow_insecure_tls).toBe(true);
await svc.saveImmichSettings(USER, 'https://photos.example.com', 'key-2', null);
expect(testDb.prepare('SELECT immich_url, immich_allow_insecure_tls FROM users WHERE id = ?').get(USER)).toEqual({
expect(await storedConnection(USER)).toEqual({
immich_url: 'https://photos.example.com', immich_allow_insecure_tls: 0,
});
@@ -996,7 +1013,7 @@ describe('self-signed certificates', () => {
});
it('IMMICH-TLS-006: a URL the guard refuses leaves the stored switch alone', async () => {
seedUser(USER, 'https://immich.test', 'key-1', 0, 1);
await seedUser(USER, 'https://immich.test', 'key-1', 0, 1);
checkSsrf.mockResolvedValue({ allowed: false, error: 'blocked host' });
await svc.saveImmichSettings(USER, 'http://169.254.169.254', 'k', null, false);
@@ -46,6 +46,9 @@ import {
createTestJourneyPhotosRepo, createTestJourneyEntryPhotosRepo,
} from '../../helpers/journey-repos';
import { createTestJourneyBooksRepo } from '../../helpers/journey-share-repos';
import { countRows, deleteRows, findRow, insertRow, updateRows } from '../../helpers/factories/rows';
import { JourneyBooks } from '../../../src/db/entities/JourneyBooks.entity';
import { Journeys } from '../../../src/db/entities/Journeys.entity';
let domain: JourneyDomainService;
let books: JourneyBookService;
@@ -190,11 +193,7 @@ describe('creating and reading', () => {
it('opens a book whose stored JSON is broken, rather than throwing', async () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id);
testDb
.prepare(
"INSERT INTO journey_books (journey_id, title, document, version) VALUES (?, 'T', '{not json', 1)",
)
.run(journey.id);
await insertRow(await sharedTestOrm(testDb), JourneyBooks, { journey: journey.id, title: 'T', document: '{not json', version: 1 });
const read = await books.getBook(journey.id, user.id);
expect(read).not.toBeNull();
@@ -334,14 +333,18 @@ describe('concurrency', () => {
const stale = await books.saveBook(journey.id, user.id, { title: 'T', document: doc('three'), baseVersion: 1 });
expect(stale && 'conflict' in stale).toBe(true);
// The same statement with the `AND version = ?` guard stripped — proving
// The same write with the `AND version = ?` guard stripped — proving
// that guard, not something else, is what makes the refusal above real.
const row = testDb.prepare('SELECT id FROM journey_books WHERE journey_id = ?').get(journey.id) as { id: number };
testDb
.prepare('UPDATE journey_books SET title = ?, document = ?, version = version + 1 WHERE id = ?')
.run('Clobbered', JSON.stringify(doc('clobbered')), row.id);
const afterMutation = testDb.prepare('SELECT title FROM journey_books WHERE id = ?').get(row.id) as { title: string };
expect(afterMutation.title).toBe('Clobbered');
const orm = await sharedTestOrm(testDb);
const row = await findRow(orm, JourneyBooks, { journey: journey.id });
if (!row) throw new Error('the save above should have left a book');
await updateRows(orm, JourneyBooks, { id: row.id }, {
title: 'Clobbered',
document: JSON.stringify(doc('clobbered')),
version: row.version + 1,
});
const afterMutation = await findRow(orm, JourneyBooks, { id: row.id });
expect(afterMutation?.title).toBe('Clobbered');
});
// M1 (task-5-review.md) — JB3's existing-link read and JB4's insert used
@@ -368,10 +371,10 @@ describe('concurrency', () => {
// other either also lands as {record} (version 2, having taken the
// update branch against baseVersion undefined -> existing.version) or
// sees a stale conflict — either way, never a second inserted row.
const rows = testDb.prepare('SELECT COUNT(*) AS n FROM journey_books WHERE journey_id = ?').get(journey.id) as { n: number };
expect(rows.n).toBe(1);
const orm = await sharedTestOrm(testDb);
expect(await countRows(orm, JourneyBooks, { journey: journey.id })).toBe(1);
const finalVersion = (testDb.prepare('SELECT version FROM journey_books WHERE journey_id = ?').get(journey.id) as { version: number }).version;
const finalVersion = (await findRow(orm, JourneyBooks, { journey: journey.id }))?.version;
expect(finalVersion).toBe(2);
});
});
@@ -421,11 +424,9 @@ describe('deleting', () => {
const journey = createJourney(testDb, user.id);
await books.saveBook(journey.id, user.id, { title: 'T', document: doc() });
testDb.prepare('DELETE FROM journeys WHERE id = ?').run(journey.id);
const orm = await sharedTestOrm(testDb);
await deleteRows(orm, Journeys, { id: journey.id });
const left = testDb
.prepare('SELECT COUNT(*) AS n FROM journey_books WHERE journey_id = ?')
.get(journey.id) as { n: number };
expect(left.n).toBe(0);
expect(await countRows(orm, JourneyBooks, { journey: journey.id })).toBe(0);
});
});
@@ -42,6 +42,12 @@ vi.mock('../../../../src/utils/ssrfGuard', () => {
import { db as testDb } from '../../../../src/db/database';
import { resetTestDb } from '../../../helpers/test-db';
import { sharedTestOrm } from '../../../helpers/test-uow';
import { countRows, deleteRows, findRows, insertRow, updateRows } from '../../../helpers/factories/rows';
import { readAppSetting } from '../../../helpers/factories/settings';
import { AppSettings } from '../../../../src/db/entities/AppSettings.entity';
import { Notifications } from '../../../../src/db/entities/Notifications.entity';
import { Settings } from '../../../../src/db/entities/Settings.entity';
import { createAdmin, createUser, disableNotificationPref, setNotificationChannels } from '../../../helpers/factories';
import {
makeNotificationPreferencesService,
@@ -94,15 +100,17 @@ const pushedTo = () => safeFetchFollow.mock.calls.map((c) => c[0] as string);
/** What a restore under a different ENCRYPTION_KEY leaves behind: a private key that no longer opens. */
const UNREADABLE = 'enc:v1:bm90IGEgY2lwaGVydGV4dA';
function breakStoredPrivateKey(): void {
testDb.prepare('UPDATE app_settings SET value = ? WHERE key = ?').run(UNREADABLE, VAPID_PRIVATE_KEY_SETTING);
async function breakStoredPrivateKey(): Promise<void> {
await updateRows(await sharedTestOrm(testDb), AppSettings, { key: VAPID_PRIVATE_KEY_SETTING }, { value: UNREADABLE });
}
const pushColumn = async (userId: number) =>
(await prefs.getPreferencesMatrix(userId, 'user')).channels.find((c) => c.id === 'push');
const storedKeyRows = () =>
testDb.prepare("SELECT key, value FROM app_settings WHERE key LIKE 'web_push_vapid_%' ORDER BY key").all();
const storedKeyRows = async () =>
(await findRows(await sharedTestOrm(testDb), AppSettings, { key: { $like: 'web_push_vapid_%' } }, { key: 'asc' })).map(
(r) => ({ key: r.key, value: r.value }),
);
beforeAll(async () => {
notifications = await makeNotificationsService(testDb);
@@ -216,20 +224,15 @@ describe('Web Push while the stored key pair cannot be used', () => {
const { user } = createUser(testDb);
const { user: actor } = createUser(testDb);
const endpoint = await addDevice(user.id);
testDb
.prepare("INSERT INTO settings (user_id, key, value) VALUES (?, 'webhook_url', ?)")
.run(user.id, 'https://hooks.example.test/trek');
await insertRow(await sharedTestOrm(testDb), Settings, { user: user.id, key: 'webhook_url', value: 'https://hooks.example.test/trek' });
setNotificationChannels(testDb, 'webhook,push');
breakStoredPrivateKey();
await breakStoredPrivateKey();
// In-app and the webhook go out; the skipped push is no failed delivery.
await expect(invite(user.id, actor.id)).resolves.toEqual({ attempted: 2, delivered: 2 });
expect(pushedTo()).toEqual(['https://hooks.example.test/trek']);
const inApp = testDb.prepare('SELECT COUNT(*) AS n FROM notifications WHERE recipient_id = ?').get(user.id) as {
n: number;
};
expect(inApp.n).toBe(1);
expect(await countRows(await sharedTestOrm(testDb), Notifications, { recipient: user.id })).toBe(1);
// The device is kept for when the key is back, and nothing failed on the way.
expect((await subscriptions.listForUser(user.id)).map((r) => r.endpoint)).toEqual([endpoint]);
expect(logError).not.toHaveBeenCalledWith(expect.stringContaining('dispatch failed'));
@@ -240,16 +243,15 @@ describe('Web Push while the stored key pair cannot be used', () => {
const { user } = createUser(testDb);
setNotificationChannels(testDb, 'email,push');
await addDevice(user.id);
const ciphertext = (
testDb.prepare('SELECT value FROM app_settings WHERE key = ?').get(VAPID_PRIVATE_KEY_SETTING) as { value: string }
).value;
breakStoredPrivateKey();
const ciphertext = await readAppSetting(await sharedTestOrm(testDb), VAPID_PRIVATE_KEY_SETTING);
if (ciphertext === null) throw new Error('the private key should be stored by now');
await breakStoredPrivateKey();
expect(await pushColumn(user.id)).toMatchObject({ active: false, configured: true });
// Only push: email keeps its column, SMTP or not, as it always has.
expect((await prefs.getPreferencesMatrix(user.id, 'user')).channels.find((c) => c.id === 'email')?.active).toBe(true);
testDb.prepare('UPDATE app_settings SET value = ? WHERE key = ?').run(ciphertext, VAPID_PRIVATE_KEY_SETTING);
await updateRows(await sharedTestOrm(testDb), AppSettings, { key: VAPID_PRIVATE_KEY_SETTING }, { value: ciphertext });
expect(await pushColumn(user.id)).toMatchObject({ active: true, configured: true });
});
@@ -258,16 +260,16 @@ describe('Web Push while the stored key pair cannot be used', () => {
setNotificationChannels(testDb, 'email,push');
await addDevice(user.id);
// What deleting both rows to start over leaves under a running server.
testDb.prepare("DELETE FROM app_settings WHERE key LIKE 'web_push_vapid_%'").run();
await deleteRows(await sharedTestOrm(testDb), AppSettings, { key: { $like: 'web_push_vapid_%' } });
expect(await pushColumn(user.id)).toMatchObject({ active: true, configured: true });
expect(storedKeyRows()).toEqual([]);
expect(await storedKeyRows()).toEqual([]);
});
it('WPDISP-010: the test send says push is unavailable and sends nothing', async () => {
const { user } = createUser(testDb);
await addDevice(user.id);
breakStoredPrivateKey();
await breakStoredPrivateKey();
await expect(notifications.testChannel(user.id, 'push')).resolves.toEqual({
success: false,
error: PUSH_UNAVAILABLE_ERROR,
@@ -283,7 +285,7 @@ describe('Web Push while the VAPID_* pair is broken and an older pair is stored'
setNotificationChannels(testDb, 'push');
// The first start without VAPID_* stored a pair; the operator then brought their own.
await keys.getPublicKey();
const stored = storedKeyRows();
const stored = await storedKeyRows();
const pair = generateVapidKeyPair();
vi.stubEnv('VAPID_PUBLIC_KEY', pair.publicKey);
vi.stubEnv('VAPID_PRIVATE_KEY', pair.privateKey);
@@ -298,7 +300,7 @@ describe('Web Push while the VAPID_* pair is broken and an older pair is stored'
expect((await subscriptions.listForUser(user.id)).map((r) => [r.endpoint, r.vapid_public_key])).toEqual([
[endpoint, pair.publicKey],
]);
expect(storedKeyRows()).toEqual(stored);
expect(await storedKeyRows()).toEqual(stored);
expect(await pushColumn(user.id)).toMatchObject({ active: false, configured: true });
vi.stubEnv('VAPID_PRIVATE_KEY', pair.privateKey);
@@ -34,6 +34,8 @@ import { TrekPhotos } from '../../../src/db/entities/TrekPhotos.entity';
import { TripPhotos } from '../../../src/db/entities/TripPhotos.entity';
import { JourneyPhotos } from '../../../src/db/entities/JourneyPhotos.entity';
import { createTestOrm, type TestOrm } from '../../helpers/test-orm';
import { deleteRows, findRow, insertRow, insertRowIgnoringConflict } from '../../helpers/factories/rows';
import { Users } from '../../../src/db/entities/Users.entity';
import { PhotoResolverService } from '../../../src/nest/memories/photo-resolver.service';
import type { ImmichService } from '../../../src/nest/memories/immich.service';
import type { SynologyService } from '../../../src/nest/memories/synology.service';
@@ -83,12 +85,17 @@ function makeRes() {
}
/** Insert a trek_photos row directly so each case controls the exact shape. */
function insertPhoto(cols: Record<string, unknown>): number {
const keys = Object.keys(cols);
const res = testDb.prepare(
`INSERT INTO trek_photos (${keys.join(', ')}) VALUES (${keys.map(() => '?').join(', ')})`
).run(...keys.map(k => cols[k] as never));
return Number(res.lastInsertRowid);
async function insertPhoto(cols: {
provider: string;
file_path?: string;
thumbnail_path?: string;
media_type?: string;
asset_id?: string;
owner_id?: number;
passphrase?: string;
}): Promise<number> {
const { owner_id, ...rest } = cols;
return insertRow(t, TrekPhotos, { ...rest, ...(owner_id !== undefined ? { owner: owner_id } : {}) });
}
beforeAll(async () => {
@@ -103,13 +110,12 @@ beforeAll(async () => {
);
});
beforeEach(() => {
beforeEach(async () => {
vi.clearAllMocks();
// owner_id carries a FK to users; the cases pick fixed ids, so seed them.
testDb.prepare('DELETE FROM trek_photos').run();
await deleteRows(t, TrekPhotos);
for (const id of [2, 3, 4, 5, 9]) {
testDb.prepare("INSERT OR IGNORE INTO users (id, username, email, password_hash) VALUES (?, ?, ?, 'x')")
.run(id, `u${id}`, `u${id}@example.test`);
await insertRowIgnoringConflict(t, Users, { id, username: `u${id}`, email: `u${id}@example.test`, password_hash: 'x' });
}
cache.serveFresh.mockReturnValue(false);
cache.getInFlight.mockReturnValue(undefined);
@@ -132,7 +138,7 @@ describe('streamPhoto — dispatch', () => {
});
it('RESOLVE-002: a local row whose file is gone answers "File not found", not a provider call', async () => {
const id = insertPhoto({ provider: 'local', file_path: 'nope/missing.jpg' });
const id = await insertPhoto({ provider: 'local', file_path: 'nope/missing.jpg' });
const res = makeRes();
await svc.streamPhoto(res as never, 1, id, 'original');
@@ -145,7 +151,7 @@ describe('streamPhoto — dispatch', () => {
it('RESOLVE-003: a poster-less video 404s instead of streaming the whole file as a thumbnail', async () => {
// #823: falling through here would push a full video down a thumbnail request.
const id = insertPhoto({ provider: 'local', file_path: 'nope/clip.mp4', media_type: 'video' });
const id = await insertPhoto({ provider: 'local', file_path: 'nope/clip.mp4', media_type: 'video' });
const res = makeRes();
await svc.streamPhoto(res as never, 1, id, 'thumbnail');
@@ -156,7 +162,7 @@ describe('streamPhoto — dispatch', () => {
});
it('RESOLVE-004: a local image with no thumbnail asks for one to be generated', async () => {
const id = insertPhoto({ provider: 'local', file_path: 'nope/photo.jpg' });
const id = await insertPhoto({ provider: 'local', file_path: 'nope/photo.jpg' });
thumbnails.ensureLocalThumbnail.mockResolvedValue(null);
const res = makeRes();
@@ -166,18 +172,22 @@ describe('streamPhoto — dispatch', () => {
});
it('RESOLVE-005: a generated thumbnail is recorded on the row', async () => {
const id = insertPhoto({ provider: 'local', file_path: 'nope/photo.jpg' });
const id = await insertPhoto({ provider: 'local', file_path: 'nope/photo.jpg' });
thumbnails.ensureLocalThumbnail.mockResolvedValue({ thumbnailRelPath: 'journey/thumbs/abc.jpg', width: 800, height: 600 });
const res = makeRes();
await svc.streamPhoto(res as never, 1, id, 'thumbnail');
const row = testDb.prepare('SELECT thumbnail_path, width, height FROM trek_photos WHERE id = ?').get(id) as { thumbnail_path: string; width: number; height: number };
expect(row).toEqual({ thumbnail_path: 'journey/thumbs/abc.jpg', width: 800, height: 600 });
const row = await findRow(t, TrekPhotos, { id });
expect({ thumbnail_path: row?.thumbnail_path, width: row?.width, height: row?.height }).toEqual({
thumbnail_path: 'journey/thumbs/abc.jpg',
width: 800,
height: 600,
});
});
it('RESOLVE-014: a local thumbnail hit streams through storage with the immutable headers', async () => {
const id = insertPhoto({ provider: 'local', file_path: 'journey/x.jpg', thumbnail_path: 'journey/thumbs/h.jpg' });
const id = await insertPhoto({ provider: 'local', file_path: 'journey/x.jpg', thumbnail_path: 'journey/thumbs/h.jpg' });
storage.exists.mockResolvedValue(true);
const res = makeRes();
@@ -190,7 +200,7 @@ describe('streamPhoto — dispatch', () => {
});
it('RESOLVE-015: a local original hit streams through storage with the day-long cache header', async () => {
const id = insertPhoto({ provider: 'local', file_path: 'journey/x.jpg' });
const id = await insertPhoto({ provider: 'local', file_path: 'journey/x.jpg' });
storage.exists.mockResolvedValue(true);
const res = makeRes();
@@ -202,7 +212,7 @@ describe('streamPhoto — dispatch', () => {
});
it('RESOLVE-016: a missing thumbnail falls through to the original for images', async () => {
const id = insertPhoto({ provider: 'local', file_path: 'journey/x.jpg', thumbnail_path: 'journey/thumbs/h.jpg' });
const id = await insertPhoto({ provider: 'local', file_path: 'journey/x.jpg', thumbnail_path: 'journey/thumbs/h.jpg' });
storage.exists.mockResolvedValueOnce(false).mockResolvedValueOnce(true);
const res = makeRes();
@@ -212,7 +222,7 @@ describe('streamPhoto — dispatch', () => {
});
it('RESOLVE-017: a video whose recorded poster is gone 404s instead of falling through (#823)', async () => {
const id = insertPhoto({ provider: 'local', file_path: 'journey/clip.mp4', thumbnail_path: 'journey/thumbs/t.jpg', media_type: 'video' });
const id = await insertPhoto({ provider: 'local', file_path: 'journey/clip.mp4', thumbnail_path: 'journey/thumbs/t.jpg', media_type: 'video' });
storage.exists.mockResolvedValue(false);
const res = makeRes();
@@ -224,7 +234,7 @@ describe('streamPhoto — dispatch', () => {
});
it('RESOLVE-019: a rejecting exists check (invalid key) reads as a local miss for thumb and original', async () => {
const id = insertPhoto({ provider: 'local', file_path: 'journey/x.jpg', thumbnail_path: 'journey/thumbs/h.jpg' });
const id = await insertPhoto({ provider: 'local', file_path: 'journey/x.jpg', thumbnail_path: 'journey/thumbs/h.jpg' });
storage.exists.mockRejectedValue(new Error('invalid storage key'));
const res = makeRes();
@@ -236,7 +246,7 @@ describe('streamPhoto — dispatch', () => {
});
it('RESOLVE-018: a file_path outside journey/ reads as a local miss without touching storage', async () => {
const id = insertPhoto({ provider: 'local', file_path: 'nope/other.jpg' });
const id = await insertPhoto({ provider: 'local', file_path: 'nope/other.jpg' });
const res = makeRes();
await svc.streamPhoto(res as never, 1, id, 'original');
@@ -247,7 +257,7 @@ describe('streamPhoto — dispatch', () => {
});
it('RESOLVE-006: an immich original goes straight to the provider with the range header', async () => {
const id = insertPhoto({ provider: 'immich', asset_id: 'a1', owner_id: 5 });
const id = await insertPhoto({ provider: 'immich', asset_id: 'a1', owner_id: 5 });
const res = makeRes();
await svc.streamPhoto(res as never, 3, id, 'original', 'bytes=0-99');
@@ -256,7 +266,7 @@ describe('streamPhoto — dispatch', () => {
});
it('RESOLVE-007: an immich thumbnail served from cache never reaches the provider', async () => {
const id = insertPhoto({ provider: 'immich', asset_id: 'a1', owner_id: 5 });
const id = await insertPhoto({ provider: 'immich', asset_id: 'a1', owner_id: 5 });
cache.serveFresh.mockReturnValue(true);
const res = makeRes();
@@ -267,7 +277,7 @@ describe('streamPhoto — dispatch', () => {
});
it('RESOLVE-008: a synology original forwards the decrypted passphrase', async () => {
const id = insertPhoto({ provider: 'synologyphotos', asset_id: 's1', owner_id: 9, passphrase: 'secret' });
const id = await insertPhoto({ provider: 'synologyphotos', asset_id: 's1', owner_id: 9, passphrase: 'secret' });
const res = makeRes();
await svc.streamPhoto(res as never, 4, id, 'original');
@@ -276,7 +286,7 @@ describe('streamPhoto — dispatch', () => {
});
it('RESOLVE-009: a synology row without a passphrase forwards undefined, not an empty string', async () => {
const id = insertPhoto({ provider: 'synologyphotos', asset_id: 's2', owner_id: 9 });
const id = await insertPhoto({ provider: 'synologyphotos', asset_id: 's2', owner_id: 9 });
const res = makeRes();
await svc.streamPhoto(res as never, 4, id, 'original');
@@ -292,7 +302,7 @@ describe('getPhotoInfo — dispatch', () => {
});
it('RESOLVE-011: asks immich for an immich asset', async () => {
const id = insertPhoto({ provider: 'immich', asset_id: 'a1', owner_id: 5 });
const id = await insertPhoto({ provider: 'immich', asset_id: 'a1', owner_id: 5 });
immich.getAssetInfo.mockResolvedValue({ success: true, data: { id: 'a1' } });
await svc.getPhotoInfo(3, id);
@@ -302,7 +312,7 @@ describe('getPhotoInfo — dispatch', () => {
});
it('RESOLVE-012: asks synology for a synology asset', async () => {
const id = insertPhoto({ provider: 'synologyphotos', asset_id: 's1', owner_id: 9 });
const id = await insertPhoto({ provider: 'synologyphotos', asset_id: 's1', owner_id: 9 });
synology.getSynologyAssetInfo.mockResolvedValue({ success: true, data: { id: 's1' } });
await svc.getPhotoInfo(4, id);
@@ -312,7 +322,7 @@ describe('getPhotoInfo — dispatch', () => {
});
it('RESOLVE-013: an unknown provider is an error, not a crash', async () => {
const id = insertPhoto({ provider: 'picasa', asset_id: 'p1', owner_id: 2 });
const id = await insertPhoto({ provider: 'picasa', asset_id: 'p1', owner_id: 2 });
const result = await svc.getPhotoInfo(1, id);
expect(result.success).toBe(false);
});
@@ -64,7 +64,9 @@ import { createUser } from '../../helpers/factories';
import { TokenService } from '../../../src/nest/tokens/token.service';
import type { McpTokensRepository } from '../../../src/db/repositories/McpTokens.repository';
import type { UsersRepository } from '../../../src/db/repositories/Users.repository';
import { createTestMcpTokensRepo, createTestUsersRepo } from '../../helpers/test-uow';
import { createTestMcpTokensRepo, createTestUsersRepo, sharedTestOrm } from '../../helpers/test-uow';
import { findRow, updateRows } from '../../helpers/factories/rows';
import { McpTokens } from '../../../src/db/entities/McpTokens.entity';
import { EphemeralTokenService } from '../../../src/nest/auth/ephemeral-token.service';
import { ApiTokenGuard } from '../../../src/nest/public-api/api-token.guard';
import { PublicApiController } from '../../../src/nest/public-api/public-api.controller';
@@ -402,12 +404,15 @@ describe('TokenService — storing and resolving a grant', () => {
testDb.close();
});
const rowFor = (id: number) =>
testDb.prepare('SELECT kind, scope_mode, api_scopes FROM mcp_tokens WHERE id = ?').get(id) as {
kind: string;
scope_mode: string | null;
api_scopes: string | null;
};
/** The grant columns of the stored token row. */
const rowFor = async (id: number) => {
const row = await findRow(await sharedTestOrm(testDb), McpTokens, { id });
if (!row) throw new Error(`no token ${id}`);
return { kind: row.kind, scope_mode: row.scope_mode, api_scopes: row.api_scopes };
};
const setApiScopes = async (id: number, apiScopes: string) =>
updateRows(await sharedTestOrm(testDb), McpTokens, { id }, { api_scopes: apiScopes });
const lastUsedAt = async (id: number) => (await findRow(await sharedTestOrm(testDb), McpTokens, { id }))?.last_used_at;
const mint = async (userId: number, name: string, scopes?: readonly string[]) =>
(await tokens.createApiToken(userId, name, scopes)).token as {
@@ -422,7 +427,7 @@ describe('TokenService — storing and resolving a grant', () => {
const created = await mint(user.id, 'Dawarich');
// 'all' is written explicitly: a NULL sentinel here would mean a bug that
// drops the column silently grants everything.
expect(rowFor(created.id)).toEqual({ kind: 'api', scope_mode: 'all', api_scopes: null });
expect(await rowFor(created.id)).toEqual({ kind: 'api', scope_mode: 'all', api_scopes: null });
expect(created.scope_mode).toBe('all');
expect(created.scopes).toEqual([...PUBLIC_API_SCOPES]);
});
@@ -463,7 +468,7 @@ describe('TokenService — storing and resolving a grant', () => {
it('PUBAPI-SCOPE-U043: a narrowed key stores exactly the chosen sections, in the canonical order', async () => {
const { user } = createUser(testDb);
const created = await mint(user.id, 'Dawarich', ['stats', 'trips']);
expect(rowFor(created.id)).toEqual({
expect(await rowFor(created.id)).toEqual({
kind: 'api',
scope_mode: 'limited',
// Canonical order, not the caller's: two keys with the same access must
@@ -485,7 +490,7 @@ describe('TokenService — storing and resolving a grant', () => {
it('PUBAPI-SCOPE-U045: an unknown scope name is dropped rather than stored', async () => {
const { user } = createUser(testDb);
const created = await mint(user.id, 'Dawarich', ['trips', 'passwords', 'trips']);
expect(rowFor(created.id).api_scopes).toBe(JSON.stringify(['trips']));
expect((await rowFor(created.id)).api_scopes).toBe(JSON.stringify(['trips']));
});
it('PUBAPI-SCOPE-U046: a list of nothing but unknown names is no narrowing at all', async () => {
@@ -493,12 +498,12 @@ describe('TokenService — storing and resolving a grant', () => {
// Not an empty limited grant: that would mint a key that can read nothing,
// which nobody asked for and which fails hours later at the integration.
const created = await mint(user.id, 'Dawarich', ['everything']);
expect(rowFor(created.id)).toMatchObject({ scope_mode: 'all', api_scopes: null });
expect(await rowFor(created.id)).toMatchObject({ scope_mode: 'all', api_scopes: null });
});
it('PUBAPI-SCOPE-U047: an empty scopes array means the caller did not narrow anything', async () => {
const { user } = createUser(testDb);
expect(rowFor((await mint(user.id, 'Dawarich', [])).id)).toMatchObject({ scope_mode: 'all', api_scopes: null });
expect(await rowFor((await mint(user.id, 'Dawarich', [])).id)).toMatchObject({ scope_mode: 'all', api_scopes: null });
});
it('PUBAPI-SCOPE-U048: a key that says it is narrowed and cannot say how reads nothing', async () => {
@@ -510,7 +515,7 @@ describe('TokenService — storing and resolving a grant', () => {
const { user } = createUser(testDb);
const created = await mint(user.id, 'Dawarich', ['trips']);
for (const broken of ['{"not":"a list"', 'null', '"trips"', '{}', '[]', '[42, null]']) {
testDb.prepare('UPDATE mcp_tokens SET api_scopes = ? WHERE id = ?').run(broken, created.id);
await setApiScopes(created.id, broken);
expect((await tokens.verifyApiTokenWithGrant(created.raw_token))?.grant).toEqual({
mode: 'limited',
scopes: [],
@@ -522,9 +527,7 @@ describe('TokenService — storing and resolving a grant', () => {
const { user } = createUser(testDb);
const created = await mint(user.id, 'Dawarich', ['trips']);
// What a future version dropping or renaming a scope constant leaves behind.
testDb
.prepare('UPDATE mcp_tokens SET api_scopes = ? WHERE id = ?')
.run(JSON.stringify(['itineraries']), created.id);
await setApiScopes(created.id, JSON.stringify(['itineraries']));
expect((await tokens.verifyApiTokenWithGrant(created.raw_token))?.grant).toEqual({
mode: 'limited',
scopes: [],
@@ -534,9 +537,7 @@ describe('TokenService — storing and resolving a grant', () => {
it('PUBAPI-SCOPE-U049: a partly unreadable list keeps the names it can read', async () => {
const { user } = createUser(testDb);
const created = await mint(user.id, 'Dawarich', ['trips']);
testDb
.prepare('UPDATE mcp_tokens SET api_scopes = ? WHERE id = ?')
.run(JSON.stringify(['trips', 42, 'nope', 'stats']), created.id);
await setApiScopes(created.id, JSON.stringify(['trips', 42, 'nope', 'stats']));
expect((await tokens.verifyApiTokenWithGrant(created.raw_token))?.grant).toEqual({
mode: 'limited',
scopes: ['trips', 'stats'],
@@ -564,7 +565,7 @@ describe('TokenService — storing and resolving a grant', () => {
const mcp = (await tokens.createMcpToken(user.id, 'Assistant')).token as { id: number };
// Stored as the column default, because an MCP token carries no read scopes
// and must not look as if it did.
expect(rowFor(mcp.id)).toEqual({ kind: 'mcp', scope_mode: 'all', api_scopes: null });
expect(await rowFor(mcp.id)).toEqual({ kind: 'mcp', scope_mode: 'all', api_scopes: null });
const listed = await tokens.listMcpTokens(user.id) as Array<Record<string, unknown>>;
expect(listed).toHaveLength(1);
// The MCP panel cannot act on scopes, so it is not shown two columns that
@@ -586,17 +587,9 @@ describe('TokenService — storing and resolving a grant', () => {
it('PUBAPI-SCOPE-U053: presenting a narrowed key stamps last_used_at like any other', async () => {
const { user } = createUser(testDb);
const created = await mint(user.id, 'Dawarich', ['trips']);
expect(
(testDb.prepare('SELECT last_used_at FROM mcp_tokens WHERE id = ?').get(created.id) as {
last_used_at: string | null;
}).last_used_at,
).toBeNull();
expect(await lastUsedAt(created.id)).toBeNull();
await tokens.verifyApiTokenWithGrant(created.raw_token);
expect(
(testDb.prepare('SELECT last_used_at FROM mcp_tokens WHERE id = ?').get(created.id) as {
last_used_at: string | null;
}).last_used_at,
).not.toBeNull();
expect(await lastUsedAt(created.id)).not.toBeNull();
});
// -------------------------------------------------------------------------
@@ -624,7 +617,7 @@ describe('TokenService — storing and resolving a grant', () => {
httpReq,
)).token as { id: number; scope_mode: string; scopes: string[]; raw_token: string };
expect(rowFor(created.id)).toMatchObject({
expect(await rowFor(created.id)).toMatchObject({
scope_mode: 'limited',
api_scopes: JSON.stringify(['trips', 'days']),
});
@@ -648,7 +641,7 @@ describe('TokenService — storing and resolving a grant', () => {
const created = (await ctl.createApiToken({ id: user.id } as User, { name: 'Legacy' }, httpReq)).token as {
id: number;
};
expect(rowFor(created.id)).toMatchObject({ scope_mode: 'all', api_scopes: null });
expect(await rowFor(created.id)).toMatchObject({ scope_mode: 'all', api_scopes: null });
expect(await ctl.listApiTokens({ id: user.id } as User)).toEqual({
tokens: [expect.objectContaining({ scope_mode: 'all', scopes: [...PUBLIC_API_SCOPES] })],
});
@@ -665,7 +658,7 @@ describe('TokenService — storing and resolving a grant', () => {
httpReq,
)).token as Record<string, unknown>;
expect(rowFor(created.id as number)).toEqual({ kind: 'mcp', scope_mode: 'all', api_scopes: null });
expect(await rowFor(created.id as number)).toEqual({ kind: 'mcp', scope_mode: 'all', api_scopes: null });
expect(created).not.toHaveProperty('scope_mode');
expect(created).not.toHaveProperty('scopes');
expect((await ctl.listMcpTokens({ id: user.id } as User)).tokens[0]).not.toHaveProperty('scope_mode');
@@ -5,6 +5,7 @@ import type { RouteUsageEntry } from '@trek/shared';
import { createTestUnitOfWork, sharedTestOrm } from '../../helpers/test-uow';
import { RouteUsageDaily } from '../../../src/db/entities/RouteUsageDaily.entity';
import { AppSettings } from '../../../src/db/entities/AppSettings.entity';
import { countRows, insertRow } from '../../helpers/factories/rows';
/**
* SRV-ROUTEUSAGE-001..010 — the counters behind "could TREK host a router".
@@ -40,6 +41,31 @@ async function serviceOver(db: Database.Database): Promise<RouteUsageService> {
return new RouteUsageService(t.repo(RouteUsageDaily), t.repo(AppSettings), await createTestUnitOfWork(db));
}
/** One counter row, written as the table holds it: (day, profile, surface, self_hosted, requests, waypoints, km, failed). */
async function insertDay(
db: Database.Database,
day: string,
selfHosted: number,
requests: number,
waypoints: number,
km: number,
failed: number,
): Promise<void> {
await insertRow(await sharedTestOrm(db), RouteUsageDaily, {
day,
profile: 'driving',
surface: 'legs',
self_hosted: selfHosted,
requests,
waypoints,
km,
failed,
});
}
/** The UTC calendar date `days` days back, as SQLite's `date('now', '-N days')` renders it. */
const daysAgo = (days: number): string => new Date(Date.now() - days * 86_400_000).toISOString().slice(0, 10);
const entry = (over: Partial<RouteUsageEntry> = {}): RouteUsageEntry => ({
profile: 'driving', surface: 'legs', selfHosted: false,
requests: 1, waypoints: 3, km: 100, failed: 0, ...over,
@@ -56,14 +82,14 @@ describe('RouteUsageService', () => {
it('SRV-ROUTEUSAGE-001: counting is on unless an operator turned it off', async () => {
expect(await svc.enabled()).toBe(true);
db.prepare("INSERT INTO app_settings (key, value) VALUES ('route_usage_enabled', 'false')").run();
await insertRow(await sharedTestOrm(db), AppSettings, { key: 'route_usage_enabled', value: 'false' });
expect(await svc.enabled()).toBe(false);
});
it('SRV-ROUTEUSAGE-002: a switched-off instance records nothing and says so', async () => {
db.prepare("INSERT INTO app_settings (key, value) VALUES ('route_usage_enabled', 'false')").run();
await insertRow(await sharedTestOrm(db), AppSettings, { key: 'route_usage_enabled', value: 'false' });
expect(await svc.record({ entries: [entry()] })).toBe(false);
expect(db.prepare('SELECT COUNT(*) c FROM route_usage_daily').get()).toEqual({ c: 0 });
expect(await countRows(await sharedTestOrm(db), RouteUsageDaily)).toBe(0);
});
it('SRV-ROUTEUSAGE-003: a batch adds onto one row per day, profile, kind and engine', async () => {
@@ -96,8 +122,8 @@ describe('RouteUsageService', () => {
it('SRV-ROUTEUSAGE-006: the summary carries the per-day average and the busiest day', async () => {
// Two days by hand: the service always writes "today", so the spread is set here.
db.prepare(`INSERT INTO route_usage_daily VALUES ('2026-09-01','driving','legs',0,10,30,900,0)`).run();
db.prepare(`INSERT INTO route_usage_daily VALUES ('2026-09-02','driving','legs',0,30,60,1800,2)`).run();
await insertDay(db, '2026-09-01', 0, 10, 30, 900, 0);
await insertDay(db, '2026-09-02', 0, 30, 60, 1800, 2);
const s = await svc.summary();
expect(s.totalRequests).toBe(40);
@@ -111,21 +137,21 @@ describe('RouteUsageService', () => {
});
it('SRV-ROUTEUSAGE-007: mean waypoints and kilometres are per request, not per row', async () => {
db.prepare(`INSERT INTO route_usage_daily VALUES ('2026-09-01','driving','legs',0,4,20,400,0)`).run();
await insertDay(db, '2026-09-01', 0, 4, 20, 400, 0);
const s = await svc.summary();
expect(s.waypointsPerRequest).toBe(5);
expect(s.kmPerRequest).toBe(100);
});
it('SRV-ROUTEUSAGE-008: the self-hosted share is the part already off the public hosts', async () => {
db.prepare(`INSERT INTO route_usage_daily VALUES ('2026-09-01','driving','legs',0,30,0,0,0)`).run();
db.prepare(`INSERT INTO route_usage_daily VALUES ('2026-09-01','driving','legs',1,10,0,0,0)`).run();
await insertDay(db, '2026-09-01', 0, 30, 0, 0, 0);
await insertDay(db, '2026-09-01', 1, 10, 0, 0, 0);
expect((await svc.summary()).selfHostedShare).toBe(0.25);
});
it('SRV-ROUTEUSAGE-009: retention drops days past the window and keeps the rest', async () => {
db.prepare(`INSERT INTO route_usage_daily VALUES (date('now','-1 day'),'driving','legs',0,1,0,0,0)`).run();
db.prepare(`INSERT INTO route_usage_daily VALUES (date('now','-${RETENTION_DAYS + 5} days'),'driving','legs',0,1,0,0,0)`).run();
await insertDay(db, daysAgo(1), 0, 1, 0, 0, 0);
await insertDay(db, daysAgo(RETENTION_DAYS + 5), 0, 1, 0, 0, 0);
expect(await svc.purgeExpired()).toBe(1);
expect(await svc.rows()).toHaveLength(1);
+35 -20
View File
@@ -48,20 +48,35 @@ import { db as testDb } from '../../../src/db/database';
import { SynologyService } from '../../../src/nest/memories/synology.service';
import type { MemoriesAccessService } from '../../../src/nest/memories/memories-access.service';
import { notificationsStub } from '../../helpers/notifications';
import { createTestUsersRepo } from '../../helpers/test-uow';
import { createTestUsersRepo, sharedTestOrm } from '../../helpers/test-uow';
import { deleteRows, upsertRow } from '../../helpers/factories/rows';
import { readUser } from '../../helpers/factories/users';
import { Users } from '../../../src/db/entities/Users.entity';
const access = { getAlbumLinkForSync: vi.fn(), updateSyncTimeForAlbumLink: vi.fn() };
let svc: SynologyService;
const USER = 1;
function seedUser(id: number, cols: Partial<Record<string, unknown>> = {}): void {
const base = { synology_url: 'https://nas.test', synology_username: 'ada', synology_password: 'pw', synology_sid: 'sid-1', synology_did: null, synology_skip_ssl: 1 };
const row = { ...base, ...cols };
testDb.prepare(
`INSERT OR REPLACE INTO users (id, username, email, password_hash, synology_url, synology_username, synology_password, synology_sid, synology_did, synology_skip_ssl)
VALUES (?, ?, ?, 'x', ?, ?, ?, ?, ?, ?)`
).run(id, `u${id}`, `u${id}@example.test`, row.synology_url, row.synology_username, row.synology_password, row.synology_sid, row.synology_did, row.synology_skip_ssl);
interface SynologyCols {
synology_url: string | null;
synology_username: string | null;
synology_password: string | null;
synology_sid: string | null;
synology_did: string | null;
synology_skip_ssl: number;
}
async function seedUser(id: number, cols: Partial<SynologyCols> = {}): Promise<void> {
const base: SynologyCols = { synology_url: 'https://nas.test', synology_username: 'ada', synology_password: 'pw', synology_sid: 'sid-1', synology_did: null, synology_skip_ssl: 1 };
await upsertRow(await sharedTestOrm(testDb), Users, {
id,
username: `u${id}`,
email: `u${id}@example.test`,
password_hash: 'x',
...base,
...cols,
});
}
/** A Synology API envelope: { success, data } or { success:false, error:{ code } }. */
@@ -80,13 +95,13 @@ beforeAll(async () => {
svc = new SynologyService(access as unknown as MemoriesAccessService, notificationsStub(), users);
});
beforeEach(() => {
beforeEach(async () => {
vi.clearAllMocks();
decryptMock.mockImplementation((v: string) => v);
maybeEncryptMock.mockImplementation((v: string) => v);
checkSsrf.mockResolvedValue({ allowed: true, isPrivate: false, resolvedIp: '1.2.3.4' });
testDb.prepare('DELETE FROM users').run();
seedUser(USER);
await deleteRows(await sharedTestOrm(testDb), Users);
await seedUser(USER);
});
afterAll(() => testDb.close());
@@ -98,7 +113,7 @@ describe('credentials', () => {
});
it('SYNO-U002: a half-filled row is "Synology not configured"', async () => {
seedUser(2, { synology_password: null });
await seedUser(2, { synology_password: null });
const result = await svc.getSynologySettings(2);
expect(result).toEqual({ success: false, error: { message: 'Synology not configured', status: 400 } });
});
@@ -110,7 +125,7 @@ describe('credentials', () => {
});
it('SYNO-U004: skip_ssl is read as a boolean, 0 meaning verify', async () => {
seedUser(3, { synology_skip_ssl: 0 });
await seedUser(3, { synology_skip_ssl: 0 });
const result = await svc.getSynologySettings(3);
expect(result.success && result.data.synology_skip_ssl).toBe(false);
});
@@ -140,14 +155,14 @@ describe('the session', () => {
});
it('SYNO-U012: a login that returns no sid is a 500', async () => {
seedUser(4, { synology_sid: null });
await seedUser(4, { synology_sid: null });
safeFetch.mockResolvedValue(api({}));
const result = await svc.searchSynologyPhotos(4);
expect(result).toEqual({ success: false, error: { message: 'Failed to get session ID from Synology', status: 500 } });
});
it('SYNO-U013: a stored device id rides along so a trusted device skips OTP', async () => {
seedUser(5, { synology_sid: null, synology_did: 'device-1' });
await seedUser(5, { synology_sid: null, synology_did: 'device-1' });
safeFetch.mockResolvedValueOnce(api({ sid: 's' })).mockResolvedValueOnce(api({ list: [] }));
await svc.searchSynologyPhotos(5);
@@ -219,7 +234,7 @@ describe('updateSynologySettings', () => {
it('SYNO-U031: keeps the stored password when none is supplied', async () => {
safeFetch.mockResolvedValue(api({ sid: 's' }));
await svc.updateSynologySettings(USER, 'https://nas2.test', 'ada');
const row = testDb.prepare('SELECT synology_password, synology_url FROM users WHERE id = ?').get(USER) as { synology_password: string; synology_url: string };
const row = await readUser(await sharedTestOrm(testDb), USER);
expect(row.synology_password).toBe('pw');
expect(row.synology_url).toBe('https://nas2.test');
});
@@ -240,9 +255,9 @@ describe('updateSynologySettings', () => {
const plaintext = 'synthetic-test-synology-pw-001';
await svc.updateSynologySettings(USER, 'https://nas3.test', 'ada', plaintext);
const row = testDb.prepare('SELECT synology_password FROM users WHERE id = ?').get(USER) as { synology_password: string };
const row = await readUser(await sharedTestOrm(testDb), USER);
expect(row.synology_password).not.toBe(plaintext);
expect(row.synology_password.startsWith('enc:v1:')).toBe(true);
expect(row.synology_password?.startsWith('enc:v1:')).toBe(true);
});
});
@@ -453,14 +468,14 @@ describe('the search window', () => {
describe('fetchSynologyThumbnailBytes', () => {
it('SYNO-U080: fails without credentials rather than fetching', async () => {
seedUser(6, { synology_url: null });
await seedUser(6, { synology_url: null });
const result = await svc.fetchSynologyThumbnailBytes(6, 6, 'a1');
expect(result).toHaveProperty('error');
expect(safeFetch).not.toHaveBeenCalled();
});
it('SYNO-U081: bounds the thumbnail read in time and size, on a host the user configured', async () => {
seedUser(7);
await seedUser(7);
safeFetch.mockResolvedValueOnce({
ok: true,
status: 200,
+12 -19
View File
@@ -13,21 +13,8 @@ vi.mock('../../../src/db/database', async () => {
const { createSnapshotTestDb } = await import('../../helpers/db-mock');
const db = createSnapshotTestDb();
const mock = {
db,
closeDb: () => {},
reinitialize: () => {},
getPlaceWithTags: () => null,
canAccessTrip: (tripId: any, userId: number) =>
db.prepare(`
SELECT t.id, t.user_id FROM trips t
LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ?
WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)
`).get(userId, tripId, userId),
isOwner: (tripId: any, userId: number) =>
!!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId),
};
return mock;
// Trip access reads through TripsRepository now; the module only hands out the handle.
return { db, closeDb: () => {}, reinitialize: () => {} };
});
@@ -44,7 +31,11 @@ import { createUser, createTrip, addTripMember } from '../../helpers/factories';
import { PermissionsService } from '../../../src/nest/permissions/permissions.service';
import { TodoService } from '../../../src/nest/todo/todo.service';
import { RealtimeService } from '../../../src/nest/realtime/realtime.service';
import { createTestUnitOfWork, createTestAppSettingsRepo, createTestTripsRepo, createTestTripMembersRepo } from '../../helpers/test-uow';
import { createTestUnitOfWork, createTestAppSettingsRepo, createTestTripsRepo, createTestTripMembersRepo, sharedTestOrm } from '../../helpers/test-uow';
import { countRows, findRows, updateRows } from '../../helpers/factories/rows';
import { TodoItems } from '../../../src/db/entities/TodoItems.entity';
import { TodoCategoryAssignees } from '../../../src/db/entities/TodoCategoryAssignees.entity';
import { Users } from '../../../src/db/entities/Users.entity';
import { createTestTodoItemsRepo, createTestTodoCategoryAssigneesRepo } from '../../helpers/todo-repos';
let svc: TodoService;
@@ -250,7 +241,7 @@ describe('reorderItems', () => {
await svc.reorderItems(trip.id, [c.id, a.id, b.id]);
const rows = testDb.prepare('SELECT id, sort_order FROM todo_items WHERE trip_id = ? ORDER BY sort_order').all(trip.id) as any[];
const rows = await findRows(await sharedTestOrm(testDb), TodoItems, { trip: trip.id }, { sort_order: 'asc' });
expect(rows[0].id).toBe(c.id);
expect(rows[1].id).toBe(a.id);
expect(rows[2].id).toBe(b.id);
@@ -321,13 +312,13 @@ describe('getCategoryAssignees / updateCategoryAssignees', () => {
expect(rows.map(r => r.user_id).sort()).toEqual([owner.id, member.id].sort());
expect(JSON.stringify(rows)).not.toContain(stranger.username);
expect(testDb.prepare('SELECT COUNT(*) AS n FROM todo_category_assignees WHERE user_id = ?').get(stranger.id)).toEqual({ n: 0 });
expect(await countRows(await sharedTestOrm(testDb), TodoCategoryAssignees, { user: stranger.id })).toBe(0);
});
it('TODO-SVC-017b: keeps a guest, who is a trip member like any other', async () => {
const { user: owner } = createUser(testDb);
const { user: guest } = createUser(testDb);
testDb.prepare('UPDATE users SET is_guest = 1 WHERE id = ?').run(guest.id);
await updateRows(await sharedTestOrm(testDb), Users, { id: guest.id }, { is_guest: 1 });
const trip = createTrip(testDb, owner.id);
addTripMember(testDb, trip.id, guest.id);
@@ -368,6 +359,7 @@ describe('TodoItemsRepository / TodoCategoryAssigneesRepository — parity', ()
await svc.createItem(trip.id, { name: 'Bare item' });
const converted = await svc.listItems(trip.id);
// test-sql-allow: the legacy statement is the oracle the repository read is held to.
const legacy = testDb.prepare('SELECT * FROM todo_items WHERE trip_id = ? ORDER BY sort_order ASC, created_at ASC').all(trip.id);
expect(converted).toEqual(legacy);
expect(converted).toHaveLength(2);
@@ -382,6 +374,7 @@ describe('TodoItemsRepository / TodoCategoryAssigneesRepository — parity', ()
const converted = await svc.updateCategoryAssignees(trip.id, 'Packing', [owner.id, stranger.id, member.id]);
// test-sql-allow: the legacy statement is the oracle the repository read is held to.
const legacy = testDb.prepare(`
SELECT tca.user_id, u.username, u.avatar
FROM todo_category_assignees tca
@@ -20,6 +20,8 @@ import { TrekPhotos } from '../../../src/db/entities/TrekPhotos.entity';
import { TripPhotos } from '../../../src/db/entities/TripPhotos.entity';
import { JourneyPhotos } from '../../../src/db/entities/JourneyPhotos.entity';
import { TripAlbumLinks } from '../../../src/db/entities/TripAlbumLinks.entity';
import { Journeys } from '../../../src/db/entities/Journeys.entity';
import { countRows, findRow, insertRow } from '../../helpers/factories/rows';
import { decrypt_api_key } from '../../../src/nest/common/crypto/apiKeyCrypto';
const testDb = createSnapshotTestDb();
@@ -41,14 +43,13 @@ async function makePhoto(): Promise<number> {
return await repo.getOrCreateLocal(`/uploads/journey/${++seq}.jpg`, null, null, null, 'image', null);
}
function rawRow(id: number): unknown {
return testDb.prepare('SELECT * FROM trek_photos WHERE id = ?').get(id);
function rawRow(id: number) {
return findRow(t, TrekPhotos, { id });
}
function read(id: number) {
return testDb.prepare('SELECT taken_at, lat, lng FROM trek_photos WHERE id = ?').get(id) as {
taken_at: string | null; lat: number | null; lng: number | null;
};
async function read(id: number) {
const row = await rawRow(id);
return { taken_at: row?.taken_at, lat: row?.lat, lng: row?.lng };
}
describe('TrekPhotoRegistrationService.recordCaptureMetadata', () => {
@@ -56,7 +57,7 @@ describe('TrekPhotoRegistrationService.recordCaptureMetadata', () => {
const id = await makePhoto();
await repo.recordCaptureMetadata(id, { takenAt: '2026-03-15T10:20:00Z', lat: 48.8584, lng: 2.2945 });
expect(read(id)).toEqual({ taken_at: '2026-03-15T10:20:00Z', lat: 48.8584, lng: 2.2945 });
expect(await read(id)).toEqual({ taken_at: '2026-03-15T10:20:00Z', lat: 48.8584, lng: 2.2945 });
});
it('TREKPHOTO-002: a later, emptier answer does not erase what is known', async () => {
@@ -65,7 +66,7 @@ describe('TrekPhotoRegistrationService.recordCaptureMetadata', () => {
// The album listing knows the date but not the place.
await repo.recordCaptureMetadata(id, { takenAt: '2020-01-01T00:00:00Z', lat: null, lng: null });
expect(read(id)).toEqual({ taken_at: '2026-03-15T10:20:00Z', lat: 48.8584, lng: 2.2945 });
expect(await read(id)).toEqual({ taken_at: '2026-03-15T10:20:00Z', lat: 48.8584, lng: 2.2945 });
});
it('TREKPHOTO-003: fills only the half that was still missing', async () => {
@@ -73,14 +74,14 @@ describe('TrekPhotoRegistrationService.recordCaptureMetadata', () => {
await repo.recordCaptureMetadata(id, { takenAt: '2026-03-15T10:20:00Z' });
await repo.recordCaptureMetadata(id, { lat: 48.8584, lng: 2.2945 });
expect(read(id)).toEqual({ taken_at: '2026-03-15T10:20:00Z', lat: 48.8584, lng: 2.2945 });
expect(await read(id)).toEqual({ taken_at: '2026-03-15T10:20:00Z', lat: 48.8584, lng: 2.2945 });
});
it('TREKPHOTO-004: refuses half a coordinate pair rather than landing on null island', async () => {
const id = await makePhoto();
await repo.recordCaptureMetadata(id, { takenAt: '2026-03-15T10:20:00Z', lat: 48.8584, lng: null });
expect(read(id)).toEqual({ taken_at: '2026-03-15T10:20:00Z', lat: null, lng: null });
expect(await read(id)).toEqual({ taken_at: '2026-03-15T10:20:00Z', lat: null, lng: null });
});
it('TREKPHOTO-005: an answer with nothing in it touches no row', async () => {
@@ -88,7 +89,7 @@ describe('TrekPhotoRegistrationService.recordCaptureMetadata', () => {
await repo.recordCaptureMetadata(id, { takenAt: '2026-03-15T10:20:00Z', lat: 48.8584, lng: 2.2945 });
await repo.recordCaptureMetadata(id, {});
expect(read(id)).toEqual({ taken_at: '2026-03-15T10:20:00Z', lat: 48.8584, lng: 2.2945 });
expect(await read(id)).toEqual({ taken_at: '2026-03-15T10:20:00Z', lat: 48.8584, lng: 2.2945 });
});
it('TREKPHOTO-006: says whether the row learned anything, so a refresh is only sent for news', async () => {
@@ -109,7 +110,7 @@ describe('TrekPhotoRegistrationService.getOrCreate (PH1-3)', () => {
it('TREKPHOTO-010: registers a new remote asset', async () => {
const { user } = createUser(testDb);
const id = await repo.getOrCreate('immich', 'asset-1', user.id, undefined, 'video');
expect(rawRow(id)).toMatchObject({ provider: 'immich', asset_id: 'asset-1', owner_id: user.id, media_type: 'video' });
expect(await rawRow(id)).toMatchObject({ provider: 'immich', asset_id: 'asset-1', owner_id: user.id, media_type: 'video' });
});
it('TREKPHOTO-011: a repeat lookup returns the same id rather than inserting a duplicate row', async () => {
@@ -117,7 +118,7 @@ describe('TrekPhotoRegistrationService.getOrCreate (PH1-3)', () => {
const first = await repo.getOrCreate('immich', 'asset-2', user.id);
const second = await repo.getOrCreate('immich', 'asset-2', user.id);
expect(second).toBe(first);
expect(testDb.prepare('SELECT COUNT(*) as c FROM trek_photos WHERE asset_id = ?').get('asset-2')).toEqual({ c: 1 });
expect(await countRows(t, TrekPhotos, { asset_id: 'asset-2' })).toBe(1);
});
it('TREKPHOTO-012 (PH2): a passphrase on an already-registered asset re-encrypts and replaces it', async () => {
@@ -125,7 +126,7 @@ describe('TrekPhotoRegistrationService.getOrCreate (PH1-3)', () => {
const id = await repo.getOrCreate('synologyphotos', 'asset-3', user.id, 'first-pass');
await repo.getOrCreate('synologyphotos', 'asset-3', user.id, 'second-pass');
const row = rawRow(id) as { passphrase: string };
const row = (await rawRow(id)) as { passphrase: string };
expect(decrypt_api_key(row.passphrase)).toBe('second-pass');
});
@@ -134,7 +135,7 @@ describe('TrekPhotoRegistrationService.getOrCreate (PH1-3)', () => {
const id = await repo.getOrCreate('synologyphotos', 'asset-4', user.id, 'keep-me');
await repo.getOrCreate('synologyphotos', 'asset-4', user.id);
const row = rawRow(id) as { passphrase: string };
const row = (await rawRow(id)) as { passphrase: string };
expect(decrypt_api_key(row.passphrase)).toBe('keep-me');
});
});
@@ -142,7 +143,7 @@ describe('TrekPhotoRegistrationService.getOrCreate (PH1-3)', () => {
describe('TrekPhotoRegistrationService.getOrCreateLocal (PH4-5)', () => {
it('TREKPHOTO-020: registers a new local photo', async () => {
const id = await repo.getOrCreateLocal('journey/a.jpg', 'journey/thumbs/a.jpg', 800, 600, 'image', null);
expect(rawRow(id)).toMatchObject({
expect(await rawRow(id)).toMatchObject({
provider: 'local', file_path: 'journey/a.jpg', thumbnail_path: 'journey/thumbs/a.jpg', width: 800, height: 600,
});
});
@@ -152,7 +153,7 @@ describe('TrekPhotoRegistrationService.getOrCreateLocal (PH4-5)', () => {
const second = await repo.getOrCreateLocal('journey/b.jpg', 'ignored-thumb.jpg', 1, 1);
expect(second).toBe(first);
// The second call's fields never reach the row — getOrCreateLocal only inserts once.
expect(rawRow(first)).toMatchObject({ thumbnail_path: null, width: null, height: null });
expect(await rawRow(first)).toMatchObject({ thumbnail_path: null, width: null, height: null });
});
});
@@ -162,6 +163,7 @@ describe('TrekPhotoRegistrationService.resolve (PH6) — parity with the legacy
const id = await repo.getOrCreate('immich', 'asset-parity', user.id, 'a-passphrase', 'video');
await repo.recordCaptureMetadata(id, { takenAt: '2026-05-01T00:00:00Z', lat: null, lng: null });
// test-sql-allow: the row as SELECT * returns it is the oracle the repository read is held to.
const legacy = testDb.prepare('SELECT * FROM trek_photos WHERE id = ?').get(id);
const resolved = await repo.resolve(id);
@@ -172,6 +174,7 @@ describe('TrekPhotoRegistrationService.resolve (PH6) — parity with the legacy
const id = await repo.getOrCreateLocal('journey/parity.jpg', 'journey/thumbs/parity.jpg', 800, 600, 'image', null);
await repo.recordCaptureMetadata(id, { takenAt: '2026-05-02T00:00:00Z', lat: 48.1, lng: 11.6 });
// test-sql-allow: the row as SELECT * returns it is the oracle the repository read is held to.
const legacy = testDb.prepare('SELECT * FROM trek_photos WHERE id = ?').get(id);
const resolved = await repo.resolve(id);
@@ -189,7 +192,7 @@ describe('TrekPhotoRegistrationService.setProvider (PH7)', () => {
const id = await repo.getOrCreateLocal('journey/retarget.jpg');
await repo.setProvider(id, 'immich', 'new-asset', user.id);
expect(rawRow(id)).toMatchObject({ provider: 'immich', asset_id: 'new-asset', owner_id: user.id });
expect(await rawRow(id)).toMatchObject({ provider: 'immich', asset_id: 'new-asset', owner_id: user.id });
});
});
@@ -198,7 +201,7 @@ describe('TrekPhotoRegistrationService.recordLocalThumbnail (PH8)', () => {
const id = await repo.getOrCreateLocal('journey/thumb-src.jpg');
await repo.recordLocalThumbnail(id, 'journey/thumbs/thumb-src.jpg', 800, 600);
expect(rawRow(id)).toMatchObject({ thumbnail_path: 'journey/thumbs/thumb-src.jpg', width: 800, height: 600 });
expect(await rawRow(id)).toMatchObject({ thumbnail_path: 'journey/thumbs/thumb-src.jpg', width: 800, height: 600 });
});
it('TREKPHOTO-051: a re-generated thumbnail does not blank dimensions already known', async () => {
@@ -206,7 +209,7 @@ describe('TrekPhotoRegistrationService.recordLocalThumbnail (PH8)', () => {
await repo.recordLocalThumbnail(id, 'journey/thumbs/thumb-keep.jpg', 400, 300);
// COALESCE(width, ?) keeps the already-known 1200x900, not the new 400x300.
expect(rawRow(id)).toMatchObject({ thumbnail_path: 'journey/thumbs/thumb-keep.jpg', width: 1200, height: 900 });
expect(await rawRow(id)).toMatchObject({ thumbnail_path: 'journey/thumbs/thumb-keep.jpg', width: 1200, height: 900 });
});
});
@@ -215,34 +218,33 @@ describe('TrekPhotoRegistrationService.deleteIfOrphan (PH10-11)', () => {
const { user } = createUser(testDb);
const id = await repo.getOrCreate('immich', 'orphan-1', user.id);
await repo.deleteIfOrphan(id);
expect(rawRow(id)).toBeUndefined();
expect(await rawRow(id)).toBeNull();
});
it('TREKPHOTO-061: a photo still referenced by trip_photos (3e-owned) is kept', async () => {
const { user } = createUser(testDb);
const id = await repo.getOrCreate('immich', 'referenced-1', user.id);
const trip = createTrip(testDb, user.id);
testDb.prepare('INSERT INTO trip_photos (trip_id, user_id, photo_id, shared) VALUES (?, ?, ?, 1)').run(trip.id, user.id, id);
await insertRow(t, TripPhotos, { trip: trip.id, user: user.id, photo: id, shared: 1 });
await repo.deleteIfOrphan(id);
expect(rawRow(id)).toBeDefined();
expect(await rawRow(id)).not.toBeNull();
});
it('TREKPHOTO-062: a photo still referenced by journey_photos (Plan 3g, converted onto JourneyPhotosRepository.existsForPhoto) is kept', async () => {
const { user } = createUser(testDb);
const id = await repo.getOrCreate('immich', 'referenced-2', user.id);
testDb.prepare("INSERT INTO journeys (user_id, title, status, created_at, updated_at) VALUES (?, 'J', 'draft', 0, 0)").run(user.id);
const journeyId = testDb.prepare('SELECT id FROM journeys WHERE user_id = ?').get(user.id) as { id: number };
testDb.prepare('INSERT INTO journey_photos (journey_id, photo_id, created_at) VALUES (?, ?, 0)').run(journeyId.id, id);
const journeyId = await insertRow(t, Journeys, { user: user.id, title: 'J', status: 'draft', created_at: 0, updated_at: 0 });
await insertRow(t, JourneyPhotos, { journey: journeyId, photo: id, created_at: 0 });
await repo.deleteIfOrphan(id);
expect(rawRow(id)).toBeDefined();
expect(await rawRow(id)).not.toBeNull();
});
it('TREKPHOTO-063: an unreferenced LOCAL photo is never reclaimed here — its bytes are ours', async () => {
const id = await repo.getOrCreateLocal('journey/never-reclaimed.jpg');
await repo.deleteIfOrphan(id);
expect(rawRow(id)).toBeDefined();
expect(await rawRow(id)).not.toBeNull();
});
});
@@ -19,6 +19,7 @@ import { Trips } from '../../../../src/db/entities/Trips.entity';
import type { TripsRepository } from '../../../../src/db/repositories/Trips.repository';
import { TripMembers } from '../../../../src/db/entities/TripMembers.entity';
import type { TripMembersRepository } from '../../../../src/db/repositories/TripMembers.repository';
import { countRows, findRow, insertRow, updateRows } from '../../../helpers/factories/rows';
import { TripMembershipService } from '../../../../src/nest/trip-membership/trip-membership.service';
const testDb = createSnapshotTestDb();
@@ -37,7 +38,7 @@ beforeEach(() => { resetTestDb(testDb); t.clear(); });
afterAll(async () => { await t.close(); testDb.close(); });
function memberRow(tripId: number, userId: number) {
return testDb.prepare('SELECT * FROM trip_members WHERE trip_id = ? AND user_id = ?').get(tripId, userId);
return findRow(t, TripMembers, { trip: tripId, user: userId });
}
describe('joinTripAsMember', () => {
@@ -48,7 +49,7 @@ describe('joinTripAsMember', () => {
const r = await svc.joinTripAsMember(trip.id, joiner.id, null);
expect(r).toEqual({ joined: true, tripId: trip.id });
expect(memberRow(trip.id, joiner.id)).toBeTruthy();
expect(await memberRow(trip.id, joiner.id)).toBeTruthy();
});
it('TRIP-JOIN-002: never adds the trip owner as a member', async () => {
@@ -57,7 +58,7 @@ describe('joinTripAsMember', () => {
const r = await svc.joinTripAsMember(trip.id, owner.id, null);
expect(r.joined).toBe(false);
expect(memberRow(trip.id, owner.id)).toBeUndefined();
expect(await memberRow(trip.id, owner.id)).toBeNull();
});
it('TRIP-JOIN-003: is idempotent for an existing member (no duplicate row)', async () => {
@@ -67,8 +68,7 @@ describe('joinTripAsMember', () => {
expect((await svc.joinTripAsMember(trip.id, joiner.id, owner.id)).joined).toBe(true);
expect((await svc.joinTripAsMember(trip.id, joiner.id, owner.id)).joined).toBe(false);
const count = testDb.prepare('SELECT COUNT(*) as n FROM trip_members WHERE trip_id = ? AND user_id = ?').get(trip.id, joiner.id) as { n: number };
expect(count.n).toBe(1);
expect(await countRows(t, TripMembers, { trip: trip.id, user: joiner.id })).toBe(1);
});
it('TRIP-JOIN-004: no-ops for a missing trip', async () => {
@@ -113,8 +113,7 @@ describe('joinTripAsMember', () => {
// Exactly one membership row exists afterwards either way — the UNIQUE
// constraint on (trip_id, user_id) is the actual safety net today, not
// application-level locking.
const count = testDb.prepare('SELECT COUNT(*) as n FROM trip_members WHERE trip_id = ? AND user_id = ?').get(trip.id, joiner.id) as { n: number };
expect(count.n).toBe(1);
expect(await countRows(t, TripMembers, { trip: trip.id, user: joiner.id })).toBe(1);
});
});
@@ -133,8 +132,8 @@ describe('leaf membership reads', () => {
const { user: m1 } = createUser(testDb);
const { user: m2 } = createUser(testDb);
const trip = createTrip(testDb, owner.id);
testDb.prepare("INSERT INTO trip_members (trip_id, user_id, added_at) VALUES (?, ?, '2026-01-02')").run(trip.id, m2.id);
testDb.prepare("INSERT INTO trip_members (trip_id, user_id, added_at) VALUES (?, ?, '2026-01-01')").run(trip.id, m1.id);
await insertRow(t, TripMembers, { trip: trip.id, user: m2.id, added_at: '2026-01-02' });
await insertRow(t, TripMembers, { trip: trip.id, user: m1.id, added_at: '2026-01-01' });
expect(await svc.listMemberUserIds(trip.id)).toEqual([m1.id, m2.id]);
expect(await svc.listMemberUserIds(999999)).toEqual([]);
});
@@ -145,10 +144,10 @@ describe('leaf membership reads', () => {
const owned = createTrip(testDb, user.id);
const memberOf = createTrip(testDb, other.id);
const foreign = createTrip(testDb, other.id);
testDb.prepare('INSERT INTO trip_members (trip_id, user_id) VALUES (?, ?)').run(memberOf.id, user.id);
await insertRow(t, TripMembers, { trip: memberOf.id, user: user.id });
// Distinct created_at so the ORDER BY is actually asserted, not assumed.
testDb.prepare("UPDATE trips SET created_at = '2026-01-01' WHERE id = ?").run(owned.id);
testDb.prepare("UPDATE trips SET created_at = '2026-01-02' WHERE id = ?").run(memberOf.id);
await updateRows(t, Trips, { id: owned.id }, { created_at: '2026-01-01' });
await updateRows(t, Trips, { id: memberOf.id }, { created_at: '2026-01-02' });
const ids = await svc.listAccessibleTripIds(user.id);
expect(ids).toEqual([memberOf.id, owned.id]);
expect(ids).not.toContain(foreign.id);
@@ -22,9 +22,13 @@ import type { AirtrailClient } from '../../../src/nest/integrations/airtrail.cli
import type { AirtrailService } from '../../../src/nest/integrations/airtrail.service';
import { notificationsStub } from '../../helpers/notifications';
import { accommodationsOver } from '../../helpers/accommodations-service';
import { createTestUnitOfWork, createTestAppSettingsRepo, createTestReservationsRepo, createTestReservationEndpointsRepo, createTestReservationTravelersRepo, createTestReservationDayPositionsRepo, createTestDayAccommodationsRepo, createTestDaysRepo, createTestPlacesRepo, createTestDayAssignmentsRepo, createTestTripMembersRepo, createTestUsersRepo, createTestTripsRepo } from '../../helpers/test-uow';
import { createTestUnitOfWork, createTestAppSettingsRepo, createTestReservationsRepo, createTestReservationEndpointsRepo, createTestReservationTravelersRepo, createTestReservationDayPositionsRepo, createTestDayAccommodationsRepo, createTestDaysRepo, createTestPlacesRepo, createTestDayAssignmentsRepo, createTestTripMembersRepo, createTestUsersRepo, createTestTripsRepo, sharedTestOrm } from '../../helpers/test-uow';
import { createTestBudgetItemsRepo } from '../../helpers/files-repos';
import { budgetRepoArgs } from '../../helpers/budget-repos';
import { findRow, findRows } from '../../helpers/factories/rows';
import { Days } from '../../../src/db/entities/Days.entity';
import { Reservations } from '../../../src/db/entities/Reservations.entity';
import { ReservationEndpoints } from '../../../src/db/entities/ReservationEndpoints.entity';
// The client and the per-user credentials are the only stubs; the reservation
// writes go through the real service against the real test DB, as before. They
@@ -113,14 +117,19 @@ const legLhrJfk = () =>
flightNumber: 'BA117',
});
function tripReservations(tripId: number) {
return db.prepare('SELECT * FROM reservations WHERE trip_id = ? ORDER BY id').all(tripId) as any[];
async function tripReservations(tripId: number) {
return findRows(await sharedTestOrm(db), Reservations, { trip: tripId }, { id: 'asc' });
}
function endpointsOf(reservationId: number) {
return db
.prepare('SELECT role, code, sequence FROM reservation_endpoints WHERE reservation_id = ? ORDER BY sequence')
.all(reservationId) as { role: string; code: string | null; sequence: number }[];
async function endpointsOf(reservationId: number) {
return findRows(await sharedTestOrm(db), ReservationEndpoints, { reservation: reservationId }, { sequence: 'asc' });
}
/** The id of the trip's day on `date`. */
async function dayIdOn(tripId: number, date: string): Promise<number> {
const day = await findRow(await sharedTestOrm(db), Days, { trip: tripId, date });
if (!day) throw new Error(`no day on ${date} for trip ${tripId}`);
return day.id;
}
let tripId: number;
@@ -142,7 +151,7 @@ describe('importAirtrailFlights connection joining (#1535)', () => {
expect([...result.imported].sort()).toEqual(['101', '102']);
expect(result.skipped).toEqual([]);
const rows = tripReservations(tripId);
const rows = await tripReservations(tripId);
expect(rows).toHaveLength(1);
const r = rows[0];
expect(r.type).toBe('flight');
@@ -150,10 +159,10 @@ describe('importAirtrailFlights connection joining (#1535)', () => {
expect(r.external_id).toBe('101');
expect(r.sync_enabled).toBe(0); // AirTrail has no multi-leg entity to round-trip to
const meta = JSON.parse(r.metadata);
const meta = JSON.parse(String(r.metadata));
expect(meta.airtrail_ids).toEqual(['101', '102']);
expect(meta.legs).toHaveLength(2);
expect(endpointsOf(r.id).map(e => [e.role, e.code])).toEqual([
expect((await endpointsOf(r.id)).map(e => [e.role, e.code])).toEqual([
['from', 'BRU'],
['stop', 'HEL'],
['to', 'JFK'],
@@ -161,9 +170,9 @@ describe('importAirtrailFlights connection joining (#1535)', () => {
// Each leg is filed on its own trip day so the day planner renders the
// legs where they belong (both flights are on Aug 1 here).
const day1 = db.prepare("SELECT id FROM days WHERE trip_id = ? AND date = '2026-08-01'").get(tripId) as { id: number };
expect(meta.legs[0]).toMatchObject({ dep_day_id: day1.id, arr_day_id: day1.id });
expect(meta.legs[1]).toMatchObject({ dep_day_id: day1.id, arr_day_id: day1.id });
const day1 = await dayIdOn(tripId, '2026-08-01');
expect(meta.legs[0]).toMatchObject({ dep_day_id: day1, arr_day_id: day1 });
expect(meta.legs[1]).toMatchObject({ dep_day_id: day1, arr_day_id: day1 });
});
it('resolves overnight-connection legs to their own days', async () => {
@@ -176,12 +185,11 @@ describe('importAirtrailFlights connection joining (#1535)', () => {
listFlights.mockResolvedValue([legBruHel(), overnightLeg2]);
await importAirtrailFlights(tripId, userId, ['101', '102'], undefined, [['101', '102']]);
const [r] = tripReservations(tripId);
const dayId = (d: string) => (db.prepare('SELECT id FROM days WHERE trip_id = ? AND date = ?').get(tripId, d) as { id: number }).id;
const legs = JSON.parse(r.metadata).legs;
expect(legs[0].dep_day_id).toBe(dayId('2026-08-01'));
expect(legs[1].dep_day_id).toBe(dayId('2026-08-02'));
expect(legs[1].arr_day_id).toBe(dayId('2026-08-02'));
const [r] = await tripReservations(tripId);
const legs = JSON.parse(String(r.metadata)).legs;
expect(legs[0].dep_day_id).toBe(await dayIdOn(tripId, '2026-08-01'));
expect(legs[1].dep_day_id).toBe(await dayIdOn(tripId, '2026-08-02'));
expect(legs[1].arr_day_id).toBe(await dayIdOn(tripId, '2026-08-02'));
});
it('refuses to join an out-and-back return as a connection', async () => {
@@ -198,7 +206,7 @@ describe('importAirtrailFlights connection joining (#1535)', () => {
const result = await importAirtrailFlights(tripId, userId, ['101', '104'], undefined, [['101', '104']]);
expect([...result.imported].sort()).toEqual(['101', '104']);
expect(tripReservations(tripId)).toHaveLength(2); // two singles, no bogus BRU→HEL→BRU booking
expect(await tripReservations(tripId)).toHaveLength(2); // two singles, no bogus BRU→HEL→BRU booking
});
it('skips every member of a joined booking on a later import attempt', async () => {
@@ -210,7 +218,7 @@ describe('importAirtrailFlights connection joining (#1535)', () => {
const again = await importAirtrailFlights(tripId, userId, ['102'], undefined);
expect(again.imported).toEqual([]);
expect(again.skipped).toEqual([{ flightId: '102', reason: 'already-imported' }]);
expect(tripReservations(tripId)).toHaveLength(1);
expect(await tripReservations(tripId)).toHaveLength(1);
});
it('recognizes a joined leg imported by another member via its per-leg signature', async () => {
@@ -224,7 +232,7 @@ describe('importAirtrailFlights connection joining (#1535)', () => {
const result = await importAirtrailFlights(tripId, other.id, ['999'], undefined);
expect(result.imported).toEqual([]);
expect(result.skipped).toEqual([{ flightId: '999', reason: 'already-in-trip', detail: expect.any(String) }]);
expect(tripReservations(tripId)).toHaveLength(1);
expect(await tripReservations(tripId)).toHaveLength(1);
});
it('falls back to individual imports when the requested join does not chain', async () => {
@@ -233,11 +241,11 @@ describe('importAirtrailFlights connection joining (#1535)', () => {
const result = await importAirtrailFlights(tripId, userId, ['101', '103'], undefined, [['101', '103']]);
expect([...result.imported].sort()).toEqual(['101', '103']);
const rows = tripReservations(tripId);
const rows = await tripReservations(tripId);
expect(rows).toHaveLength(2);
for (const r of rows) {
expect(r.sync_enabled).toBe(1); // plain imports keep live sync
expect(endpointsOf(r.id)).toHaveLength(2);
expect(await endpointsOf(r.id)).toHaveLength(2);
}
});
@@ -247,7 +255,7 @@ describe('importAirtrailFlights connection joining (#1535)', () => {
const result = await importAirtrailFlights(tripId, userId, ['101', '102'], undefined, [['101', '102']]);
expect([...result.imported].sort()).toEqual(['101', '102']);
expect(tripReservations(tripId)).toHaveLength(2);
expect(await tripReservations(tripId)).toHaveLength(2);
});
it('imports singles exactly as before when no join is requested', async () => {
@@ -256,11 +264,11 @@ describe('importAirtrailFlights connection joining (#1535)', () => {
const result = await importAirtrailFlights(tripId, userId, ['101'], undefined);
expect(result.imported).toEqual(['101']);
const [r] = tripReservations(tripId);
const [r] = await tripReservations(tripId);
expect(r.external_id).toBe('101');
expect(r.sync_enabled).toBe(1);
expect(r.external_hash).toBeTruthy();
expect(JSON.parse(r.metadata).airtrail_ids).toBeUndefined();
expect(JSON.parse(String(r.metadata)).airtrail_ids).toBeUndefined();
});
});