test(server): seed the atlas and notes tools, day removal, doc sync config and plugin runtime specs through the factories

The atlas and notes MCP tool suites, day removal, the doc sync config
service and the plugin runtime conversion suite build their fixtures and
read back what a call left through the ORM factories. Helpers that used
to answer synchronously off the handle (dayRows, range, the reservation
and boundary readers) are async now and every caller awaits them; a
reader that answered undefined for a gone row still does, so the
toBeUndefined/toBeDefined checks keep their meaning.

The one statement left raw rewinds sqlite_sequence, the engine's own
autoincrement table, which no entity maps; it is marked.
This commit is contained in:
Maurice
2026-10-08 16:16:02 +02:00
parent 2b41a55022
commit 2cf853e504
5 changed files with 401 additions and 302 deletions
@@ -28,6 +28,7 @@ import { UnitOfWork } from '../../../src/nest/database/unit-of-work';
import { createTestAddonsService } from '../../helpers/test-addons';
import { createSnapshotTestDb } from '../../helpers/db-mock';
import { createTestOrm, type TestOrm } from '../../helpers/test-orm';
import { countRows, findRow, insertRow, updateRows } from '../../helpers/factories/rows';
import { AuditLog } from '../../../src/db/entities/AuditLog.entity';
import { Users } from '../../../src/db/entities/Users.entity';
import { Plugins } from '../../../src/db/entities/Plugins.entity';
@@ -97,22 +98,28 @@ function buildPluginsService(): PluginsService {
);
}
function seedPlugin(id: string, overrides: Partial<{ enabled: number; granted_permissions: string; permissions: string; trek_range: string; api_version: number; version: string; author_pubkey: string }> = {}) {
testDb
.prepare(
`INSERT INTO plugins (id, name, status, enabled, version, trek_range, api_version, permissions, granted_permissions, capabilities, config, author_pubkey)
VALUES (?, ?, 'inactive', ?, ?, ?, ?, ?, ?, '{}', '{}', ?)`,
)
.run(
id, id,
overrides.enabled ?? 0,
overrides.version ?? '1.0.0',
overrides.trek_range ?? '>=3.0.0',
overrides.api_version ?? 1,
overrides.permissions ?? '["db:own"]',
overrides.granted_permissions ?? '["db:own"]',
overrides.author_pubkey ?? null,
);
async function seedPlugin(id: string, overrides: Partial<{ enabled: number; granted_permissions: string; permissions: string; trek_range: string; api_version: number; version: string; author_pubkey: string }> = {}): Promise<void> {
await insertRow(t, Plugins, {
id,
name: id,
status: 'inactive',
enabled: overrides.enabled ?? 0,
version: overrides.version ?? '1.0.0',
trek_range: overrides.trek_range ?? '>=3.0.0',
api_version: overrides.api_version ?? 1,
permissions: overrides.permissions ?? '["db:own"]',
granted_permissions: overrides.granted_permissions ?? '["db:own"]',
capabilities: '{}',
config: '{}',
author_pubkey: overrides.author_pubkey ?? null,
});
}
/** The plugin's stored row; fails the case when there is none. */
async function pluginRow(id: string) {
const row = await findRow(t, Plugins, { id });
if (!row) throw new Error(`no plugins row for ${id}`);
return row;
}
describe('Plan 3j Task 2 — R-install-gates named accept+refuse pairs (PR17/PR26/PR28)', () => {
@@ -121,7 +128,7 @@ describe('Plan 3j Task 2 — R-install-gates named accept+refuse pairs (PR17/PR2
describe('PR17 — version-range + permission-grant gate (assertActivatable)', () => {
it('INSTALL-GATE-PR17-ACCEPT: a plugin whose declared range admits this host, with no permission widening, passes the gate (activate resolves, never PluginDependencyError)', async () => {
seedPlugin('pr17-accept', { trek_range: '>=3.0.0 <99.0.0' });
await seedPlugin('pr17-accept', { trek_range: '>=3.0.0 <99.0.0' });
// The gate itself (assertActivatable, reading PR17's row through
// PluginsRepository.findActivationGate) is the thing under test — the actual
// child spawn is stubbed out (already covered end to end by
@@ -134,7 +141,7 @@ describe('Plan 3j Task 2 — R-install-gates named accept+refuse pairs (PR17/PR2
});
it('INSTALL-GATE-PR17-REFUSE: a plugin whose declared range excludes this host is refused, never spawned', async () => {
seedPlugin('pr17-refuse', { trek_range: '>=99.0.0' });
await seedPlugin('pr17-refuse', { trek_range: '>=99.0.0' });
const err = await rt.activate('pr17-refuse').catch((e) => e);
expect(err).toBeInstanceOf(PluginDependencyError);
expect(err).toMatchObject({ code: 'TREK_VERSION_INCOMPATIBLE' });
@@ -147,13 +154,13 @@ describe('Plan 3j Task 2 — R-install-gates named accept+refuse pairs (PR17/PR2
({
resolveVersion: vi.fn(async () => ({ version: '2.0.0' })),
install: vi.fn(async (id: string) => {
testDb.prepare('UPDATE plugins SET permissions = ? WHERE id = ?').run(JSON.stringify(perms), id);
await updateRows(t, Plugins, { id }, { permissions: JSON.stringify(perms) });
return { id, version: '2.0.0' };
}),
}) as unknown as import('../../../src/nest/plugins/registry/registry.service').PluginRegistryService;
it('INSTALL-GATE-PR26-ACCEPT: no new permissions on update -> activated: true, PR26 snapshot read via the repository', async () => {
seedPlugin('pr26-accept2', { enabled: 1, permissions: '["db:own"]', granted_permissions: '["db:own"]' });
await seedPlugin('pr26-accept2', { enabled: 1, permissions: '["db:own"]', granted_permissions: '["db:own"]' });
const audit = new AuditService(t.repo(AuditLog), t.repo(Users));
const addons = await createTestAddonsService(testDb);
const userSettings = new PluginUserSettingsService(t.repo(PluginSettingsFields), t.repo(PluginUserConfig));
@@ -175,7 +182,7 @@ describe('Plan 3j Task 2 — R-install-gates named accept+refuse pairs (PR17/PR2
});
it('INSTALL-GATE-PR26-REFUSE: a widened permission set on update leaves the plugin inactive with the delta reported', async () => {
seedPlugin('pr26-refuse', { enabled: 1, permissions: '["db:own"]', granted_permissions: '["db:own"]' });
await seedPlugin('pr26-refuse', { enabled: 1, permissions: '["db:own"]', granted_permissions: '["db:own"]' });
const audit = new AuditService(t.repo(AuditLog), t.repo(Users));
const addons = await createTestAddonsService(testDb);
const userSettings = new PluginUserSettingsService(t.repo(PluginSettingsFields), t.repo(PluginUserConfig));
@@ -203,13 +210,13 @@ describe('Plan 3j Task 2 — R-install-gates named accept+refuse pairs (PR17/PR2
({
assertRetrustable: vi.fn(async (_id: string, key: string) => ({ authorPublicKey: key })),
install: vi.fn(async (_id: string, opts?: { retrustKey?: string }) => {
testDb.prepare("UPDATE plugins SET author_pubkey = ?, version = '2.0.0' WHERE id = ?").run(opts?.retrustKey, id);
await updateRows(t, Plugins, { id }, { author_pubkey: opts?.retrustKey, version: '2.0.0' });
return { id, version: '2.0.0' };
}),
}) as unknown as import('../../../src/nest/plugins/registry/registry.service').PluginRegistryService;
it('INSTALL-GATE-PR28-ACCEPT: a genuinely rotated key is pinned, read through PluginsRepository.findAuthorPubkey', async () => {
seedPlugin('pr28-accept', { enabled: 1, author_pubkey: 'OLDKEY' });
await seedPlugin('pr28-accept', { enabled: 1, author_pubkey: 'OLDKEY' });
const audit = new AuditService(t.repo(AuditLog), t.repo(Users));
const addons = await createTestAddonsService(testDb);
const userSettings = new PluginUserSettingsService(t.repo(PluginSettingsFields), t.repo(PluginUserConfig));
@@ -228,12 +235,12 @@ describe('Plan 3j Task 2 — R-install-gates named accept+refuse pairs (PR17/PR2
vi.spyOn((rtR as any).supervisor, 'disable').mockResolvedValue(undefined);
await rtR.retrust('pr28-accept', '2.0.0', 'NEWKEY', { userId: 1 });
expect(registry.assertRetrustable).toHaveBeenCalledWith('pr28-accept', 'NEWKEY');
const row = testDb.prepare("SELECT author_pubkey FROM plugins WHERE id='pr28-accept'").get() as { author_pubkey: string };
const row = await pluginRow('pr28-accept');
expect(row.author_pubkey).toBe('NEWKEY');
});
it('INSTALL-GATE-PR28-REFUSE: an invalid signature is not re-trustable — the pinned key is untouched', async () => {
seedPlugin('pr28-refuse', { enabled: 1, author_pubkey: 'OLDKEY' });
await seedPlugin('pr28-refuse', { enabled: 1, author_pubkey: 'OLDKEY' });
const audit = new AuditService(t.repo(AuditLog), t.repo(Users));
const addons = await createTestAddonsService(testDb);
const userSettings = new PluginUserSettingsService(t.repo(PluginSettingsFields), t.repo(PluginUserConfig));
@@ -249,7 +256,7 @@ describe('Plan 3j Task 2 — R-install-gates named accept+refuse pairs (PR17/PR2
);
await expect(rtR.retrust('pr28-refuse', '2.0.0', 'NEWKEY', { userId: 1 })).rejects.toThrow(/nothing to re-trust/);
expect(registry.install).not.toHaveBeenCalled();
const row = testDb.prepare("SELECT author_pubkey FROM plugins WHERE id='pr28-refuse'").get() as { author_pubkey: string };
const row = await pluginRow('pr28-refuse');
expect(row.author_pubkey).toBe('OLDKEY');
});
});
@@ -259,11 +266,11 @@ describe('R-uninstall: the cascade is one transaction', () => {
it('CASCADE-PARTIAL-001: a failure partway through the cascade rolls every earlier delete back, and the data directory stays', async () => {
const rt = await buildRuntime();
const id = 'cascade-partial';
seedPlugin(id);
testDb.prepare("INSERT INTO plugin_settings_fields (plugin_id, field_key, scope) VALUES (?, 'k', 'instance')").run(id);
testDb.prepare("INSERT INTO plugin_scheduled_tasks (plugin_id, name, due_at) VALUES (?, 'poll', 0)").run(id);
testDb.prepare("INSERT INTO plugin_error_log (plugin_id, level, message) VALUES (?, 'error', 'x')").run(id);
testDb.prepare("INSERT INTO plugin_entity_metadata (plugin_id, entity_type, entity_id, key, value) VALUES (?, 'place', 1, 'k', 'v')").run(id);
await seedPlugin(id);
await insertRow(t, PluginSettingsFields, { plugin_id: id, field_key: 'k', scope: 'instance' });
await insertRow(t, PluginScheduledTasks, { plugin_id: id, name: 'poll', due_at: 0 });
await insertRow(t, PluginErrorLog, { plugin_id: id, level: 'error', message: 'x' });
await insertRow(t, PluginEntityMetadata, { plugin_id: id, entity_type: 'place', entity_id: 1, key: 'k', value: 'v' });
// Simulate a crash INSIDE the cascade, between plugin_scheduled_tasks (deletes
// cleanly, unconditional, no try/catch) and plugin_error_log (the next
@@ -278,15 +285,16 @@ describe('R-uninstall: the cascade is one transaction', () => {
await expect(rt.uninstall(id, true)).rejects.toThrow(/simulated mid-cascade crash/);
spy.mockRestore();
// Everything before the simulated crash point was rolled back with it: a half
// uninstall used to leave settings, tasks and metadata for a later plugin that
// reuses the id to inherit.
for (const table of ['plugin_settings_fields', 'plugin_scheduled_tasks', 'plugin_error_log', 'plugin_entity_metadata']) {
expect(testDb.prepare(`SELECT COUNT(*) c FROM ${table} WHERE plugin_id=?`).get(id)).toMatchObject({ c: 1 });
}
expect(testDb.prepare("SELECT COUNT(*) c FROM plugins WHERE id=?").get(id)).toMatchObject({ c: 1 });
// The data directory goes only after the rows commit.
expect(fs.existsSync(dataDir)).toBe(true);
// Everything before the simulated crash point was rolled back with it: a half
// uninstall used to leave settings, tasks and metadata for a later plugin that
// reuses the id to inherit.
expect(await countRows(t, PluginSettingsFields, { plugin_id: id })).toBe(1);
expect(await countRows(t, PluginScheduledTasks, { plugin_id: id })).toBe(1);
expect(await countRows(t, PluginErrorLog, { plugin_id: id })).toBe(1);
expect(await countRows(t, PluginEntityMetadata, { plugin_id: id })).toBe(1);
expect(await countRows(t, Plugins, { id })).toBe(1);
// The data directory goes only after the rows commit.
expect(fs.existsSync(dataDir)).toBe(true);
});
});
@@ -294,8 +302,8 @@ describe('Plan 3j Task 2 — R-uninstall\'s ONE named transaction: PR22/PR23 egr
it('EGRESS-TX-ROLLBACK-001: a simulated failure mid-write leaves the OLD egress-host set intact, not partially cleared', async () => {
const rt = await buildRuntime();
const id = 'egress-tx';
seedPlugin(id, { permissions: '["http:outbound"]', granted_permissions: '["http:outbound"]' });
testDb.prepare("UPDATE plugins SET operator_egress = 1 WHERE id = ?").run(id);
await seedPlugin(id, { permissions: '["http:outbound"]', granted_permissions: '["http:outbound"]' });
await updateRows(t, Plugins, { id }, { operator_egress: 1 });
await rt.setOperatorEgressHosts(id, ['old-a.example.com', 'old-b.example.com']);
expect((await rt.operatorEgressHosts(id)).sort()).toEqual(['old-a.example.com', 'old-b.example.com']);
@@ -329,9 +337,9 @@ describe('Plan 3j Task 2 — PS7/PS9/PS11: the triple-duplicate SELECT collapses
it('PS-USERCONFIG-PARITY-001: getUserConfig, setUserConfig\'s pre-write check and getUserConfigDecrypted all read the SAME stored row identically', async () => {
const svc = buildPluginsService();
const id = 'ps-parity';
seedPlugin(id);
testDb.prepare("INSERT INTO plugin_settings_fields (plugin_id, field_key, scope, secret) VALUES (?, 'apiKey', 'user', 1)").run(id);
testDb.prepare("INSERT INTO plugin_settings_fields (plugin_id, field_key, scope, secret) VALUES (?, 'units', 'user', 0)").run(id);
await seedPlugin(id);
await insertRow(t, PluginSettingsFields, { plugin_id: id, field_key: 'apiKey', scope: 'user', secret: 1 });
await insertRow(t, PluginSettingsFields, { plugin_id: id, field_key: 'units', scope: 'user', secret: 0 });
await svc.updateUserConfig(id, 7, { apiKey: 'sk-live', units: 'metric' });
@@ -362,8 +370,8 @@ describe('Plan 3j Task 2 — two racing ticks (scheduler claim/re-arm + erasure
it('RACE-SCHED-001: two concurrent fireDueScheduled passes deliver a due one-shot task exactly once', async () => {
const pluginId = 'race-sched';
seedPlugin(pluginId, { enabled: 1 });
testDb.prepare('INSERT INTO plugin_scheduled_tasks (plugin_id, name, due_at) VALUES (?, ?, ?)').run(pluginId, 'once', Date.now() - 1000);
await seedPlugin(pluginId, { enabled: 1 });
await insertRow(t, PluginScheduledTasks, { plugin_id: pluginId, name: 'once', due_at: Date.now() - 1000 });
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const supervisor = (rt as any).supervisor;
@@ -381,8 +389,8 @@ describe('Plan 3j Task 2 — two racing ticks (scheduler claim/re-arm + erasure
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await Promise.all([(rt as any).fireDueScheduled(), (rt as any).fireDueScheduled()]);
expect(delivered).toBe(1); // claimed (re-armed/deleted) by exactly one pass
const row = testDb.prepare('SELECT id FROM plugin_scheduled_tasks WHERE plugin_id=? AND name=?').get(pluginId, 'once');
expect(row).toBeUndefined(); // deleted, not left dangling or double-inserted
const row = await findRow(t, PluginScheduledTasks, { plugin_id: pluginId, name: 'once' });
expect(row).toBeNull(); // deleted, not left dangling or double-inserted
} finally {
invokeSpy.mockRestore();
supervisor.running.delete(pluginId);
@@ -395,9 +403,9 @@ describe('Plan 3j Task 2 — two racing ticks (scheduler claim/re-arm + erasure
// (`rearm`'s claim guard) went untested, and both mutations dropping IT survived
// (M6/M6b). Same shape as RACE-SCHED-001, on a task with `every_ms` set instead.
const pluginId = 'race-sched-recur';
seedPlugin(pluginId, { enabled: 1 });
await seedPlugin(pluginId, { enabled: 1 });
const everyMs = 60_000;
testDb.prepare('INSERT INTO plugin_scheduled_tasks (plugin_id, name, due_at, every_ms) VALUES (?, ?, ?, ?)').run(pluginId, 'recurring', Date.now() - 1000, everyMs);
await insertRow(t, PluginScheduledTasks, { plugin_id: pluginId, name: 'recurring', due_at: Date.now() - 1000, every_ms: everyMs });
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const supervisor = (rt as any).supervisor;
@@ -419,8 +427,8 @@ describe('Plan 3j Task 2 — two racing ticks (scheduler claim/re-arm + erasure
// `claimed = true` and both invoke — this is the assertion RACE-SCHED-001's own
// shape proves for the one-shot branch, now proven for the recurring one.
expect(delivered).toBe(1);
const row = testDb.prepare('SELECT due_at FROM plugin_scheduled_tasks WHERE plugin_id=? AND name=?').get(pluginId, 'recurring') as { due_at: number } | undefined;
expect(row).toBeDefined(); // a recurring task is re-armed, never deleted
const row = await findRow(t, PluginScheduledTasks, { plugin_id: pluginId, name: 'recurring' });
expect(row).not.toBeNull(); // a recurring task is re-armed, never deleted
expect(row?.due_at).toBeGreaterThanOrEqual(before + everyMs); // moved forward, not left due in the past
} finally {
invokeSpy.mockRestore();
@@ -430,8 +438,8 @@ describe('Plan 3j Task 2 — two racing ticks (scheduler claim/re-arm + erasure
it('RACE-ERASURE-001: two concurrent drains coalesce onto the SAME in-flight pass (drainUserErasures\' own promise-sharing) — one ACK, one drop', async () => {
const pluginId = 'race-erasure';
seedPlugin(pluginId, { enabled: 1 });
testDb.prepare('INSERT INTO plugin_user_erasure_queue (plugin_id, user_id) VALUES (?, ?)').run(pluginId, 99);
await seedPlugin(pluginId, { enabled: 1 });
await insertRow(t, PluginUserErasureQueue, { plugin_id: pluginId, user_id: 99 });
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const supervisor = (rt as any).supervisor;
@@ -447,8 +455,7 @@ describe('Plan 3j Task 2 — two racing ticks (scheduler claim/re-arm + erasure
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await Promise.all([(rt as any).drainUserErasures(), (rt as any).drainUserErasures()]);
expect(delivered).toBe(1); // drainUserErasures' own in-flight coalescing, unchanged by this task's conversion
const row = testDb.prepare('SELECT id FROM plugin_user_erasure_queue WHERE plugin_id=? AND user_id=?').get(pluginId, 99);
expect(row).toBeUndefined();
expect(await findRow(t, PluginUserErasureQueue, { plugin_id: pluginId, user_id: 99 })).toBeNull();
} finally {
deliverSpy.mockRestore();
supervisor.running.delete(pluginId);
@@ -27,6 +27,17 @@ import { createUser, createBucketListItem, createVisitedCountry, createTrip, cre
import { createMcpHarness, parseToolResult, parseResourceResult, type McpHarness } from '../../helpers/mcp-harness';
import { setAddonEnabled } from '../../helpers/test-db';
import { ADDON_IDS } from '../../../src/addons';
import { createTestOrm, type TestOrm } from '../../helpers/test-orm';
import { countRows, findRow, insertRow, upsertRow } from '../../helpers/factories/rows';
import { makePlace } from '../../helpers/factories/places';
import { makeTrip } from '../../helpers/factories/trips';
import { markCountryVisited } from '../../helpers/factories/atlas';
import { BucketList } from '../../../src/db/entities/BucketList.entity';
import { HiddenRegions } from '../../../src/db/entities/HiddenRegions.entity';
import { PlaceRegions } from '../../../src/db/entities/PlaceRegions.entity';
import { Places } from '../../../src/db/entities/Places.entity';
import { ReservationEndpoints } from '../../../src/db/entities/ReservationEndpoints.entity';
import { VisitedRegions } from '../../../src/db/entities/VisitedRegions.entity';
beforeEach(() => {
setAddonEnabled(testDb, ADDON_IDS.ATLAS, true);
@@ -35,7 +46,14 @@ beforeEach(() => {
delete process.env.DEMO_MODE;
});
afterAll(() => {
let orm: TestOrm;
beforeAll(async () => {
orm = await createTestOrm(testDb);
});
afterAll(async () => {
await orm.close();
testDb.close();
});
@@ -53,25 +71,32 @@ async function withResourceHarness(userId: number, fn: (h: McpHarness) => Promis
}
// A place carrying an address and coordinates, which createPlace does not write.
function insertPlace(tripId: number, name: string, address: string | null, lat: number, lng: number): number {
const r = testDb
.prepare('INSERT INTO places (trip_id, name, address, lat, lng) VALUES (?, ?, ?, ?, ?)')
.run(tripId, name, address, lat, lng);
return r.lastInsertRowid as number;
async function insertPlace(tripId: number, name: string, address: string | null, lat: number, lng: number): Promise<number> {
return (await makePlace(orm, tripId, { name, address, lat, lng, category: null })).id;
}
// The geocoder's answer for a place, pre-seeded so visitedRegions() reads the cache
// and never reaches for Nominatim (same trick as ATLAS-UNIT-020).
function cacheRegion(placeId: number, countryCode: string, regionCode: string, regionName: string): void {
testDb
.prepare('INSERT OR REPLACE INTO place_regions (place_id, country_code, region_code, region_name) VALUES (?, ?, ?, ?)')
.run(placeId, countryCode, regionCode, regionName);
async function cacheRegion(placeId: number, countryCode: string, regionCode: string, regionName: string): Promise<void> {
await upsertRow(orm, PlaceRegions, { place: placeId, country_code: countryCode, region_code: regionCode, region_name: regionName });
}
function insertEndpoint(reservationId: number, role: 'from' | 'to', sequence: number, lat: number, lng: number): void {
testDb
.prepare('INSERT INTO reservation_endpoints (reservation_id, role, sequence, name, lat, lng) VALUES (?, ?, ?, ?, ?, ?)')
.run(reservationId, role, sequence, `Endpoint ${sequence}`, lat, lng);
async function insertEndpoint(reservationId: number, role: 'from' | 'to', sequence: number, lat: number, lng: number): Promise<void> {
await insertRow(orm, ReservationEndpoints, { reservation: reservationId, role, sequence, name: `Endpoint ${sequence}`, lat, lng });
}
/** Marks a region by hand, the way the region route stores it. */
async function markRegion(userId: number, regionCode: string, regionName: string, countryCode: string): Promise<void> {
await insertRow(orm, VisitedRegions, { user: userId, region_code: regionCode, region_name: regionName, country_code: countryCode });
}
/** A wish with no coordinates; returns its id. */
async function insertWish(userId: number, name: string, targetDate?: string): Promise<number> {
return insertRow(orm, BucketList, {
user: userId,
name,
...(targetDate !== undefined ? { target_date: targetDate } : { lat: null, lng: null }),
});
}
// Trips have to sit in the past for their countries and regions to count as
@@ -105,10 +130,10 @@ describe('Tool: get_atlas_stats', () => {
it('carries the passport figures REST answers with: the cities by name and the distance flown', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Rome', start_date: PAST_START, end_date: PAST_END });
insertPlace(trip.id, 'Colosseum', 'Colosseum, Rome, Italy', 41.8902, 12.4922);
await insertPlace(trip.id, 'Colosseum', 'Colosseum, Rome, Italy', 41.8902, 12.4922);
const flight = createReservation(testDb, trip.id, { type: 'flight', title: 'FCO-JFK' });
insertEndpoint(flight.id, 'from', 0, 41.8003, 12.2389);
insertEndpoint(flight.id, 'to', 1, 40.6413, -73.7781);
await insertEndpoint(flight.id, 'from', 0, 41.8003, 12.2389);
await insertEndpoint(flight.id, 'to', 1, 40.6413, -73.7781);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'get_atlas_stats', arguments: {} });
@@ -120,8 +145,7 @@ describe('Tool: get_atlas_stats', () => {
expect(data.travel.countries).toContain('US');
// Rome to New York, so a four-figure number rather than a rounding artefact.
expect(data.travel.totalDistanceKm).toBeGreaterThan(6000);
const places = testDb.prepare('SELECT COUNT(*) AS c FROM places WHERE trip_id = ?').get(trip.id) as { c: number };
expect(data.travel.totalPlaces).toBe(places.c);
expect(data.travel.totalPlaces).toBe(await countRows(orm, Places, { trip: trip.id }));
// Rendering data stays out unless asked for.
expect(data.travel.coords).toBeUndefined();
});
@@ -130,16 +154,14 @@ describe('Tool: get_atlas_stats', () => {
it('include_coords adds the per-place coordinates the dashboard map plots', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Rome', start_date: PAST_START, end_date: PAST_END });
insertPlace(trip.id, 'Colosseum', 'Colosseum, Rome, Italy', 41.8902, 12.4922);
insertPlace(trip.id, 'Pantheon', 'Pantheon, Rome, Italy', 41.8986, 12.4769);
await insertPlace(trip.id, 'Colosseum', 'Colosseum, Rome, Italy', 41.8902, 12.4922);
await insertPlace(trip.id, 'Pantheon', 'Pantheon, Rome, Italy', 41.8986, 12.4769);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'get_atlas_stats', arguments: { include_coords: true } });
const data = parseToolResult(result) as any;
const withCoords = testDb
.prepare('SELECT COUNT(*) AS c FROM places WHERE trip_id = ? AND lat IS NOT NULL AND lng IS NOT NULL')
.get(trip.id) as { c: number };
expect(data.travel.coords).toHaveLength(withCoords.c);
const withCoords = await countRows(orm, Places, { trip: trip.id, lat: { $ne: null }, lng: { $ne: null } });
expect(data.travel.coords).toHaveLength(withCoords);
expect(data.travel.coords[0]).toEqual({ lat: 41.8902, lng: 12.4922 });
});
});
@@ -170,9 +192,7 @@ describe('Tool: list_visited_regions', () => {
it('groups a manual mark under its country, in the shape the map reads', async () => {
const { user } = createUser(testDb);
testDb.prepare(
'INSERT INTO visited_regions (user_id, region_code, region_name, country_code) VALUES (?, ?, ?, ?)'
).run(user.id, 'FR-75', 'Paris', 'FR');
await markRegion(user.id, 'FR-75', 'Paris', 'FR');
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'list_visited_regions', arguments: {} });
const data = parseToolResult(result) as any;
@@ -185,8 +205,8 @@ describe('Tool: list_visited_regions', () => {
it('reports regions derived from trip places, which the manual table never held', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Rome', start_date: PAST_START, end_date: PAST_END });
cacheRegion(insertPlace(trip.id, 'Colosseum', null, 41.8902, 12.4922), 'IT', 'IT-62', 'Lazio');
cacheRegion(insertPlace(trip.id, 'Pantheon', null, 41.8986, 12.4769), 'IT', 'IT-62', 'Lazio');
await cacheRegion(await insertPlace(trip.id, 'Colosseum', null, 41.8902, 12.4922), 'IT', 'IT-62', 'Lazio');
await cacheRegion(await insertPlace(trip.id, 'Pantheon', null, 41.8986, 12.4769), 'IT', 'IT-62', 'Lazio');
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'list_visited_regions', arguments: {} });
@@ -194,22 +214,19 @@ describe('Tool: list_visited_regions', () => {
expect(data.regions.IT).toEqual([{ code: 'IT-62', name: 'Lazio', placeCount: 2, status: 'visited' }]);
// Nothing was ever marked by hand, so the old manual-only read had no row
// to answer with at all.
const marked = testDb.prepare('SELECT COUNT(*) AS c FROM visited_regions WHERE user_id = ?').get(user.id) as { c: number };
expect(marked.c).toBe(0);
expect(await countRows(orm, VisitedRegions, { user: user.id })).toBe(0);
});
});
it('drops a region the user dismissed, matching the map', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Rome', start_date: PAST_START, end_date: PAST_END });
cacheRegion(insertPlace(trip.id, 'Colosseum', null, 41.8902, 12.4922), 'IT', 'IT-62', 'Lazio');
await cacheRegion(await insertPlace(trip.id, 'Colosseum', null, 41.8902, 12.4922), 'IT', 'IT-62', 'Lazio');
await withHarness(user.id, async (h) => {
const unmark = await h.client.callTool({ name: 'unmark_region_visited', arguments: { regionCode: 'IT-62' } });
expect(parseToolResult(unmark)).toEqual({ success: true });
const tombstone = testDb
.prepare('SELECT region_code FROM hidden_regions WHERE user_id = ? AND region_code = ?')
.get(user.id, 'IT-62');
const tombstone = await findRow(orm, HiddenRegions, { user: user.id, region_code: 'IT-62' });
expect(tombstone).toBeTruthy();
const result = await h.client.callTool({ name: 'list_visited_regions', arguments: {} });
@@ -257,10 +274,11 @@ describe('Tool: locate_atlas_region', () => {
countryCode: located.country_code,
},
});
const row = testDb
.prepare('SELECT region_code, country_code FROM visited_regions WHERE user_id = ?')
.get(user.id) as { region_code: string; country_code: string };
expect(row).toEqual({ region_code: located.region_code, country_code: 'IT' });
const row = await findRow(orm, VisitedRegions, { user: user.id });
expect({ region_code: row?.region_code, country_code: row?.country_code }).toEqual({
region_code: located.region_code,
country_code: 'IT',
});
});
});
@@ -309,8 +327,7 @@ describe('Tool: mark_region_visited', () => {
arguments: { regionCode: 'jp-13' },
});
expect(parseToolResult(unmark)).toEqual({ success: true });
const row = testDb.prepare('SELECT 1 FROM visited_regions WHERE user_id = ?').get(user.id);
expect(row).toBeUndefined();
expect(await findRow(orm, VisitedRegions, { user: user.id })).toBeNull();
});
});
@@ -328,7 +345,7 @@ describe('Tool: mark_region_visited', () => {
expect(data.region.name).toBe('California');
expect(data.region.country_code).toBe('US');
expect(data.region.manuallyMarked).toBe(true);
const row = testDb.prepare('SELECT * FROM visited_regions WHERE user_id = ? AND region_code = ?').get(user.id, 'US-CA');
const row = await findRow(orm, VisitedRegions, { user: user.id, region_code: 'US-CA' });
expect(row).toBeTruthy();
});
});
@@ -353,9 +370,7 @@ describe('Tool: mark_region_visited', () => {
describe('Tool: unmark_region_visited', () => {
it('removes region and returns success', async () => {
const { user } = createUser(testDb);
testDb.prepare(
'INSERT INTO visited_regions (user_id, region_code, region_name, country_code) VALUES (?, ?, ?, ?)'
).run(user.id, 'IT-LO', 'Lombardy', 'IT');
await markRegion(user.id, 'IT-LO', 'Lombardy', 'IT');
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'unmark_region_visited',
@@ -363,8 +378,7 @@ describe('Tool: unmark_region_visited', () => {
});
const data = parseToolResult(result) as any;
expect(data.success).toBe(true);
const row = testDb.prepare('SELECT * FROM visited_regions WHERE user_id = ? AND region_code = ?').get(user.id, 'IT-LO');
expect(row).toBeUndefined();
expect(await findRow(orm, VisitedRegions, { user: user.id, region_code: 'IT-LO' })).toBeNull();
});
});
@@ -401,9 +415,9 @@ describe('Tool: get_country_atlas_places', () => {
it('uppercases the country code like REST does (post-fold quirk fix)', async () => {
const { user } = createUser(testDb);
testDb.prepare('INSERT INTO visited_countries (user_id, country_code) VALUES (?, ?)').run(user.id, 'JP');
await markCountryVisited(orm, user.id, 'JP');
// A trip so countryPlaces reaches the visited_countries lookup pre-quirk-fix too.
testDb.prepare('INSERT INTO trips (user_id, title) VALUES (?, ?)').run(user.id, 'Japan');
await makeTrip(orm, user.id, { title: 'Japan' });
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'get_country_atlas_places',
@@ -423,10 +437,7 @@ describe('Tool: get_country_atlas_places', () => {
describe('Tool: update_bucket_list_item', () => {
it('updates notes and returns item', async () => {
const { user } = createUser(testDb);
const r = testDb.prepare(
'INSERT INTO bucket_list (user_id, name, lat, lng) VALUES (?, ?, NULL, NULL)'
).run(user.id, 'Visit Tokyo');
const itemId = r.lastInsertRowid as number;
const itemId = await insertWish(user.id, 'Visit Tokyo');
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'update_bucket_list_item',
@@ -440,10 +451,7 @@ describe('Tool: update_bucket_list_item', () => {
it('updates name of existing item', async () => {
const { user } = createUser(testDb);
const r = testDb.prepare(
'INSERT INTO bucket_list (user_id, name, lat, lng) VALUES (?, ?, NULL, NULL)'
).run(user.id, 'Old Name');
const itemId = r.lastInsertRowid as number;
const itemId = await insertWish(user.id, 'Old Name');
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'update_bucket_list_item',
@@ -467,27 +475,23 @@ describe('Tool: update_bucket_list_item', () => {
it('returns isError when the edit would land on another wish (#1898)', async () => {
const { user } = createUser(testDb);
const insert = testDb.prepare('INSERT INTO bucket_list (user_id, name, target_date) VALUES (?, ?, ?)');
insert.run(user.id, 'Japan', '2027-05');
const itemId = insert.run(user.id, 'Japan', '2028-09').lastInsertRowid as number;
await insertWish(user.id, 'Japan', '2027-05');
const itemId = await insertWish(user.id, 'Japan', '2028-09');
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'update_bucket_list_item',
arguments: { itemId, target_date: '2027-05' },
});
expect(result.isError).toBe(true);
const row = testDb.prepare('SELECT target_date FROM bucket_list WHERE id = ?').get(itemId) as { target_date: string };
expect(row.target_date).toBe('2028-09');
const row = await findRow(orm, BucketList, { id: itemId });
expect(row?.target_date).toBe('2028-09');
});
});
it('blocks demo user', async () => {
process.env.DEMO_MODE = 'true';
const { user } = createUser(testDb, { email: 'demo@nomad.app' });
const r = testDb.prepare(
'INSERT INTO bucket_list (user_id, name, lat, lng) VALUES (?, ?, NULL, NULL)'
).run(user.id, 'Bucket Item');
const itemId = r.lastInsertRowid as number;
const itemId = await insertWish(user.id, 'Bucket Item');
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'update_bucket_list_item',
@@ -529,11 +533,9 @@ describe('Resource: trek://atlas/regions', () => {
it('returns marked and derived regions together, as the tool does', async () => {
const { user } = createUser(testDb);
testDb.prepare(
'INSERT INTO visited_regions (user_id, region_code, region_name, country_code) VALUES (?, ?, ?, ?)'
).run(user.id, 'ES-CT', 'Catalonia', 'ES');
await markRegion(user.id, 'ES-CT', 'Catalonia', 'ES');
const trip = createTrip(testDb, user.id, { title: 'Rome', start_date: PAST_START, end_date: PAST_END });
cacheRegion(insertPlace(trip.id, 'Colosseum', null, 41.8902, 12.4922), 'IT', 'IT-62', 'Lazio');
await cacheRegion(await insertPlace(trip.id, 'Colosseum', null, 41.8902, 12.4922), 'IT', 'IT-62', 'Lazio');
await withResourceHarness(user.id, async (h) => {
const result = await h.client.readResource({ uri: 'trek://atlas/regions' });
+56 -25
View File
@@ -32,6 +32,12 @@ vi.mock('fs', async (importOriginal) => {
import { resetTestDb } from '../../helpers/test-db';
import { createUser, createTrip, createDay, createDayNote, createCollabNote } from '../../helpers/factories';
import { createMcpHarness, parseToolResult, parseResourceResult, type McpHarness } from '../../helpers/mcp-harness';
import { createTestOrm, type TestOrm } from '../../helpers/test-orm';
import { countRows, findRow, updateRows } from '../../helpers/factories/rows';
import { makeTripFile } from '../../helpers/factories/files';
import { CollabNotes } from '../../../src/db/entities/CollabNotes.entity';
import { DayNotes } from '../../../src/db/entities/DayNotes.entity';
import { TripFiles } from '../../../src/db/entities/TripFiles.entity';
beforeEach(() => {
resetTestDb(testDb);
@@ -40,10 +46,31 @@ beforeEach(() => {
delete process.env.DEMO_MODE;
});
afterAll(() => {
let orm: TestOrm;
beforeAll(async () => {
orm = await createTestOrm(testDb);
});
afterAll(async () => {
await orm.close();
testDb.close();
});
/** The stored day note; fails the case when it is gone. */
async function dayNote(id: number) {
const row = await findRow(orm, DayNotes, { id });
if (!row) throw new Error(`no day note ${id}`);
return row;
}
/** The stored collab note; fails the case when it is gone. */
async function collabNote(id: number) {
const row = await findRow(orm, CollabNotes, { id });
if (!row) throw new Error(`no collab note ${id}`);
return row;
}
async function withHarness(userId: number, fn: (h: McpHarness) => Promise<void>) {
const h = await createMcpHarness({ userId, withResources: false });
try { await fn(h); } finally { await h.cleanup(); }
@@ -104,7 +131,7 @@ describe('Tool: create_day_note', () => {
arguments: { tripId: trip.id, dayId: day.id, text: 'Ferry leaves early', color: '#dc2626', sort_order: 2 },
});
const data = parseToolResult(result) as any;
const row = testDb.prepare('SELECT color, sort_order FROM day_notes WHERE id = ?').get(data.note.id) as any;
const row = await dayNote(data.note.id);
expect(row.color).toBe('#dc2626');
expect(row.sort_order).toBe(2);
});
@@ -120,7 +147,7 @@ describe('Tool: create_day_note', () => {
arguments: { tripId: trip.id, dayId: day.id, text: 'A note' },
});
const data = parseToolResult(result) as any;
const row = testDb.prepare('SELECT color, sort_order FROM day_notes WHERE id = ?').get(data.note.id) as any;
const row = await dayNote(data.note.id);
expect(row.color).toBeNull();
expect(row.sort_order).toBe(9999);
});
@@ -136,7 +163,7 @@ describe('Tool: create_day_note', () => {
arguments: { tripId: trip.id, dayId: day.id, text: 'A note', color: '#ff0000' },
});
expect(result.isError).toBe(true);
expect(testDb.prepare('SELECT COUNT(*) AS n FROM day_notes WHERE day_id = ?').get(day.id)).toEqual({ n: 0 });
expect(await countRows(orm, DayNotes, { day: day.id })).toBe(0);
});
});
@@ -210,7 +237,7 @@ describe('Tool: update_day_note', () => {
name: 'update_day_note',
arguments: { tripId: trip.id, dayId: day.id, noteId: note.id, color: '#16a34a', sort_order: 1.5 },
});
const row = testDb.prepare('SELECT color, sort_order FROM day_notes WHERE id = ?').get(note.id) as any;
const row = await dayNote(note.id);
expect(row.color).toBe('#16a34a');
expect(row.sort_order).toBe(1.5);
});
@@ -221,13 +248,13 @@ describe('Tool: update_day_note', () => {
const trip = createTrip(testDb, user.id);
const day = createDay(testDb, trip.id);
const note = createDayNote(testDb, day.id, trip.id);
testDb.prepare('UPDATE day_notes SET color = ? WHERE id = ?').run('#2563eb', note.id);
await updateRows(orm, DayNotes, { id: note.id }, { color: '#2563eb' });
await withHarness(user.id, async (h) => {
await h.client.callTool({
name: 'update_day_note',
arguments: { tripId: trip.id, dayId: day.id, noteId: note.id, color: null },
});
expect((testDb.prepare('SELECT color FROM day_notes WHERE id = ?').get(note.id) as any).color).toBeNull();
expect((await dayNote(note.id)).color).toBeNull();
});
});
@@ -236,13 +263,13 @@ describe('Tool: update_day_note', () => {
const trip = createTrip(testDb, user.id);
const day = createDay(testDb, trip.id);
const note = createDayNote(testDb, day.id, trip.id, { sort_order: 3 });
testDb.prepare('UPDATE day_notes SET color = ? WHERE id = ?').run('#9333ea', note.id);
await updateRows(orm, DayNotes, { id: note.id }, { color: '#9333ea' });
await withHarness(user.id, async (h) => {
await h.client.callTool({
name: 'update_day_note',
arguments: { tripId: trip.id, dayId: day.id, noteId: note.id, text: 'Only the text changed' },
});
const row = testDb.prepare('SELECT color, sort_order FROM day_notes WHERE id = ?').get(note.id) as any;
const row = await dayNote(note.id);
expect(row.color).toBe('#9333ea');
expect(row.sort_order).toBe(3);
});
@@ -259,7 +286,7 @@ describe('Tool: update_day_note', () => {
arguments: { tripId: trip.id, dayId: day.id, noteId: note.id, color: 'red' },
});
expect(result.isError).toBe(true);
expect((testDb.prepare('SELECT color FROM day_notes WHERE id = ?').get(note.id) as any).color).toBeNull();
expect((await dayNote(note.id)).color).toBeNull();
});
});
@@ -311,7 +338,7 @@ describe('Tool: delete_day_note', () => {
const result = await h.client.callTool({ name: 'delete_day_note', arguments: { tripId: trip.id, dayId: day.id, noteId: note.id } });
const data = parseToolResult(result) as any;
expect(data.success).toBe(true);
expect(testDb.prepare('SELECT id FROM day_notes WHERE id = ?').get(note.id)).toBeUndefined();
expect(await findRow(orm, DayNotes, { id: note.id })).toBeNull();
});
});
@@ -469,7 +496,7 @@ describe('Tool: create_collab_note', () => {
});
const data = parseToolResult(result) as any;
expect(data.note.website).toBe('https://example.com/tour');
expect((testDb.prepare('SELECT website FROM collab_notes WHERE id = ?').get(data.note.id) as any).website)
expect((await collabNote(data.note.id)).website)
.toBe('https://example.com/tour');
});
});
@@ -480,7 +507,7 @@ describe('Tool: create_collab_note', () => {
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'create_collab_note', arguments: { tripId: trip.id, title: 'Plain note' } });
const data = parseToolResult(result) as any;
expect((testDb.prepare('SELECT website FROM collab_notes WHERE id = ?').get(data.note.id) as any).website).toBeNull();
expect((await collabNote(data.note.id)).website).toBeNull();
});
});
@@ -534,7 +561,7 @@ describe('Tool: update_collab_note', () => {
name: 'update_collab_note',
arguments: { tripId: trip.id, noteId: note.id, website: 'https://example.com/museum' },
});
expect((testDb.prepare('SELECT website FROM collab_notes WHERE id = ?').get(note.id) as any).website)
expect((await collabNote(note.id)).website)
.toBe('https://example.com/museum');
});
});
@@ -543,10 +570,10 @@ describe('Tool: update_collab_note', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const note = createCollabNote(testDb, trip.id, user.id);
testDb.prepare('UPDATE collab_notes SET website = ? WHERE id = ?').run('https://example.com/old', note.id);
await updateRows(orm, CollabNotes, { id: note.id }, { website: 'https://example.com/old' });
await withHarness(user.id, async (h) => {
await h.client.callTool({ name: 'update_collab_note', arguments: { tripId: trip.id, noteId: note.id, website: null } });
expect((testDb.prepare('SELECT website FROM collab_notes WHERE id = ?').get(note.id) as any).website).toBeNull();
expect((await collabNote(note.id)).website).toBeNull();
});
});
@@ -554,10 +581,10 @@ describe('Tool: update_collab_note', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const note = createCollabNote(testDb, trip.id, user.id);
testDb.prepare('UPDATE collab_notes SET website = ? WHERE id = ?').run('https://example.com/keep', note.id);
await updateRows(orm, CollabNotes, { id: note.id }, { website: 'https://example.com/keep' });
await withHarness(user.id, async (h) => {
await h.client.callTool({ name: 'update_collab_note', arguments: { tripId: trip.id, noteId: note.id, title: 'Renamed' } });
expect((testDb.prepare('SELECT website FROM collab_notes WHERE id = ?').get(note.id) as any).website)
expect((await collabNote(note.id)).website)
.toBe('https://example.com/keep');
});
});
@@ -572,7 +599,7 @@ describe('Tool: update_collab_note', () => {
arguments: { tripId: trip.id, noteId: note.id, website: `https://example.com/${'x'.repeat(600)}` },
});
expect(result.isError).toBe(true);
expect((testDb.prepare('SELECT website FROM collab_notes WHERE id = ?').get(note.id) as any).website).toBeNull();
expect((await collabNote(note.id)).website).toBeNull();
});
});
@@ -620,7 +647,7 @@ describe('Tool: delete_collab_note', () => {
const result = await h.client.callTool({ name: 'delete_collab_note', arguments: { tripId: trip.id, noteId: note.id } });
const data = parseToolResult(result) as any;
expect(data.success).toBe(true);
expect(testDb.prepare('SELECT id FROM collab_notes WHERE id = ?').get(note.id)).toBeUndefined();
expect(await findRow(orm, CollabNotes, { id: note.id })).toBeNull();
});
});
@@ -629,9 +656,13 @@ describe('Tool: delete_collab_note', () => {
const trip = createTrip(testDb, user.id);
const note = createCollabNote(testDb, trip.id, user.id);
// Insert a trip_file linked to this note
testDb.prepare(
`INSERT INTO trip_files (trip_id, note_id, filename, original_name, mime_type, file_size) VALUES (?, ?, ?, ?, ?, ?)`
).run(trip.id, note.id, 'test-file.pdf', 'document.pdf', 'application/pdf', 1024);
await makeTripFile(orm, trip.id, {
note: note.id,
filename: 'test-file.pdf',
original_name: 'document.pdf',
mime_type: 'application/pdf',
file_size: 1024,
});
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'delete_collab_note', arguments: { tripId: trip.id, noteId: note.id } });
@@ -639,9 +670,9 @@ describe('Tool: delete_collab_note', () => {
});
// trip_files rows are deleted as part of the transaction
expect(testDb.prepare('SELECT id FROM trip_files WHERE note_id = ?').all(note.id)).toHaveLength(0);
expect(await countRows(orm, TripFiles, { note: note.id })).toBe(0);
// note itself is deleted
expect(testDb.prepare('SELECT id FROM collab_notes WHERE id = ?').get(note.id)).toBeUndefined();
expect(await findRow(orm, CollabNotes, { id: note.id })).toBeNull();
});
it('broadcasts collab:note:deleted event', async () => {
+167 -114
View File
@@ -11,16 +11,8 @@ import { describe, it, expect, vi, beforeAll, beforeEach, afterAll, afterEach }
vi.mock('../../../src/db/database', async () => {
const { createSnapshotTestDb } = await import('../../helpers/db-mock');
const db = createSnapshotTestDb();
return {
db,
closeDb: () => {},
reinitialize: () => {},
getPlaceWithTags: () => null,
canAccessTrip: (tripId: number | string, 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: number | string, userId: number) =>
!!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId),
};
// Trip access reads through TripsRepository now; the module only hands out the handle.
return { db, closeDb: () => {}, reinitialize: () => {} };
});
vi.mock('../../../src/config', () => ({
JWT_SECRET: 'test-secret',
@@ -56,6 +48,19 @@ import {
createTestJourneyPhotosRepo, createTestJourneyEntryPhotosRepo,
} from '../../helpers/journey-repos';
import { createTestToursRepo } from '../../helpers/tours-repos';
import type { EntityClass, FilterQuery } from '@mikro-orm/core';
import { countRows, deleteRows, findRow, findRows, insertRow } from '../../helpers/factories/rows';
import { makeReservation } from '../../helpers/factories/reservations';
import { makeBudgetItem } from '../../helpers/factories/budget';
import { BudgetItems } from '../../../src/db/entities/BudgetItems.entity';
import { DayAccommodations } from '../../../src/db/entities/DayAccommodations.entity';
import { DayAssignments } from '../../../src/db/entities/DayAssignments.entity';
import { Days } from '../../../src/db/entities/Days.entity';
import { Places } from '../../../src/db/entities/Places.entity';
import { Reservations } from '../../../src/db/entities/Reservations.entity';
import { ReservationEndpoints } from '../../../src/db/entities/ReservationEndpoints.entity';
import { RoadtripDayBoundaries } from '../../../src/db/entities/RoadtripDayBoundaries.entity';
import { Trips } from '../../../src/db/entities/Trips.entity';
let days: DaysService;
let journey: JourneyDomainService;
@@ -128,27 +133,44 @@ afterAll(() => {
type Row = { id: number; day_number: number; date: string | null };
const dayRows = (tripId: number): Row[] =>
testDb.prepare('SELECT id, day_number, date FROM days WHERE trip_id = ? ORDER BY day_number').all(tripId) as Row[];
const orm = () => sharedTestOrm(testDb);
const range = (tripId: number) =>
testDb.prepare('SELECT start_date, end_date FROM trips WHERE id = ?').get(tripId) as { start_date: string | null; end_date: string | null };
const dayRows = async (tripId: number): Promise<Row[]> =>
(await findRows(await orm(), Days, { trip: tripId }, { day_number: 'asc' })).map((d) => ({
id: d.id,
day_number: d.day_number,
date: d.date ?? null,
}));
const reservationRow = (id: number) =>
testDb.prepare('SELECT day_id, end_day_id, reservation_time, reservation_end_time FROM reservations WHERE id = ?').get(id) as
{ day_id: number | null; end_day_id: number | null; reservation_time: string | null; reservation_end_time: string | null } | undefined;
const range = async (tripId: number) => {
const trip = await findRow(await orm(), Trips, { id: tripId });
if (!trip) throw new Error(`no trip ${tripId}`);
return { start_date: trip.start_date ?? null, end_date: trip.end_date ?? null };
};
function booking(tripId: number, dayId: number | null, time: string | null, extra: { endDayId?: number; endTime?: string } = {}): number {
return Number(testDb.prepare(
'INSERT INTO reservations (trip_id, day_id, end_day_id, title, type, reservation_time, reservation_end_time) VALUES (?, ?, ?, ?, ?, ?, ?)',
).run(tripId, dayId, extra.endDayId ?? null, 'Booking', 'restaurant', time, extra.endTime ?? null).lastInsertRowid);
/** The booking's day links and times, or undefined once it is gone. */
const reservationRow = async (id: number) => {
const r = await findRow(await orm(), Reservations, { id });
if (!r) return undefined;
return { day_id: r.day_id ?? null, end_day_id: r.end_day_id ?? null, reservation_time: r.reservation_time ?? null, reservation_end_time: r.reservation_end_time ?? null };
};
async function booking(tripId: number, dayId: number | null, time: string | null, extra: { endDayId?: number; endTime?: string } = {}): Promise<number> {
return (await makeReservation(await orm(), tripId, {
day: dayId,
endDay: extra.endDayId ?? null,
title: 'Booking',
type: 'restaurant',
reservation_time: time,
reservation_end_time: extra.endTime ?? null,
})).id;
}
/** A dated trip whose days the factory generated, one per date. */
function datedTrip(start: string, end: string) {
async function datedTrip(start: string, end: string) {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { start_date: start, end_date: end });
return { user, trip, rows: dayRows(trip.id) };
return { user, trip, rows: await (await dayRows(trip.id)) };
}
/** A booked night: the stay, its hotel booking, the stop on its check-in day and an expense. */
@@ -156,18 +178,44 @@ async function bookedNight(tripId: number, startDayId: number, endDayId: number)
const place = createPlace(testDb, tripId, { name: 'Harbour Hotel' });
const { accommodation } = await accommodations.createAccommodation(tripId, { place_id: place.id, start_day_id: startDayId, end_day_id: endDayId });
const stayId = (accommodation as { id: number }).id;
const reservationId = (testDb.prepare('SELECT id FROM reservations WHERE accommodation_id = ?').get(stayId) as { id: number }).id;
const budgetId = Number(testDb.prepare(
'INSERT INTO budget_items (trip_id, name, category, total_price, reservation_id) VALUES (?, ?, ?, ?, ?)',
).run(tripId, 'Harbour Hotel', 'Accommodation', 240, reservationId).lastInsertRowid);
const stop = testDb.prepare('SELECT id, day_id FROM day_assignments WHERE accommodation_id = ?').get(stayId) as { id: number; day_id: number };
const hotelBooking = await findRow(await orm(), Reservations, { accommodation_id: String(stayId) });
if (!hotelBooking) throw new Error('createAccommodation should have booked the stay');
const reservationId = hotelBooking.id;
const budgetId = (await makeBudgetItem(await orm(), tripId, {
name: 'Harbour Hotel',
category: 'Accommodation',
total_price: 240,
reservation: reservationId,
})).id;
const stopRow = await findRow(await orm(), DayAssignments, { accommodation_id: stayId });
if (!stopRow) throw new Error('createAccommodation should have put a stop on the check-in day');
const stop = { id: stopRow.id, day_id: stopRow.day_id };
return { place, stayId, reservationId, budgetId, stop };
}
function boundary(tripId: number, dayNumber: number, fromAssignmentId: number) {
testDb.prepare(
'INSERT INTO roadtrip_day_boundaries (trip_id, day_number, from_assignment_id, to_assignment_id, fraction) VALUES (?, ?, ?, NULL, 1)',
).run(tripId, dayNumber, fromAssignmentId);
async function boundary(tripId: number, dayNumber: number, fromAssignmentId: number): Promise<void> {
await insertRow(await orm(), RoadtripDayBoundaries, {
trip: tripId,
day_number: dayNumber,
fromAssignment: fromAssignmentId,
toAssignment: null,
fraction: 1,
});
}
/** The trip's road trip boundaries in day order, as the service hands them back. */
async function storedBoundaries(tripId: number) {
return (await findRows(await orm(), RoadtripDayBoundaries, { trip: tripId }, { day_number: 'asc' })).map((b) => ({
day_number: b.day_number,
from_assignment_id: b.from_assignment_id,
to_assignment_id: b.to_assignment_id ?? null,
fraction: b.fraction,
}));
}
/** Whether a row with the id is still stored. */
async function exists<T extends { id: number }>(entity: EntityClass<T>, id: number): Promise<boolean> {
return (await countRows(await orm(), entity, { id } as FilterQuery<T>)) > 0;
}
describe('DayRemovalService.remove', () => {
@@ -178,129 +226,136 @@ describe('DayRemovalService.remove', () => {
const result = await removal.remove(dateless.id, b.id, { userId: user.id });
expect(dayRows(dateless.id)).toEqual([
expect(await dayRows(dateless.id)).toEqual([
{ id: a.id, day_number: 1, date: null },
{ id: c.id, day_number: 2, date: null },
]);
expect(result).toMatchObject({ dayId: b.id, orderedIds: [a.id, c.id], endDate: null, boundaries: null, stayIds: [] });
// The extra day of a dated trip, the case the dialog was asked for: no date moves.
const dated = datedTrip('2026-03-01', '2026-03-02');
const dated = await datedTrip('2026-03-01', '2026-03-02');
const spare = createDay(testDb, dated.trip.id);
const dinner = booking(dated.trip.id, dated.rows[1].id, '2026-03-02T19:00');
const dinner = await booking(dated.trip.id, dated.rows[1].id, '2026-03-02T19:00');
await removal.remove(dated.trip.id, spare.id, { userId: dated.user.id });
expect(dayRows(dated.trip.id)).toEqual(dated.rows);
expect(range(dated.trip.id)).toEqual({ start_date: '2026-03-01', end_date: '2026-03-02' });
expect(reservationRow(dinner)?.reservation_time).toBe('2026-03-02T19:00');
expect(await dayRows(dated.trip.id)).toEqual(dated.rows);
expect(await range(dated.trip.id)).toEqual({ start_date: '2026-03-01', end_date: '2026-03-02' });
expect((await reservationRow(dinner))?.reservation_time).toBe('2026-03-02T19:00');
});
it('DAY-DEL-002 a dated day with a spare day after it: the dates stay on their slots and the bookings move along', async () => {
const { user, trip, rows } = datedTrip('2026-01-01', '2026-01-03');
const { user, trip, rows } = await datedTrip('2026-01-01', '2026-01-03');
const [d1, d2, d3] = rows;
const spare = createDay(testDb, trip.id);
const flight = booking(trip.id, d3.id, '2026-01-03T10:00', { endDayId: d3.id, endTime: '2026-01-03T12:00' });
const leg = Number(testDb.prepare(
'INSERT INTO reservation_endpoints (reservation_id, role, sequence, name, lat, lng, local_date) VALUES (?, ?, ?, ?, ?, ?, ?)',
).run(flight, 'from', 0, 'HEL', 60.31, 24.96, '2026-01-03').lastInsertRowid);
const flight = await booking(trip.id, d3.id, '2026-01-03T10:00', { endDayId: d3.id, endTime: '2026-01-03T12:00' });
const leg = await insertRow(await orm(), ReservationEndpoints, {
reservation: flight,
role: 'from',
sequence: 0,
name: 'HEL',
lat: 60.31,
lng: 24.96,
local_date: '2026-01-03',
});
const result = await removal.remove(trip.id, d2.id, { userId: user.id });
expect(dayRows(trip.id)).toEqual([
expect(await dayRows(trip.id)).toEqual([
{ id: d1.id, day_number: 1, date: '2026-01-01' },
{ id: d3.id, day_number: 2, date: '2026-01-02' },
{ id: spare.id, day_number: 3, date: '2026-01-03' },
]);
expect(reservationRow(flight)).toMatchObject({ reservation_time: '2026-01-02T10:00', reservation_end_time: '2026-01-02T12:00' });
expect(testDb.prepare('SELECT local_date FROM reservation_endpoints WHERE id = ?').get(leg)).toEqual({ local_date: '2026-01-02' });
expect(await reservationRow(flight)).toMatchObject({ reservation_time: '2026-01-02T10:00', reservation_end_time: '2026-01-02T12:00' });
expect((await findRow(await orm(), ReservationEndpoints, { id: leg }))?.local_date).toBe('2026-01-02');
// The spare day took the last date, so the trip keeps its range.
expect(range(trip.id)).toEqual({ start_date: '2026-01-01', end_date: '2026-01-03' });
expect(await range(trip.id)).toEqual({ start_date: '2026-01-01', end_date: '2026-01-03' });
expect(result.endDate).toBeNull();
});
it('DAY-DEL-003 a dated day with no spare day left: the last date goes and the trip ends a day earlier', async () => {
const { user, trip, rows } = datedTrip('2026-01-01', '2026-01-03');
const { user, trip, rows } = await datedTrip('2026-01-01', '2026-01-03');
const [d1, d2, d3] = rows;
const result = await removal.remove(trip.id, d1.id, { userId: user.id });
expect(dayRows(trip.id)).toEqual([
expect(await dayRows(trip.id)).toEqual([
{ id: d2.id, day_number: 1, date: '2026-01-01' },
{ id: d3.id, day_number: 2, date: '2026-01-02' },
]);
expect(range(trip.id)).toEqual({ start_date: '2026-01-01', end_date: '2026-01-02' });
expect(await range(trip.id)).toEqual({ start_date: '2026-01-01', end_date: '2026-01-02' });
expect(result.endDate).toBe('2026-01-02');
// The trip in list shape for the trip header, without its feed credential.
expect(result.trip).toMatchObject({ id: trip.id, end_date: '2026-01-02', day_count: 2, is_owner: 1, feed_token: null });
});
it('DAY-DEL-004 the last day of a trip stays, and nothing is written', async () => {
const { user, trip, rows } = datedTrip('2026-05-01', '2026-05-01');
const { user, trip, rows } = await datedTrip('2026-05-01', '2026-05-01');
const night = await bookedNight(trip.id, rows[0].id, rows[0].id);
await expect(removal.remove(trip.id, rows[0].id, { userId: user.id })).rejects.toThrow(new DayDeleteError(LAST_DAY_MESSAGE));
expect(dayRows(trip.id)).toEqual(rows);
expect(testDb.prepare('SELECT id FROM day_accommodations WHERE id = ?').get(night.stayId)).toBeDefined();
expect(reservationRow(night.reservationId)).toBeDefined();
expect(await dayRows(trip.id)).toEqual(rows);
expect(await exists(DayAccommodations, night.stayId)).toBe(true);
expect(await reservationRow(night.reservationId)).toBeDefined();
});
it('DAY-DEL-005 a stay checking in on the day is cancelled with its booking and that booking\'s expense', async () => {
const { user, trip, rows } = datedTrip('2026-01-01', '2026-01-03');
const { user, trip, rows } = await datedTrip('2026-01-01', '2026-01-03');
const night = await bookedNight(trip.id, rows[1].id, rows[2].id);
expect(night.stop.day_id).toBe(rows[1].id);
const result = await removal.remove(trip.id, rows[1].id, { userId: user.id });
expect(testDb.prepare('SELECT id FROM day_accommodations WHERE id = ?').get(night.stayId)).toBeUndefined();
expect(reservationRow(night.reservationId)).toBeUndefined();
expect(testDb.prepare('SELECT id FROM budget_items WHERE id = ?').get(night.budgetId)).toBeUndefined();
expect(await exists(DayAccommodations, night.stayId)).toBe(false);
expect(await reservationRow(night.reservationId)).toBeUndefined();
expect(await exists(BudgetItems, night.budgetId)).toBe(false);
// The hotel itself is a place and stays on the list.
expect(testDb.prepare('SELECT id FROM places WHERE id = ?').get(night.place.id)).toBeDefined();
expect(await exists(Places, night.place.id)).toBe(true);
expect(result).toMatchObject({ stayIds: [night.stayId], reservationIds: [night.reservationId], budgetItemIds: [night.budgetId] });
// Its stop sat on the deleted day, which day:deleted already covers.
expect(result.mirrors[0].removed).toEqual([]);
});
it('DAY-DEL-006 a stay checking out on the day is cancelled too, and its stop on the check-in day goes with it', async () => {
const { user, trip, rows } = datedTrip('2026-01-01', '2026-01-03');
const { user, trip, rows } = await datedTrip('2026-01-01', '2026-01-03');
const night = await bookedNight(trip.id, rows[0].id, rows[1].id);
const result = await removal.remove(trip.id, rows[1].id, { userId: user.id });
expect(testDb.prepare('SELECT id FROM day_accommodations WHERE id = ?').get(night.stayId)).toBeUndefined();
expect(reservationRow(night.reservationId)).toBeUndefined();
expect(testDb.prepare('SELECT id FROM day_assignments WHERE id = ?').get(night.stop.id)).toBeUndefined();
expect(await exists(DayAccommodations, night.stayId)).toBe(false);
expect(await reservationRow(night.reservationId)).toBeUndefined();
expect(await exists(DayAssignments, night.stop.id)).toBe(false);
expect(result.mirrors[0].removed).toEqual([{ id: night.stop.id, dayId: rows[0].id }]);
});
it('DAY-DEL-007 a stay that only runs across the day keeps standing', async () => {
const { user, trip, rows } = datedTrip('2026-01-01', '2026-01-03');
const { user, trip, rows } = await datedTrip('2026-01-01', '2026-01-03');
const night = await bookedNight(trip.id, rows[0].id, rows[2].id);
const result = await removal.remove(trip.id, rows[1].id, { userId: user.id });
expect(testDb.prepare('SELECT start_day_id, end_day_id FROM day_accommodations WHERE id = ?').get(night.stayId))
const stay = await findRow(await orm(), DayAccommodations, { id: night.stayId });
expect({ start_day_id: stay?.start_day_id, end_day_id: stay?.end_day_id })
.toEqual({ start_day_id: rows[0].id, end_day_id: rows[2].id });
expect(reservationRow(night.reservationId)).toBeDefined();
expect(await reservationRow(night.reservationId)).toBeDefined();
expect(result.stayIds).toEqual([]);
});
it('DAY-DEL-008 bookings on the day are let go of, with their dates untouched', async () => {
const { user, trip, rows } = datedTrip('2026-01-01', '2026-01-03');
const { user, trip, rows } = await datedTrip('2026-01-01', '2026-01-03');
const [d1, d2] = rows;
const dinner = booking(trip.id, d2.id, '2026-01-02T19:00');
const car = booking(trip.id, d1.id, '2026-01-01T09:00', { endDayId: d2.id, endTime: '2026-01-02T10:00' });
const dinner = await booking(trip.id, d2.id, '2026-01-02T19:00');
const car = await booking(trip.id, d1.id, '2026-01-01T09:00', { endDayId: d2.id, endTime: '2026-01-02T10:00' });
await removal.remove(trip.id, d2.id, { userId: user.id });
expect(reservationRow(dinner)).toEqual({ day_id: null, end_day_id: null, reservation_time: '2026-01-02T19:00', reservation_end_time: null });
expect(reservationRow(car)).toEqual({ day_id: d1.id, end_day_id: null, reservation_time: '2026-01-01T09:00', reservation_end_time: '2026-01-02T10:00' });
expect(await reservationRow(dinner)).toEqual({ day_id: null, end_day_id: null, reservation_time: '2026-01-02T19:00', reservation_end_time: null });
expect(await reservationRow(car)).toEqual({ day_id: d1.id, end_day_id: null, reservation_time: '2026-01-01T09:00', reservation_end_time: '2026-01-02T10:00' });
});
it('DAY-DEL-009 the road trip boundaries after the day move up with their days', async () => {
const { user, trip, rows } = datedTrip('2026-01-01', '2026-01-04');
const { user, trip, rows } = await datedTrip('2026-01-01', '2026-01-04');
const place = createPlace(testDb, trip.id);
const stops = rows.map(r => createDayAssignment(testDb, r.id, place.id));
rows.forEach((_, i) => boundary(trip.id, i + 1, stops[i].id));
for (const [i] of rows.entries()) await boundary(trip.id, i + 1, stops[i].id);
const result = await removal.remove(trip.id, rows[1].id, { userId: user.id });
@@ -309,76 +364,75 @@ describe('DayRemovalService.remove', () => {
{ day_number: 2, from_assignment_id: stops[2].id, to_assignment_id: null, fraction: 1 },
{ day_number: 3, from_assignment_id: stops[3].id, to_assignment_id: null, fraction: 1 },
];
expect(testDb.prepare('SELECT day_number, from_assignment_id, to_assignment_id, fraction FROM roadtrip_day_boundaries WHERE trip_id = ? ORDER BY day_number').all(trip.id))
.toEqual(expected);
expect(await storedBoundaries(trip.id)).toEqual(expected);
expect(result.boundaries).toEqual(expected);
});
it('DAY-DEL-010 deleting the day an insert slotted in gives back the trip as it was', async () => {
const { user, trip, rows } = datedTrip('2026-01-01', '2026-01-03');
const train = booking(trip.id, rows[2].id, '2026-01-03T08:00');
const before = { rows: dayRows(trip.id), range: range(trip.id), train: reservationRow(train) };
const { user, trip, rows } = await datedTrip('2026-01-01', '2026-01-03');
const train = await booking(trip.id, rows[2].id, '2026-01-03T08:00');
const before = { rows: (await dayRows(trip.id)), range: (await range(trip.id)), train: (await reservationRow(train)) };
const inserted = await days.insert(trip.id, 2);
expect(range(trip.id).end_date).toBe('2026-01-04');
expect((await range(trip.id)).end_date).toBe('2026-01-04');
await removal.remove(trip.id, inserted.id, { userId: user.id });
expect({ rows: dayRows(trip.id), range: range(trip.id), train: reservationRow(train) }).toEqual(before);
expect({ rows: (await dayRows(trip.id)), range: (await range(trip.id)), train: (await reservationRow(train)) }).toEqual(before);
});
it('DAY-DEL-011 appending a day, moving it into a slot and deleting the day it pushed out keeps every date', async () => {
const { user, trip, rows } = datedTrip('2026-01-01', '2026-01-03');
const { user, trip, rows } = await datedTrip('2026-01-01', '2026-01-03');
const [d1, d2, d3] = rows;
const lunch = booking(trip.id, d2.id, '2026-01-02T12:00');
const tour = booking(trip.id, d3.id, '2026-01-03T09:00');
const lunch = await booking(trip.id, d2.id, '2026-01-02T12:00');
const tour = await booking(trip.id, d3.id, '2026-01-03T09:00');
const appended = await days.create(trip.id);
await days.reorder(trip.id, [d1.id, d2.id, appended.id, d3.id]);
// The pushed-out day lost its date to the new one and sits at the end without one.
expect(dayRows(trip.id).at(-1)).toEqual({ id: d3.id, day_number: 4, date: null });
expect((await dayRows(trip.id)).at(-1)).toEqual({ id: d3.id, day_number: 4, date: null });
await removal.remove(trip.id, d3.id, { userId: user.id });
expect(dayRows(trip.id)).toEqual([
expect(await dayRows(trip.id)).toEqual([
{ id: d1.id, day_number: 1, date: '2026-01-01' },
{ id: d2.id, day_number: 2, date: '2026-01-02' },
{ id: appended.id, day_number: 3, date: '2026-01-03' },
]);
expect(range(trip.id)).toEqual({ start_date: '2026-01-01', end_date: '2026-01-03' });
expect(reservationRow(lunch)?.reservation_time).toBe('2026-01-02T12:00');
expect(reservationRow(tour)?.reservation_time).toBe('2026-01-03T09:00');
expect(await range(trip.id)).toEqual({ start_date: '2026-01-01', end_date: '2026-01-03' });
expect((await reservationRow(lunch))?.reservation_time).toBe('2026-01-02T12:00');
expect((await reservationRow(tour))?.reservation_time).toBe('2026-01-03T09:00');
});
it('DAY-DEL-012 a failure halfway rolls the whole delete back', async () => {
const { user, trip, rows } = datedTrip('2026-01-01', '2026-01-03');
const { user, trip, rows } = await datedTrip('2026-01-01', '2026-01-03');
const night = await bookedNight(trip.id, rows[1].id, rows[2].id);
vi.spyOn(days, 'restampReservationDates').mockImplementation(() => { throw new Error('disk full'); });
await expect(removal.remove(trip.id, rows[1].id, { userId: user.id })).rejects.toThrow('disk full');
expect(dayRows(trip.id)).toEqual(rows);
expect(testDb.prepare('SELECT id FROM day_accommodations WHERE id = ?').get(night.stayId)).toBeDefined();
expect(reservationRow(night.reservationId)).toBeDefined();
expect(testDb.prepare('SELECT id FROM budget_items WHERE id = ?').get(night.budgetId)).toBeDefined();
expect(await dayRows(trip.id)).toEqual(rows);
expect(await exists(DayAccommodations, night.stayId)).toBe(true);
expect(await reservationRow(night.reservationId)).toBeDefined();
expect(await exists(BudgetItems, night.budgetId)).toBe(true);
});
it('DAY-DEL-013 the journey catches up after the commit, and a failure there does not undo the delete', async () => {
const { user, trip, rows } = datedTrip('2026-01-01', '2026-01-02');
const { user, trip, rows } = await datedTrip('2026-01-01', '2026-01-02');
const reconcile = vi.spyOn(journey, 'reconcileTripSkeletons').mockImplementation(() => { throw new Error('journey down'); });
const result = await removal.remove(trip.id, rows[0].id, { userId: user.id, socketId: 'sock-1' });
expect(reconcile).toHaveBeenCalledWith(trip.id, 'sock-1');
expect(result.orderedIds).toEqual([rows[1].id]);
expect(dayRows(trip.id)).toHaveLength(1);
expect(await dayRows(trip.id)).toHaveLength(1);
});
it('DAY-DEL-015 a day of another trip is refused, and nothing is written', async () => {
const { user, trip, rows } = datedTrip('2026-01-01', '2026-01-02');
const other = datedTrip('2026-02-01', '2026-02-02');
const { user, trip, rows } = await datedTrip('2026-01-01', '2026-01-02');
const other = await datedTrip('2026-02-01', '2026-02-02');
await expect(removal.remove(trip.id, other.rows[0].id, { userId: user.id })).rejects.toThrow(new DayDeleteError('Day not found'));
expect(dayRows(trip.id)).toEqual(rows);
expect(dayRows(other.trip.id)).toEqual(other.rows);
expect(await dayRows(trip.id)).toEqual(rows);
expect(await dayRows(other.trip.id)).toEqual(other.rows);
});
it('DAY-DEL-016 dated days on a trip without a range move their dates but leave the trip alone', async () => {
@@ -389,13 +443,13 @@ describe('DayRemovalService.remove', () => {
const result = await removal.remove(trip.id, a.id, { userId: user.id });
expect(dayRows(trip.id)).toEqual([{ id: b.id, day_number: 1, date: '2026-04-01' }]);
expect(range(trip.id)).toEqual({ start_date: null, end_date: null });
expect(await dayRows(trip.id)).toEqual([{ id: b.id, day_number: 1, date: '2026-04-01' }]);
expect(await range(trip.id)).toEqual({ start_date: null, end_date: null });
expect(result.endDate).toBeNull();
});
it('DAY-DEL-018 a cancelled stay does not announce the deleted day: neither its stop there nor its roads', async () => {
const { user, trip, rows } = datedTrip('2026-01-01', '2026-01-03');
const { user, trip, rows } = await datedTrip('2026-01-01', '2026-01-03');
const place = createPlace(testDb, trip.id);
createDayAccommodation(testDb, trip.id, place.id, rows[1].id, rows[1].id);
const stub = {
@@ -419,28 +473,27 @@ describe('DayRemovalService.remove', () => {
});
it('DAY-DEL-019 a hole left in the numbering closes too, and the boundaries follow their own days into it', async () => {
const { user, trip, rows } = datedTrip('2026-01-01', '2026-01-05');
const { user, trip, rows } = await datedTrip('2026-01-01', '2026-01-05');
const place = createPlace(testDb, trip.id);
const stops = rows.map(r => createDayAssignment(testDb, r.id, place.id));
// Days 1, 2, 4 and 5: the third went through the old bare delete, which left
// its number empty and one boundary on it. Another one sits past the last day.
boundary(trip.id, 2, stops[1].id);
boundary(trip.id, 3, stops[0].id);
boundary(trip.id, 4, stops[3].id);
boundary(trip.id, 5, stops[4].id);
boundary(trip.id, 7, stops[0].id);
testDb.prepare('DELETE FROM days WHERE id = ?').run(rows[2].id);
await boundary(trip.id, 2, stops[1].id);
await boundary(trip.id, 3, stops[0].id);
await boundary(trip.id, 4, stops[3].id);
await boundary(trip.id, 5, stops[4].id);
await boundary(trip.id, 7, stops[0].id);
await deleteRows(await orm(), Days, { id: rows[2].id });
const result = await removal.remove(trip.id, rows[1].id, { userId: user.id });
expect(dayRows(trip.id).map(r => [r.id, r.day_number])).toEqual([[rows[0].id, 1], [rows[3].id, 2], [rows[4].id, 3]]);
expect((await dayRows(trip.id)).map(r => [r.id, r.day_number])).toEqual([[rows[0].id, 1], [rows[3].id, 2], [rows[4].id, 3]]);
const expected = [
{ day_number: 2, from_assignment_id: stops[3].id, to_assignment_id: null, fraction: 1 },
{ day_number: 3, from_assignment_id: stops[4].id, to_assignment_id: null, fraction: 1 },
{ day_number: 5, from_assignment_id: stops[0].id, to_assignment_id: null, fraction: 1 },
];
expect(testDb.prepare('SELECT day_number, from_assignment_id, to_assignment_id, fraction FROM roadtrip_day_boundaries WHERE trip_id = ? ORDER BY day_number').all(trip.id))
.toEqual(expected);
expect(await storedBoundaries(trip.id)).toEqual(expected);
expect(result.boundaries).toEqual(expected);
});
});
@@ -454,10 +507,10 @@ describe('DayRemovalService.announce', () => {
}
it('DAY-DEL-014 fans out in one order: the day, the new order, the stays, their rows, the boundaries, the trip', async () => {
const { user, trip, rows } = datedTrip('2026-01-01', '2026-01-03');
const { user, trip, rows } = await datedTrip('2026-01-01', '2026-01-03');
const night = await bookedNight(trip.id, rows[0].id, rows[1].id);
const place = createPlace(testDb, trip.id);
boundary(trip.id, 3, createDayAssignment(testDb, rows[2].id, place.id).id);
await boundary(trip.id, 3, createDayAssignment(testDb, rows[2].id, place.id).id);
const result = await removal.remove(trip.id, rows[1].id, { userId: user.id, socketId: 'sock' });
const mirror = vi.spyOn(accommodations, 'announceMirror');
const { sent, all, others } = recorder();
@@ -59,7 +59,16 @@ import type { DocumentProviderRegistry } from '../../../../src/nest/doc-sync/doc
// off, so the boolean discriminant stops discriminating. The domain's own
// predicate is what every call site uses instead.
import { docFailed } from '../../../../src/nest/doc-sync/document-provider';
import { createTestUnitOfWork, createTestTripsRepo } from '../../../helpers/test-uow';
import { createTestUnitOfWork, createTestTripsRepo, sharedTestOrm } from '../../../helpers/test-uow';
import { countRows, deleteRows, findRow, findRows, insertRow, updateRows } from '../../../helpers/factories/rows';
import { addTripMember } from '../../../helpers/factories/trips';
import { DocumentConnections } from '../../../../src/db/entities/DocumentConnections.entity';
import { DocumentProviders } from '../../../../src/db/entities/DocumentProviders.entity';
import { DocumentSyncItems } from '../../../../src/db/entities/DocumentSyncItems.entity';
import { TripDocumentLinks } from '../../../../src/db/entities/TripDocumentLinks.entity';
import { TripMembers } from '../../../../src/db/entities/TripMembers.entity';
const orm = () => sharedTestOrm(testDb);
import {
createTestDocumentConnectionsRepo,
createTestDocumentProviderFieldsRepo,
@@ -129,17 +138,19 @@ beforeAll(async () => {
);
});
beforeEach(() => {
beforeEach(async () => {
// The document_* tables are not in resetTestDb's list, and they reference
// trips and users, so they go first, before the rows they hang off vanish.
testDb.exec('DELETE FROM document_sync_items; DELETE FROM trip_document_links; DELETE FROM document_connections;');
await deleteRows(await orm(), DocumentSyncItems);
await deleteRows(await orm(), TripDocumentLinks);
await deleteRows(await orm(), DocumentConnections);
resetTestDb(testDb);
vi.clearAllMocks();
checkSsrf.mockResolvedValue({ allowed: true, isPrivate: false, resolvedIp: '203.0.113.10' });
OWNER = createUser(testDb, { username: 'owner', email: 'owner@test.local' }).user.id;
MEMBER = createUser(testDb, { username: 'member', email: 'member@test.local' }).user.id;
TRIP = createTrip(testDb, OWNER, { title: 'Japan' }).id;
testDb.prepare('INSERT INTO trip_members (trip_id, user_id) VALUES (?, ?)').run(TRIP, MEMBER);
await addTripMember(await orm(), TRIP, MEMBER);
});
afterAll(() => {
@@ -177,10 +188,7 @@ describe('upsertConnection secrets', () => {
const first = await connect();
const second = await connect({ baseUrl: 'https://paperless.example.com/', credentials: {} });
expect(second.id).toBe(first.id);
const count = testDb
.prepare('SELECT COUNT(*) AS n FROM document_connections WHERE trip_id = ?')
.get(TRIP) as { n: number };
expect(count.n).toBe(1);
expect(await countRows(await orm(), DocumentConnections, { trip: TRIP })).toBe(1);
});
/**
@@ -324,7 +332,7 @@ describe('publicConnection', () => {
it('reads a capabilities blob left behind by another build as unknown, not as a crash', async () => {
const created = await connect();
testDb.prepare('UPDATE document_connections SET capabilities = ? WHERE id = ?').run('{not json', created.id);
await updateRows(await orm(), DocumentConnections, { id: created.id }, { capabilities: '{not json' });
const view = await svc.publicConnection((await svc.getConnection(created.id)) as ConnectionRow);
expect(view.capabilities).toBeNull();
});
@@ -359,7 +367,7 @@ describe('a secret the provider earned itself', () => {
it('DOCSYNC-ENC-001: the raw document_connections.secrets column is enc:v1:-prefixed ciphertext, never plaintext, and round-trips', async () => {
const conn = await nas();
const raw = (testDb.prepare('SELECT secrets FROM document_connections WHERE id = ?').get(conn.id) as { secrets: string | null }).secrets;
const raw = (await findRow(await orm(), DocumentConnections, { id: conn.id }))?.secrets;
expect(raw).toMatch(/^enc:v1:/);
expect(raw).not.toContain('nas-pw');
@@ -491,7 +499,7 @@ describe('enabledProviderIds', () => {
// Every document provider ships OFF, so an empty answer on a fresh install
// is the correct one and a non-empty one would be a policy change.
expect(await svc.enabledProviderIds()).toEqual([]);
testDb.prepare("UPDATE document_providers SET enabled = 1 WHERE id IN ('nextcloud', 'paperless')").run();
await updateRows(await orm(), DocumentProviders, { id: { $in: ['nextcloud', 'paperless'] } }, { enabled: 1 });
expect(await svc.enabledProviderIds()).toEqual(['paperless', 'nextcloud']);
});
});
@@ -524,27 +532,26 @@ describe('deleteConnection', () => {
});
const doomedLink = await link(doomed.id, { scopeKey: 'tag:1' });
const keptLink = await link(kept.id, { scopeKey: 'folder:1' });
testDb
.prepare('INSERT INTO document_sync_items (link_id, trip_id, trek_doc_uid) VALUES (?, ?, ?)')
.run(doomedLink.id, TRIP, 'uid-a');
await insertRow(await orm(), DocumentSyncItems, { link: doomedLink.id, trip: TRIP, trek_doc_uid: 'uid-a' });
await svc.deleteConnection(doomed.id);
expect(await svc.getConnection(doomed.id)).toBeUndefined();
expect((await svc.listLinks(TRIP)).map((l) => l.id)).toEqual([keptLink.id]);
expect(testDb.prepare('SELECT COUNT(*) AS n FROM document_sync_items').get()).toEqual({ n: 0 });
expect(await countRows(await orm(), DocumentSyncItems)).toBe(0);
});
});
describe('toRef', () => {
it('names the row and not only its id, which a backup restored in place hands out again', async () => {
const first = await connect();
testDb.prepare("UPDATE document_connections SET created_at = '2026-09-01 08:00:00' WHERE id = ?").run(first.id);
await updateRows(await orm(), DocumentConnections, { id: first.id }, { created_at: '2026-09-01 08:00:00' });
const before = svc.toRef(await svc.getConnection(first.id) as ConnectionRow);
// What the restore of an older backup does to this table: the row is gone
// and the id sequence is back where it stood before the row was made.
testDb.prepare('DELETE FROM document_connections WHERE id = ?').run(first.id);
await deleteRows(await orm(), DocumentConnections, { id: first.id });
// test-sql-allow: sqlite_sequence is the engine's own autoincrement table, which no entity maps.
testDb.prepare("UPDATE sqlite_sequence SET seq = ? WHERE name = 'document_connections'").run(first.id - 1);
const after = svc.toRef(await connect());
@@ -586,7 +593,7 @@ describe('toScopeRef', () => {
);
expect(created.success).toBe(true);
const row = created.success ? created.data : ({} as LinkRow);
testDb.prepare('UPDATE trip_document_links SET remote_cursor = ? WHERE id = ?').run('etag-9', row.id);
await updateRows(await orm(), TripDocumentLinks, { id: row.id }, { remote_cursor: 'etag-9' });
expect(svc.toScopeRef(await svc.getLink(row.id) as LinkRow)).toEqual({
linkId: row.id,
@@ -679,7 +686,7 @@ describe('createLink', () => {
const conn = await connect();
const bound = await link(conn.id);
const raw = (testDb.prepare('SELECT webhook_secret FROM trip_document_links WHERE id = ?').get(bound.id) as { webhook_secret: string | null }).webhook_secret;
const raw = (await findRow(await orm(), TripDocumentLinks, { id: bound.id }))?.webhook_secret;
expect(raw).toMatch(/^enc:v1:/);
const secret = svc.webhookSecret(bound);
@@ -694,7 +701,7 @@ describe('publicLink', () => {
// the one place a binding left behind can still get its name from.
const conn = await connect();
const bound = await link(conn.id);
testDb.prepare("UPDATE document_providers SET enabled = 0 WHERE id = 'paperless'").run();
await updateRows(await orm(), DocumentProviders, { id: 'paperless' }, { enabled: 0 });
expect(await svc.publicLink(bound, null)).toMatchObject({ providerId: 'paperless', providerName: 'Paperless-ngx' });
});
@@ -711,18 +718,17 @@ describe('deleteLink', () => {
const conn = await connect();
const doomed = await link(conn.id, { scopeKey: 'tag:1' });
const kept = await link(conn.id, { scopeKey: 'tag:2' });
const insert = testDb.prepare(
'INSERT INTO document_sync_items (link_id, trip_id, trek_doc_uid) VALUES (?, ?, ?)',
);
insert.run(doomed.id, TRIP, 'uid-a');
insert.run(doomed.id, TRIP, 'uid-b');
insert.run(kept.id, TRIP, 'uid-c');
const insert = async (linkId: number, trekDocUid: string) =>
insertRow(await orm(), DocumentSyncItems, { link: linkId, trip: TRIP, trek_doc_uid: trekDocUid });
await insert(doomed.id, 'uid-a');
await insert(doomed.id, 'uid-b');
await insert(kept.id, 'uid-c');
await svc.deleteLink(doomed.id);
expect(await svc.getLink(doomed.id)).toBeUndefined();
expect(await svc.getLink(kept.id)).toBeDefined();
const rows = testDb.prepare('SELECT link_id FROM document_sync_items').all() as { link_id: number }[];
const rows = await findRows(await orm(), DocumentSyncItems);
expect(rows.map((r) => r.link_id)).toEqual([kept.id]);
});
@@ -743,7 +749,7 @@ describe('markOrphanedLinks', () => {
it('disables the binding whose credential owner left the trip', async () => {
const orphan = await bind(MEMBER, 'paperless');
testDb.prepare('DELETE FROM trip_members WHERE trip_id = ? AND user_id = ?').run(TRIP, MEMBER);
await deleteRows(await orm(), TripMembers, { trip: TRIP, user: MEMBER });
expect(await svc.markOrphanedLinks()).toBe(1);
expect(await svc.getLink(orphan.id)).toMatchObject({ last_sync_state: 'orphaned', sync_enabled: 0 });
@@ -761,7 +767,7 @@ describe('markOrphanedLinks', () => {
it('leaves a still-valid binding alone while orphaning the one beside it', async () => {
const orphan = await bind(MEMBER, 'paperless');
const kept = await bind(OWNER, 'nextcloud');
testDb.prepare('DELETE FROM trip_members WHERE trip_id = ? AND user_id = ?').run(TRIP, MEMBER);
await deleteRows(await orm(), TripMembers, { trip: TRIP, user: MEMBER });
expect(await svc.markOrphanedLinks()).toBe(1);
expect((await svc.getLink(orphan.id))?.last_sync_state).toBe('orphaned');
@@ -808,7 +814,7 @@ describe('markOrphanedLinks', () => {
it('counts nothing on a second sweep, so the job stays quiet after the first one', async () => {
await bind(MEMBER, 'paperless');
testDb.prepare('DELETE FROM trip_members WHERE trip_id = ? AND user_id = ?').run(TRIP, MEMBER);
await deleteRows(await orm(), TripMembers, { trip: TRIP, user: MEMBER });
expect(await svc.markOrphanedLinks()).toBe(1);
expect(await svc.markOrphanedLinks()).toBe(0);
});
@@ -816,7 +822,7 @@ describe('markOrphanedLinks', () => {
it('does not touch a binding the user had already switched off', async () => {
const paused = await bind(MEMBER, 'paperless');
await svc.updateLink(paused.id, { syncEnabled: false });
testDb.prepare('DELETE FROM trip_members WHERE trip_id = ? AND user_id = ?').run(TRIP, MEMBER);
await deleteRows(await orm(), TripMembers, { trip: TRIP, user: MEMBER });
expect(await svc.markOrphanedLinks()).toBe(0);
expect((await svc.getLink(paused.id))?.last_sync_state).toBe('never');
@@ -831,7 +837,7 @@ describe('isOrphaned', () => {
it('answers yes for a paused binding whose owner left, which the sweep never marks', async () => {
const paused = await bind(MEMBER);
await svc.updateLink(paused.id, { syncEnabled: false });
testDb.prepare('DELETE FROM trip_members WHERE trip_id = ? AND user_id = ?').run(TRIP, MEMBER);
await deleteRows(await orm(), TripMembers, { trip: TRIP, user: MEMBER });
expect(await svc.markOrphanedLinks()).toBe(0);
expect(await svc.isOrphaned((await svc.getLink(paused.id))!)).toBe(true);
@@ -841,7 +847,7 @@ describe('isOrphaned', () => {
const kept = await bind(OWNER);
expect(await svc.isOrphaned(kept)).toBe(false);
testDb.prepare("UPDATE trip_document_links SET last_sync_state = 'orphaned' WHERE id = ?").run(kept.id);
await updateRows(await orm(), TripDocumentLinks, { id: kept.id }, { last_sync_state: 'orphaned' });
expect(await svc.isOrphaned((await svc.getLink(kept.id))!)).toBe(true);
});
@@ -854,7 +860,7 @@ describe('isOrphaned', () => {
describe('switching an orphaned binding back on', () => {
async function orphan(): Promise<LinkRow> {
const bound = await link((await connect({}, MEMBER)).id);
testDb.prepare('DELETE FROM trip_members WHERE trip_id = ? AND user_id = ?').run(TRIP, MEMBER);
await deleteRows(await orm(), TripMembers, { trip: TRIP, user: MEMBER });
await svc.markOrphanedLinks();
return bound;
}
@@ -869,7 +875,7 @@ describe('switching an orphaned binding back on', () => {
it('lets it run again once its owner is back on the trip', async () => {
const bound = await orphan();
testDb.prepare('INSERT INTO trip_members (trip_id, user_id) VALUES (?, ?)').run(TRIP, MEMBER);
await addTripMember(await orm(), TRIP, MEMBER);
const res = await svc.updateLink(bound.id, { syncEnabled: true });