test(server): seed the MCP tool specs through the repositories

The MCP tool suites seeded trips, places, bookings, packing, vacay and
settings rows and read back what a tool call left behind through raw
better-sqlite3 statements on the sync handle. They now go through the
ORM factories and readers in tests/helpers/factories, so the rows are
written and read the way the repositories do it.

Every assertion keeps its meaning: a missing row that read back as
undefined now reads back as null, and a partial SELECT is replaced by
the same fields picked off the full row.
This commit is contained in:
Maurice
2026-10-08 15:32:01 +02:00
parent 1bba65634c
commit ddf97c5a21
15 changed files with 761 additions and 523 deletions
@@ -22,6 +22,16 @@ vi.mock('../../../src/websocket', () => ({ broadcast: broadcastMock }));
import { resetTestDb } from '../../helpers/test-db';
import { createUser, createTrip, createDay, createPlace, createDayAssignment, createReservation } from '../../helpers/factories';
import { createMcpHarness, parseToolResult, type McpHarness } from '../../helpers/mcp-harness';
import { createTestOrm, type TestOrm } from '../../helpers/test-orm';
import { findRow, insertRow } from '../../helpers/factories/rows';
import { AssignmentParticipants } from '../../../src/db/entities/AssignmentParticipants.entity';
import { DayAssignments } from '../../../src/db/entities/DayAssignments.entity';
let orm: TestOrm;
beforeAll(async () => {
orm = await createTestOrm(testDb);
});
beforeEach(() => {
resetTestDb(testDb);
@@ -29,7 +39,8 @@ beforeEach(() => {
delete process.env.DEMO_MODE;
});
afterAll(() => {
afterAll(async () => {
await orm.close();
testDb.close();
});
@@ -64,7 +75,7 @@ describe('Tool: move_assignment', () => {
expect.objectContaining({ oldDayId: day1.id, newDayId: day2.id }),
);
// Verify the assignment was moved
const updated = testDb.prepare('SELECT day_id FROM day_assignments WHERE id = ?').get(assignment.id) as any;
const updated = (await findRow(orm, DayAssignments, { id: assignment.id }))!;
expect(updated.day_id).toBe(day2.id);
});
});
@@ -160,7 +171,7 @@ describe('Tool: set_assignment_participants', () => {
const place = createPlace(testDb, trip.id);
const assignment = createDayAssignment(testDb, day.id, place.id);
// First set
testDb.prepare('INSERT INTO assignment_participants (assignment_id, user_id) VALUES (?, ?)').run(assignment.id, user.id);
await insertRow(orm, AssignmentParticipants, { assignment: assignment.id, user: user.id });
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'set_assignment_participants',
+58 -34
View File
@@ -22,13 +22,40 @@ vi.mock('../../../src/websocket', () => ({ broadcast: broadcastMock }));
import { resetTestDb } from '../../helpers/test-db';
import { createUser, createTrip, createDay, createPlace, createDayAssignment, createJourney } from '../../helpers/factories';
import { createMcpHarness, parseToolResult, type McpHarness } from '../../helpers/mcp-harness';
import { createTestOrm, type TestOrm } from '../../helpers/test-orm';
import { countRows, findRow, findRows, insertRow, updateRows } from '../../helpers/factories/rows';
import { linkTripToJourney } from '../../helpers/factories/journeys';
import { DayAssignments } from '../../../src/db/entities/DayAssignments.entity';
import { JourneyEntries } from '../../../src/db/entities/JourneyEntries.entity';
import { RoadtripVias } from '../../../src/db/entities/RoadtripVias.entity';
let orm: TestOrm;
beforeAll(async () => {
orm = await createTestOrm(testDb);
});
/** The day's assignment ids in order_index order. */
async function dayOrder(dayId: number): Promise<number[]> {
return (await findRows(orm, DayAssignments, { day: dayId }, { order_index: 'asc' })).map(r => r.id);
}
async function viaAfterIndex(id: number) {
const row = await findRow(orm, RoadtripVias, { id });
return row ? { after_order_index: row.after_order_index } : undefined;
}
async function assignmentNotes(id: number) {
const row = await findRow(orm, DayAssignments, { id });
return row ? { notes: row.notes } : undefined;
}
/** Link a journey to a trip so reconcileTripSkeletons has a target. */
function linkJourney(journeyId: number, tripId: number) {
testDb.prepare('INSERT INTO journey_trips (journey_id, trip_id, added_at) VALUES (?, ?, ?)').run(journeyId, tripId, Date.now());
return linkTripToJourney(orm, journeyId, tripId);
}
function skeletonFor(journeyId: number, placeId: number) {
return testDb.prepare('SELECT * FROM journey_entries WHERE journey_id = ? AND source_place_id = ?').get(journeyId, placeId) as any;
return findRow(orm, JourneyEntries, { journey: journeyId, sourcePlace: placeId });
}
beforeEach(() => {
@@ -37,7 +64,8 @@ beforeEach(() => {
delete process.env.DEMO_MODE;
});
afterAll(() => {
afterAll(async () => {
await orm.close();
testDb.close();
});
@@ -93,17 +121,17 @@ describe('Tool: assign_place_to_day', () => {
const day = createDay(testDb, trip.id);
const place = createPlace(testDb, trip.id, { name: 'Linked Place' });
const journey = createJourney(testDb, user.id);
linkJourney(journey.id, trip.id);
await linkJourney(journey.id, trip.id);
await withHarness(user.id, async (h) => {
await h.client.callTool({
name: 'assign_place_to_day',
arguments: { tripId: trip.id, dayId: day.id, placeId: place.id },
});
const skeleton = skeletonFor(journey.id, place.id);
expect(skeleton).toBeDefined();
expect(skeleton.type).toBe('skeleton');
expect(skeleton.title).toBe('Linked Place');
const skeleton = await skeletonFor(journey.id, place.id);
expect(skeleton).not.toBeNull();
expect(skeleton!.type).toBe('skeleton');
expect(skeleton!.title).toBe('Linked Place');
});
});
@@ -200,7 +228,7 @@ describe('Tool: unassign_place', () => {
});
const data = parseToolResult(result) as any;
expect(data.success).toBe(true);
expect(testDb.prepare('SELECT id FROM day_assignments WHERE id = ?').get(assignment.id)).toBeUndefined();
expect(await findRow(orm, DayAssignments, { id: assignment.id })).toBeNull();
});
});
@@ -222,17 +250,17 @@ describe('Tool: unassign_place', () => {
const day = createDay(testDb, trip.id);
const place = createPlace(testDb, trip.id);
const journey = createJourney(testDb, user.id);
linkJourney(journey.id, trip.id);
await linkJourney(journey.id, trip.id);
await withHarness(user.id, async (h) => {
// Assign via MCP (materialises the skeleton), then unassign that same assignment.
const assigned = parseToolResult(
await h.client.callTool({ name: 'assign_place_to_day', arguments: { tripId: trip.id, dayId: day.id, placeId: place.id } }),
) as any;
expect(skeletonFor(journey.id, place.id)).toBeDefined();
expect(await skeletonFor(journey.id, place.id)).not.toBeNull();
await h.client.callTool({ name: 'unassign_place', arguments: { tripId: trip.id, dayId: day.id, assignmentId: assigned.assignment.id } });
expect(skeletonFor(journey.id, place.id)).toBeUndefined();
expect(await skeletonFor(journey.id, place.id)).toBeNull();
});
});
@@ -276,7 +304,7 @@ describe('Tool: clear_day_assignments (#2470)', () => {
const data = parseToolResult(await h.client.callTool({ name: 'clear_day_assignments', arguments: { tripId: trip.id, dayId: day.id } })) as any;
expect(data.success).toBe(true);
expect([...data.removedIds].sort()).toEqual([a1.id, a2.id].sort());
expect(testDb.prepare('SELECT COUNT(*) AS n FROM day_assignments WHERE day_id = ?').get(day.id)).toEqual({ n: 0 });
expect(await countRows(orm, DayAssignments, { day: day.id })).toBe(0);
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'assignment:deleted', expect.objectContaining({ assignmentId: a1.id, dayId: day.id }));
});
});
@@ -322,8 +350,8 @@ describe('Tool: reorder_day_assignments', () => {
const data = parseToolResult(result) as any;
expect(data.success).toBe(true);
const a1Updated = testDb.prepare('SELECT order_index FROM day_assignments WHERE id = ?').get(a1.id) as { order_index: number };
const a2Updated = testDb.prepare('SELECT order_index FROM day_assignments WHERE id = ?').get(a2.id) as { order_index: number };
const a1Updated = (await findRow(orm, DayAssignments, { id: a1.id }))!;
const a2Updated = (await findRow(orm, DayAssignments, { id: a2.id }))!;
expect(a2Updated.order_index).toBe(0);
expect(a1Updated.order_index).toBe(1);
});
@@ -397,7 +425,7 @@ describe('Tool: update_assignment_time', () => {
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 assignment_time = ?, assignment_end_time = ? WHERE id = ?').run('09:00', '11:00', assignment.id);
await updateRows(orm, DayAssignments, { id: assignment.id }, { assignment_time: '09:00', assignment_end_time: '11:00' });
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
@@ -430,19 +458,17 @@ describe('Tool: update_assignment_time', () => {
const day = createDay(testDb, trip.id);
const place = createPlace(testDb, trip.id);
const [a, b, c, d] = [0, 1, 2, 3].map(i => createDayAssignment(testDb, day.id, place.id, { order_index: i }).id);
testDb.prepare('UPDATE day_assignments SET assignment_time = ? WHERE id = ?').run('15:00', b);
const via = Number(testDb.prepare(
'INSERT INTO roadtrip_vias (day_id, after_order_index, sequence, lat, lng) VALUES (?, 1, 0, 48.1, 11.5)'
).run(day.id).lastInsertRowid);
await updateRows(orm, DayAssignments, { id: b }, { assignment_time: '15:00' });
const via = await insertRow(orm, RoadtripVias, { day: day.id, after_order_index: 1, sequence: 0, lat: 48.1, lng: 11.5 });
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'update_assignment_time', arguments: { tripId: trip.id, assignmentId: d, place_time: '10:00' } });
expect((parseToolResult(result) as { assignment: { assignment_time: string } }).assignment.assignment_time).toBe('10:00');
});
const order = (testDb.prepare('SELECT id FROM day_assignments WHERE day_id = ? ORDER BY order_index').all(day.id) as { id: number }[]).map(r => r.id);
const order = await dayOrder(day.id);
expect(order).toEqual([a, d, b, c]);
expect(testDb.prepare('SELECT after_order_index FROM roadtrip_vias WHERE id = ?').get(via)).toEqual({ after_order_index: 2 });
expect(await viaAfterIndex(via)).toEqual({ after_order_index: 2 });
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'assignment:reordered', expect.objectContaining({ dayId: day.id, orderedIds: [a, d, b, c] }));
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'roadtripVia:changed', expect.objectContaining({
dayId: day.id,
@@ -461,7 +487,7 @@ describe('Tool: update_assignment_time', () => {
await h.client.callTool({ name: 'update_assignment_time', arguments: { tripId: trip.id, assignmentId: c, place_time: '14:00' } });
});
const order = (testDb.prepare('SELECT id FROM day_assignments WHERE day_id = ? ORDER BY order_index').all(day.id) as { id: number }[]).map(r => r.id);
const order = await dayOrder(day.id);
expect(order).toEqual([a, b, c]);
expect(broadcastMock.mock.calls.map(call => call[1])).toEqual(['assignment:updated']);
});
@@ -473,11 +499,9 @@ describe('Tool: update_assignment_time', () => {
const place = createPlace(testDb, trip.id);
// B starts before A and was put behind it on purpose.
const [a, b] = [0, 1].map(i => createDayAssignment(testDb, day.id, place.id, { order_index: i }).id);
testDb.prepare("UPDATE day_assignments SET assignment_time = '14:00' WHERE id = ?").run(a);
testDb.prepare("UPDATE day_assignments SET assignment_time = '10:00' WHERE id = ?").run(b);
const via = Number(testDb.prepare(
'INSERT INTO roadtrip_vias (day_id, after_order_index, sequence, lat, lng) VALUES (?, 0, 0, 48.1, 11.5)'
).run(day.id).lastInsertRowid);
await updateRows(orm, DayAssignments, { id: a }, { assignment_time: '14:00' });
await updateRows(orm, DayAssignments, { id: b }, { assignment_time: '10:00' });
const via = await insertRow(orm, RoadtripVias, { day: day.id, after_order_index: 0, sequence: 0, lat: 48.1, lng: 11.5 });
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'update_assignment_time', arguments: { tripId: trip.id, assignmentId: b, end_time: '11:30' } });
@@ -485,9 +509,9 @@ describe('Tool: update_assignment_time', () => {
.toMatchObject({ assignment_time: '10:00', assignment_end_time: '11:30' });
});
const order = (testDb.prepare('SELECT id FROM day_assignments WHERE day_id = ? ORDER BY order_index').all(day.id) as { id: number }[]).map(r => r.id);
const order = await dayOrder(day.id);
expect(order).toEqual([a, b]);
expect(testDb.prepare('SELECT after_order_index FROM roadtrip_vias WHERE id = ?').get(via)).toEqual({ after_order_index: 0 });
expect(await viaAfterIndex(via)).toEqual({ after_order_index: 0 });
expect(broadcastMock.mock.calls.map(call => call[1])).toEqual(['assignment:updated']);
});
@@ -533,7 +557,7 @@ describe('Tool: update_assignment_notes', () => {
});
const data = parseToolResult(result) as any;
expect(data.assignment.notes).toBe('Book the 10:00 timed entry and arrive 15 minutes early');
expect(testDb.prepare('SELECT notes FROM day_assignments WHERE id = ?').get(assignment.id)).toEqual({ notes: 'Book the 10:00 timed entry and arrive 15 minutes early' });
expect(await assignmentNotes(assignment.id)).toEqual({ notes: 'Book the 10:00 timed entry and arrive 15 minutes early' });
});
});
@@ -543,7 +567,7 @@ describe('Tool: update_assignment_notes', () => {
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 notes = ? WHERE id = ?').run('old', assignment.id);
await updateRows(orm, DayAssignments, { id: assignment.id }, { notes: 'old' });
await withHarness(user.id, async (h) => {
const cleared = parseToolResult(await h.client.callTool({
@@ -551,7 +575,7 @@ describe('Tool: update_assignment_notes', () => {
arguments: { tripId: trip.id, assignmentId: assignment.id, notes: null },
})) as any;
expect(cleared.assignment.notes).toBeNull();
testDb.prepare('UPDATE day_assignments SET notes = ? WHERE id = ?').run('old', assignment.id);
await updateRows(orm, DayAssignments, { id: assignment.id }, { notes: 'old' });
const emptied = parseToolResult(await h.client.callTool({
name: 'update_assignment_notes',
arguments: { tripId: trip.id, assignmentId: assignment.id, notes: '' },
@@ -591,7 +615,7 @@ describe('Tool: update_assignment_notes', () => {
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'update_assignment_notes', arguments: { tripId: trip.id, assignmentId: assignment.id, notes: 'n' } });
expect(result.isError).toBe(true);
expect(testDb.prepare('SELECT notes FROM day_assignments WHERE id = ?').get(assignment.id)).toEqual({ notes: null });
expect(await assignmentNotes(assignment.id)).toEqual({ notes: null });
});
});
});
+18 -7
View File
@@ -21,13 +21,24 @@ vi.mock('../../../src/websocket', () => ({ broadcast: vi.fn() }));
import { resetTestDb } from '../../helpers/test-db';
import { createUser, createBucketListItem, createVisitedCountry } from '../../helpers/factories';
import { createMcpHarness, parseToolResult, type McpHarness } from '../../helpers/mcp-harness';
import { createTestOrm, type TestOrm } from '../../helpers/test-orm';
import { countRows, findRow } from '../../helpers/factories/rows';
import { BucketList } from '../../../src/db/entities/BucketList.entity';
import { VisitedCountries } from '../../../src/db/entities/VisitedCountries.entity';
let orm: TestOrm;
beforeAll(async () => {
orm = await createTestOrm(testDb);
});
beforeEach(() => {
resetTestDb(testDb);
delete process.env.DEMO_MODE;
});
afterAll(() => {
afterAll(async () => {
await orm.close();
testDb.close();
});
@@ -48,7 +59,7 @@ describe('Tool: mark_country_visited', () => {
const data = parseToolResult(result) as any;
expect(data.success).toBe(true);
expect(data.country_code).toBe('FR');
const row = testDb.prepare('SELECT country_code FROM visited_countries WHERE user_id = ? AND country_code = ?').get(user.id, 'FR');
const row = await findRow(orm, VisitedCountries, { user: user.id, country_code: 'FR' });
expect(row).toBeTruthy();
});
});
@@ -60,7 +71,7 @@ describe('Tool: mark_country_visited', () => {
const result = await h.client.callTool({ name: 'mark_country_visited', arguments: { country_code: 'JP' } });
const data = parseToolResult(result) as any;
expect(data.success).toBe(true);
const count = (testDb.prepare('SELECT COUNT(*) as c FROM visited_countries WHERE user_id = ? AND country_code = ?').get(user.id, 'JP') as { c: number }).c;
const count = await countRows(orm, VisitedCountries, { user: user.id, country_code: 'JP' });
expect(count).toBe(1);
});
});
@@ -87,8 +98,8 @@ describe('Tool: unmark_country_visited', () => {
const result = await h.client.callTool({ name: 'unmark_country_visited', arguments: { country_code: 'ES' } });
const data = parseToolResult(result) as any;
expect(data.success).toBe(true);
const row = testDb.prepare('SELECT country_code FROM visited_countries WHERE user_id = ? AND country_code = ?').get(user.id, 'ES');
expect(row).toBeUndefined();
const row = await findRow(orm, VisitedCountries, { user: user.id, country_code: 'ES' });
expect(row).toBeNull();
});
});
@@ -181,7 +192,7 @@ describe('Tool: create_bucket_list_item', () => {
arguments: { name: 'Japan', country_code: 'JP' },
});
expect(result.isError).toBe(true);
expect((testDb.prepare('SELECT COUNT(*) AS n FROM bucket_list WHERE user_id = ?').get(user.id) as { n: number }).n).toBe(1);
expect(await countRows(orm, BucketList, { user: user.id })).toBe(1);
});
});
@@ -207,7 +218,7 @@ describe('Tool: delete_bucket_list_item', () => {
const result = await h.client.callTool({ name: 'delete_bucket_list_item', arguments: { itemId: item.id } });
const data = parseToolResult(result) as any;
expect(data.success).toBe(true);
expect(testDb.prepare('SELECT id FROM bucket_list WHERE id = ?').get(item.id)).toBeUndefined();
expect(await findRow(orm, BucketList, { id: item.id })).toBeNull();
});
});
+50 -31
View File
@@ -37,13 +37,30 @@ import { PermissionsService } from '../../../src/nest/permissions/permissions.se
import { RealtimeService } from '../../../src/nest/realtime/realtime.service';
import { McpToolGuardsService } from '../../../src/nest/mcp-shared/mcp-tool-guards.service';
import { createTestUnitOfWork, createTestAppSettingsRepo, createTestCategoriesRepo, createTestTripsRepo, createTestUsersRepo, sharedTestOrm } from '../../helpers/test-uow';
import { createTestOrm, type TestOrm } from '../../helpers/test-orm';
import { findRow } from '../../helpers/factories/rows';
import { makeCategory, makePlace } from '../../helpers/factories/places';
import { makeTrip } from '../../helpers/factories/trips';
import { Categories } from '../../../src/db/entities/Categories.entity';
import { Places } from '../../../src/db/entities/Places.entity';
let orm: TestOrm;
beforeAll(async () => {
orm = await createTestOrm(testDb);
});
async function categoryRow(id: number) {
return (await findRow(orm, Categories, { id }))!;
}
beforeEach(() => {
resetTestDb(testDb);
delete process.env.DEMO_MODE;
});
afterAll(() => {
afterAll(async () => {
await orm.close();
testDb.close();
});
@@ -87,8 +104,8 @@ async function withWriteHarness(userId: number, fn: (client: Client) => Promise<
}
}
function insertCategory(name: string, color = '#111111', icon = '🅰️'): number {
return Number(testDb.prepare('INSERT INTO categories (name, color, icon) VALUES (?, ?, ?)').run(name, color, icon).lastInsertRowid);
async function insertCategory(name: string, color = '#111111', icon = '🅰️'): Promise<number> {
return (await makeCategory(orm, { name, color, icon })).id;
}
// ---------------------------------------------------------------------------
@@ -114,8 +131,8 @@ describe('Tool: list_categories', () => {
it('returns categories from all users, ordered by name', async () => {
const { user } = createUser(testDb);
const { user: other } = createUser(testDb);
testDb.prepare('INSERT INTO categories (name, color, icon, user_id) VALUES (?, ?, ?, ?)').run('Zzz Mine', '#111111', '🅰️', user.id);
testDb.prepare('INSERT INTO categories (name, color, icon, user_id) VALUES (?, ?, ?, ?)').run('Zzz Other', '#222222', '🅱️', other.id);
await makeCategory(orm, { name: 'Zzz Mine', color: '#111111', icon: '🅰️', user: user.id });
await makeCategory(orm, { name: 'Zzz Other', color: '#222222', icon: '🅱️', user: other.id });
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'list_categories', arguments: {} });
const names = (parseToolResult(result) as any).categories.map((c: { name: string }) => c.name);
@@ -139,8 +156,8 @@ describe('Tool: create_category', () => {
arguments: { name: 'Street food', color: '#16a34a', icon: '🍜' },
});
const data = parseToolResult(result) as any;
const row = testDb.prepare('SELECT name, color, icon, user_id FROM categories WHERE id = ?').get(data.category.id) as any;
expect(row).toEqual({ name: 'Street food', color: '#16a34a', icon: '🍜', user_id: admin.id });
const { name, color, icon, user_id } = await categoryRow(data.category.id);
expect({ name, color, icon, user_id }).toEqual({ name: 'Street food', color: '#16a34a', icon: '🍜', user_id: admin.id });
});
});
@@ -149,8 +166,8 @@ describe('Tool: create_category', () => {
await withWriteHarness(admin.id, async (client) => {
const result = await client.callTool({ name: 'create_category', arguments: { name: 'Bare minimum' } });
const data = parseToolResult(result) as any;
const row = testDb.prepare('SELECT color, icon FROM categories WHERE id = ?').get(data.category.id) as any;
expect(row).toEqual({ color: '#6366f1', icon: '📍' });
const { color, icon } = await categoryRow(data.category.id);
expect({ color, icon }).toEqual({ color: '#6366f1', icon: '📍' });
});
});
@@ -159,7 +176,7 @@ describe('Tool: create_category', () => {
await withWriteHarness(user.id, async (client) => {
const result = await client.callTool({ name: 'create_category', arguments: { name: 'Sneaky' } });
expect(result.isError).toBe(true);
expect(testDb.prepare('SELECT id FROM categories WHERE name = ?').get('Sneaky')).toBeUndefined();
expect(await findRow(orm, Categories, { name: 'Sneaky' })).toBeNull();
});
});
@@ -169,7 +186,7 @@ describe('Tool: create_category', () => {
await withWriteHarness(user.id, async (client) => {
const result = await client.callTool({ name: 'create_category', arguments: { name: 'Demo made this' } });
expect(result.isError).toBe(true);
expect(testDb.prepare('SELECT id FROM categories WHERE name = ?').get('Demo made this')).toBeUndefined();
expect(await findRow(orm, Categories, { name: 'Demo made this' })).toBeNull();
});
});
@@ -178,7 +195,7 @@ describe('Tool: create_category', () => {
await withWriteHarness(admin.id, async (client) => {
const result = await client.callTool({ name: 'create_category', arguments: { name: 'Bad colour', color: 'rebeccapurple' } });
expect(result.isError).toBe(true);
expect(testDb.prepare('SELECT id FROM categories WHERE name = ?').get('Bad colour')).toBeUndefined();
expect(await findRow(orm, Categories, { name: 'Bad colour' })).toBeNull();
});
});
@@ -198,20 +215,22 @@ describe('Tool: create_category', () => {
describe('Tool: update_category', () => {
it('renames and recolours an existing category', async () => {
const { user: admin } = createAdmin(testDb);
const id = insertCategory('Old name', '#111111', '🅰️');
const id = await insertCategory('Old name', '#111111', '🅰️');
await withWriteHarness(admin.id, async (client) => {
await client.callTool({ name: 'update_category', arguments: { categoryId: id, name: 'New name', color: '#dc2626' } });
const row = testDb.prepare('SELECT name, color, icon FROM categories WHERE id = ?').get(id) as any;
const { name, color, icon } = await categoryRow(id);
const row = { name, color, icon };
expect(row).toEqual({ name: 'New name', color: '#dc2626', icon: '🅰️' });
});
});
it('leaves the fields it was not given alone', async () => {
const { user: admin } = createAdmin(testDb);
const id = insertCategory('Keep me', '#0891b2', '🚕');
const id = await insertCategory('Keep me', '#0891b2', '🚕');
await withWriteHarness(admin.id, async (client) => {
await client.callTool({ name: 'update_category', arguments: { categoryId: id, icon: '🚗' } });
const row = testDb.prepare('SELECT name, color, icon FROM categories WHERE id = ?').get(id) as any;
const { name, color, icon } = await categoryRow(id);
const row = { name, color, icon };
expect(row).toEqual({ name: 'Keep me', color: '#0891b2', icon: '🚗' });
});
});
@@ -226,32 +245,32 @@ describe('Tool: update_category', () => {
it('refuses a non-admin and leaves the row untouched', async () => {
const { user } = createUser(testDb);
const id = insertCategory('Not yours', '#9333ea', '🔒');
const id = await insertCategory('Not yours', '#9333ea', '🔒');
await withWriteHarness(user.id, async (client) => {
const result = await client.callTool({ name: 'update_category', arguments: { categoryId: id, name: 'Hijacked' } });
expect(result.isError).toBe(true);
expect((testDb.prepare('SELECT name FROM categories WHERE id = ?').get(id) as any).name).toBe('Not yours');
expect((await categoryRow(id)).name).toBe('Not yours');
});
});
it('blocks demo user', async () => {
process.env.DEMO_MODE = 'true';
const { user } = createAdmin(testDb, { email: 'demo@trek.app' });
const id = insertCategory('Demo untouchable', '#ea580c', '🙅');
const id = await insertCategory('Demo untouchable', '#ea580c', '🙅');
await withWriteHarness(user.id, async (client) => {
const result = await client.callTool({ name: 'update_category', arguments: { categoryId: id, name: 'Changed' } });
expect(result.isError).toBe(true);
expect((testDb.prepare('SELECT name FROM categories WHERE id = ?').get(id) as any).name).toBe('Demo untouchable');
expect((await categoryRow(id)).name).toBe('Demo untouchable');
});
});
it('refuses a color that is not a hex value', async () => {
const { user: admin } = createAdmin(testDb);
const id = insertCategory('Colour guard', '#2563eb', '🎨');
const id = await insertCategory('Colour guard', '#2563eb', '🎨');
await withWriteHarness(admin.id, async (client) => {
const result = await client.callTool({ name: 'update_category', arguments: { categoryId: id, color: 'goldenrod' } });
expect(result.isError).toBe(true);
expect((testDb.prepare('SELECT color FROM categories WHERE id = ?').get(id) as any).color).toBe('#2563eb');
expect((await categoryRow(id)).color).toBe('#2563eb');
});
});
});
@@ -263,14 +282,14 @@ describe('Tool: update_category', () => {
describe('Tool: delete_category', () => {
it('removes the category and unassigns the places that carried it', async () => {
const { user: admin } = createAdmin(testDb);
const id = insertCategory('Doomed', '#d97706', '💀');
const trip = Number(testDb.prepare('INSERT INTO trips (user_id, title) VALUES (?, ?)').run(admin.id, 'Trip').lastInsertRowid);
const place = Number(testDb.prepare('INSERT INTO places (trip_id, name, category_id) VALUES (?, ?, ?)').run(trip, 'Somewhere', id).lastInsertRowid);
const id = await insertCategory('Doomed', '#d97706', '💀');
const { id: trip } = await makeTrip(orm, admin.id, { title: 'Trip' });
const { id: place } = await makePlace(orm, trip, { name: 'Somewhere', category: id });
await withWriteHarness(admin.id, async (client) => {
const result = await client.callTool({ name: 'delete_category', arguments: { categoryId: id } });
expect((parseToolResult(result) as any).success).toBe(true);
expect(testDb.prepare('SELECT id FROM categories WHERE id = ?').get(id)).toBeUndefined();
expect((testDb.prepare('SELECT category_id FROM places WHERE id = ?').get(place) as any).category_id).toBeNull();
expect(await findRow(orm, Categories, { id })).toBeNull();
expect((await findRow(orm, Places, { id: place }))!.category_id).toBeNull();
});
});
@@ -284,22 +303,22 @@ describe('Tool: delete_category', () => {
it('refuses a non-admin and keeps the row', async () => {
const { user } = createUser(testDb);
const id = insertCategory('Survivor', '#16a34a', '🌿');
const id = await insertCategory('Survivor', '#16a34a', '🌿');
await withWriteHarness(user.id, async (client) => {
const result = await client.callTool({ name: 'delete_category', arguments: { categoryId: id } });
expect(result.isError).toBe(true);
expect(testDb.prepare('SELECT id FROM categories WHERE id = ?').get(id)).toBeDefined();
expect(await findRow(orm, Categories, { id })).not.toBeNull();
});
});
it('blocks demo user', async () => {
process.env.DEMO_MODE = 'true';
const { user } = createAdmin(testDb, { email: 'demo@trek.app' });
const id = insertCategory('Demo survivor', '#0891b2', '🛟');
const id = await insertCategory('Demo survivor', '#0891b2', '🛟');
await withWriteHarness(user.id, async (client) => {
const result = await client.callTool({ name: 'delete_category', arguments: { categoryId: id } });
expect(result.isError).toBe(true);
expect(testDb.prepare('SELECT id FROM categories WHERE id = ?').get(id)).toBeDefined();
expect(await findRow(orm, Categories, { id })).not.toBeNull();
});
});
});
@@ -28,6 +28,28 @@ import { createUser, createTrip, createDay, createPlace, createDayAccommodation,
import { invalidatePermissionsCache } from '../../../src/nest/permissions/permissions-cache';
import { DayRemovalService } from '../../../src/nest/days/day-removal.service';
import { createMcpHarness, parseToolResult, parseResourceResult, type McpHarness } from '../../helpers/mcp-harness';
import { createTestOrm, type TestOrm } from '../../helpers/test-orm';
import { countRows, deleteRows, findRow, findRows } from '../../helpers/factories/rows';
import { makeReservation } from '../../helpers/factories/reservations';
import { setAppSetting } from '../../helpers/factories/settings';
import { AppSettings } from '../../../src/db/entities/AppSettings.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 { Trips } from '../../../src/db/entities/Trips.entity';
let orm: TestOrm;
beforeAll(async () => {
orm = await createTestOrm(testDb);
});
async function placeWebsite(id: number) {
const row = await findRow(orm, Places, { id });
return row ? { website: row.website } : undefined;
}
beforeEach(() => {
resetTestDb(testDb);
@@ -35,7 +57,8 @@ beforeEach(() => {
delete process.env.DEMO_MODE;
});
afterAll(() => {
afterAll(async () => {
await orm.close();
testDb.close();
});
@@ -96,7 +119,7 @@ describe('Tool: create_day', () => {
expect(text).toContain('Start day not found');
expect(text).toContain(', ');
});
expect(testDb.prepare('SELECT COUNT(*) AS n FROM day_accommodations').get()).toEqual({ n: 0 });
expect(await countRows(orm, DayAccommodations)).toBe(0);
});
it('returns access denied for non-member', async () => {
@@ -125,13 +148,13 @@ describe('Tool: create_day', () => {
// ---------------------------------------------------------------------------
describe('Tool: delete_day', () => {
const dayRows = (tripId: number) =>
testDb.prepare('SELECT id, day_number, date FROM days WHERE trip_id = ? ORDER BY day_number').all(tripId) as { id: number; day_number: number; date: string | null }[];
const dayRows = async (tripId: number) =>
(await findRows(orm, Days, { trip: tripId }, { day_number: 'asc' })).map(d => ({ id: d.id, day_number: d.day_number, date: d.date }));
it('deletes a day the way REST does: the gap closes, the dates stay on their slots, and every screen hears it', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { start_date: '2026-01-01', end_date: '2026-01-03' });
const [d1, d2, d3] = dayRows(trip.id);
const [d1, d2, d3] = await dayRows(trip.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'delete_day',
@@ -139,12 +162,12 @@ describe('Tool: delete_day', () => {
});
const data = parseToolResult(result) as any;
expect(data.success).toBe(true);
expect(testDb.prepare('SELECT id FROM days WHERE id = ?').get(d2.id)).toBeUndefined();
expect(dayRows(trip.id)).toEqual([
expect(await findRow(orm, Days, { id: d2.id })).toBeNull();
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' },
]);
expect(testDb.prepare('SELECT end_date FROM trips WHERE id = ?').get(trip.id)).toEqual({ end_date: '2026-01-02' });
expect((await findRow(orm, Trips, { id: trip.id }))!.end_date).toBe('2026-01-02');
const events = broadcastMock.mock.calls.map(c => c[1]);
expect(events).toEqual(['day:deleted', 'day:reordered', 'trip:updated']);
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'day:deleted', expect.objectContaining({ dayId: d2.id }));
@@ -160,17 +183,17 @@ describe('Tool: delete_day', () => {
const d2 = createDay(testDb, trip.id);
const place = createPlace(testDb, trip.id, { name: 'Harbour Hotel' });
const stay = createDayAccommodation(testDb, trip.id, place.id, d2.id, d2.id);
const reservationId = Number(testDb.prepare(
"INSERT INTO reservations (trip_id, day_id, title, type, accommodation_id) VALUES (?, ?, 'Harbour Hotel', 'hotel', ?)",
).run(trip.id, d2.id, stay.id).lastInsertRowid);
const { id: reservationId } = await makeReservation(orm, trip.id, {
day: d2.id, title: 'Harbour Hotel', type: 'hotel', accommodation_id: String(stay.id),
});
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'delete_day', arguments: { tripId: trip.id, dayId: d2.id } });
expect(result.isError).toBeFalsy();
expect(testDb.prepare('SELECT id FROM day_accommodations WHERE id = ?').get(stay.id)).toBeUndefined();
expect(testDb.prepare('SELECT id FROM reservations WHERE id = ?').get(reservationId)).toBeUndefined();
expect(await findRow(orm, DayAccommodations, { id: stay.id })).toBeNull();
expect(await findRow(orm, Reservations, { id: reservationId })).toBeNull();
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'reservation:deleted', expect.objectContaining({ reservationId }));
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'accommodation:deleted', expect.objectContaining({ accommodationId: stay.id }));
expect(dayRows(trip.id).map(r => r.id)).toEqual([d1.id]);
expect((await dayRows(trip.id)).map(r => r.id)).toEqual([d1.id]);
});
});
@@ -182,7 +205,7 @@ describe('Tool: delete_day', () => {
const result = await h.client.callTool({ name: 'delete_day', arguments: { tripId: trip.id, dayId: day.id } });
expect(result.isError).toBe(true);
expect(JSON.stringify(result.content)).toContain('A trip needs at least one day.');
expect(testDb.prepare('SELECT id FROM days WHERE id = ?').get(day.id)).toBeDefined();
expect(await findRow(orm, Days, { id: day.id })).not.toBeNull();
expect(broadcastMock).not.toHaveBeenCalled();
});
});
@@ -234,16 +257,16 @@ describe('Tool: delete_day', () => {
addTripMember(testDb, trip.id, member.id);
const day = createDay(testDb, trip.id);
createDay(testDb, trip.id);
testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('perm_day_edit', 'trip_owner')").run();
await setAppSetting(orm, 'perm_day_edit', 'trip_owner');
await invalidatePermissionsCache();
try {
await withHarness(member.id, async (h) => {
const result = await h.client.callTool({ name: 'delete_day', arguments: { tripId: trip.id, dayId: day.id } });
expect(result.isError).toBe(true);
expect(testDb.prepare('SELECT id FROM days WHERE id = ?').get(day.id)).toBeDefined();
expect(await findRow(orm, Days, { id: day.id })).not.toBeNull();
});
} finally {
testDb.prepare("DELETE FROM app_settings WHERE key = 'perm_day_edit'").run();
await deleteRows(orm, AppSettings, { key: 'perm_day_edit' });
await invalidatePermissionsCache();
}
});
@@ -257,7 +280,7 @@ describe('Tool: delete_day', () => {
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'delete_day', arguments: { tripId: trip.id, dayId: day.id } });
expect(result.isError).toBe(true);
expect(testDb.prepare('SELECT id FROM days WHERE id = ?').get(day.id)).toBeDefined();
expect(await findRow(orm, Days, { id: day.id })).not.toBeNull();
});
});
});
@@ -394,7 +417,7 @@ describe('Tool: delete_accommodation', () => {
const data = parseToolResult(result) as any;
expect(data.success).toBe(true);
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'accommodation:deleted', expect.objectContaining({ id: acc.id }));
expect(testDb.prepare('SELECT id FROM day_accommodations WHERE id = ?').get(acc.id)).toBeUndefined();
expect(await findRow(orm, DayAccommodations, { id: acc.id })).toBeNull();
});
});
@@ -425,7 +448,7 @@ describe('Tool: create_place_accommodation', () => {
name: 'create_place_accommodation',
arguments: { tripId: trip.id, name: 'Hôtel du Faouët', start_day_id: day.id, end_day_id: day.id, website: 'www.hotel-faouet.example' },
})) as { place: { id: number } };
expect(testDb.prepare('SELECT website FROM places WHERE id = ?').get(data.place.id)).toEqual({ website: 'https://www.hotel-faouet.example' });
expect(await placeWebsite(data.place.id)).toEqual({ website: 'https://www.hotel-faouet.example' });
});
});
@@ -446,7 +469,7 @@ describe('Tool: create_place_accommodation', () => {
const data = parseToolResult(result) as { place: { id: number; website: unknown }; accommodation: { id: number } };
expect(data.place.website, name).toBeNull();
expect(data.accommodation.id, name).toBeGreaterThan(0);
expect(testDb.prepare('SELECT website FROM places WHERE id = ?').get(data.place.id), name).toEqual({ website: null });
expect(await placeWebsite(data.place.id), name).toEqual({ website: null });
}
});
});
@@ -469,7 +492,7 @@ describe('Tool: create_place_accommodation', () => {
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'place:created', expect.objectContaining({ place: expect.anything() }));
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'accommodation:created', expect.objectContaining({ accommodation: expect.anything() }));
// The partner hotel reservation rides along.
const linked = testDb.prepare('SELECT type FROM reservations WHERE accommodation_id = ?').get(data.accommodation.id) as { type: string };
const linked = (await findRow(orm, Reservations, { accommodation_id: String(data.accommodation.id) }))!;
expect(linked.type).toBe('hotel');
});
});
@@ -483,7 +506,7 @@ describe('Tool: create_place_accommodation', () => {
arguments: { tripId: trip.id, name: 'Nowhere Inn', start_day_id: 99999, end_day_id: 99999 },
});
expect(result.isError).toBe(true);
expect(testDb.prepare('SELECT COUNT(*) as n FROM places WHERE trip_id = ?').get(trip.id)).toMatchObject({ n: 0 });
expect(await countRows(orm, Places, { trip: trip.id })).toBe(0);
});
});
@@ -503,9 +526,9 @@ describe('Tool: create_place_accommodation', () => {
expect(result.isError).toBe(true);
const { content } = result as { content: { type: string; text?: string }[] };
expect(content[0].text).toContain('Failed to create place and accommodation');
expect(testDb.prepare('SELECT COUNT(*) as n FROM places WHERE trip_id = ?').get(trip.id)).toMatchObject({ n: 0 });
expect(testDb.prepare('SELECT COUNT(*) as n FROM day_accommodations WHERE trip_id = ?').get(trip.id)).toMatchObject({ n: 0 });
expect(testDb.prepare('SELECT COUNT(*) as n FROM day_assignments WHERE day_id = ?').get(day.id)).toMatchObject({ n: 0 });
expect(await countRows(orm, Places, { trip: trip.id })).toBe(0);
expect(await countRows(orm, DayAccommodations, { trip: trip.id })).toBe(0);
expect(await countRows(orm, DayAssignments, { day: day.id })).toBe(0);
});
});
});
@@ -28,13 +28,24 @@ vi.mock('../../../src/websocket', () => ({ broadcast: vi.fn() }));
import { resetTestDb } from '../../helpers/test-db';
import { createUser } 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 { makeNotification } from '../../helpers/factories/notifications';
import { Notifications } from '../../../src/db/entities/Notifications.entity';
let orm: TestOrm;
beforeAll(async () => {
orm = await createTestOrm(testDb);
});
beforeEach(() => {
resetTestDb(testDb);
delete process.env.DEMO_MODE;
});
afterAll(() => {
afterAll(async () => {
await orm.close();
testDb.close();
});
@@ -42,19 +53,18 @@ afterAll(() => {
// Helper: insert a notification directly into the DB
// ---------------------------------------------------------------------------
function createNotification(db: any, userId: number, overrides: any = {}) {
const r = db.prepare(
`INSERT INTO notifications (type, scope, target, recipient_id, title_key, text_key, is_read)
VALUES (?, ?, ?, ?, ?, ?, 0)`
).run(
overrides.type ?? 'simple',
overrides.scope ?? 'user',
overrides.target ?? 0,
userId,
overrides.title_key ?? 'notification.test.title',
overrides.text_key ?? 'notification.test.body'
);
return db.prepare('SELECT * FROM notifications WHERE id = ?').get(r.lastInsertRowid);
function createNotification(
userId: number,
overrides: { type?: string; scope?: string; target?: number; title_key?: string; text_key?: string } = {},
) {
return makeNotification(orm, userId, {
type: overrides.type ?? 'simple',
scope: overrides.scope ?? 'user',
target: overrides.target ?? 0,
title_key: overrides.title_key ?? 'notification.test.title',
text_key: overrides.text_key ?? 'notification.test.body',
is_read: 0,
});
}
async function withHarness(userId: number, fn: (h: McpHarness) => Promise<void>) {
@@ -85,8 +95,8 @@ describe('Tool: list_notifications', () => {
it('returns notifications when they exist', async () => {
const { user } = createUser(testDb);
createNotification(testDb, user.id, { title_key: 'notif.first' });
createNotification(testDb, user.id, { title_key: 'notif.second' });
await createNotification(user.id, { title_key: 'notif.first' });
await createNotification(user.id, { title_key: 'notif.second' });
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'list_notifications', arguments: {} });
const data = parseToolResult(result) as any;
@@ -96,9 +106,9 @@ describe('Tool: list_notifications', () => {
it('returns only unread notifications when unread_only is true', async () => {
const { user } = createUser(testDb);
createNotification(testDb, user.id);
const read = createNotification(testDb, user.id) as any;
testDb.prepare('UPDATE notifications SET is_read = 1 WHERE id = ?').run(read.id);
await createNotification(user.id);
const read = await createNotification(user.id);
await updateRows(orm, Notifications, { id: read.id }, { is_read: 1 });
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'list_notifications', arguments: { unread_only: true } });
const data = parseToolResult(result) as any;
@@ -123,7 +133,7 @@ describe('Tool: get_unread_notification_count', () => {
it('returns 1 after inserting one unread notification', async () => {
const { user } = createUser(testDb);
createNotification(testDb, user.id);
await createNotification(user.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'get_unread_notification_count', arguments: {} });
const data = parseToolResult(result) as any;
@@ -139,7 +149,7 @@ describe('Tool: get_unread_notification_count', () => {
describe('Tool: mark_notification_read', () => {
it('flips is_read to 1 and returns success', async () => {
const { user } = createUser(testDb);
const notif = createNotification(testDb, user.id) as any;
const notif = await createNotification(user.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'mark_notification_read',
@@ -147,7 +157,7 @@ describe('Tool: mark_notification_read', () => {
});
const data = parseToolResult(result) as any;
expect(data.success).toBe(true);
const row = testDb.prepare('SELECT is_read FROM notifications WHERE id = ?').get(notif.id) as any;
const row = (await findRow(orm, Notifications, { id: notif.id }))!;
expect(row.is_read).toBe(1);
});
});
@@ -166,7 +176,7 @@ describe('Tool: mark_notification_read', () => {
it('blocks demo user', async () => {
process.env.DEMO_MODE = 'true';
const { user } = createUser(testDb, { email: 'demo@nomad.app' });
const notif = createNotification(testDb, user.id) as any;
const notif = await createNotification(user.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'mark_notification_read',
@@ -184,8 +194,8 @@ describe('Tool: mark_notification_read', () => {
describe('Tool: mark_notification_unread', () => {
it('flips is_read to 0', async () => {
const { user } = createUser(testDb);
const notif = createNotification(testDb, user.id) as any;
testDb.prepare('UPDATE notifications SET is_read = 1 WHERE id = ?').run(notif.id);
const notif = await createNotification(user.id);
await updateRows(orm, Notifications, { id: notif.id }, { is_read: 1 });
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'mark_notification_unread',
@@ -193,7 +203,7 @@ describe('Tool: mark_notification_unread', () => {
});
const data = parseToolResult(result) as any;
expect(data.success).toBe(true);
const row = testDb.prepare('SELECT is_read FROM notifications WHERE id = ?').get(notif.id) as any;
const row = (await findRow(orm, Notifications, { id: notif.id }))!;
expect(row.is_read).toBe(0);
});
});
@@ -217,15 +227,15 @@ describe('Tool: mark_notification_unread', () => {
describe('Tool: mark_all_notifications_read', () => {
it('marks all notifications read and returns count', async () => {
const { user } = createUser(testDb);
createNotification(testDb, user.id);
createNotification(testDb, user.id);
createNotification(testDb, user.id);
await createNotification(user.id);
await createNotification(user.id);
await createNotification(user.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'mark_all_notifications_read', arguments: {} });
const data = parseToolResult(result) as any;
expect(data.success).toBe(true);
expect(data.count).toBe(3);
const unread = (testDb.prepare('SELECT COUNT(*) as c FROM notifications WHERE recipient_id = ? AND is_read = 0').get(user.id) as any).c;
const unread = await countRows(orm, Notifications, { recipient: user.id, is_read: 0 });
expect(unread).toBe(0);
});
});
@@ -247,7 +257,7 @@ describe('Tool: mark_all_notifications_read', () => {
describe('Resource: trek://notifications/in-app', () => {
it('returns notifications list', async () => {
const { user } = createUser(testDb);
createNotification(testDb, user.id, { title_key: 'notif.test' });
await createNotification(user.id, { title_key: 'notif.test' });
await withResourceHarness(user.id, async (h) => {
const result = await h.client.readResource({ uri: 'trek://notifications/in-app' });
const data = parseResourceResult(result) as any;
@@ -26,6 +26,21 @@ vi.mock('../../../src/websocket', () => ({ broadcast: broadcastMock }));
import { resetTestDb } from '../../helpers/test-db';
import { createUser, createAdmin, createTrip, createPackingItem } from '../../helpers/factories';
import { createMcpHarness, parseToolResult, type McpHarness } from '../../helpers/mcp-harness';
import { createTestOrm, type TestOrm } from '../../helpers/test-orm';
import { countRows, findRow, insertRow } from '../../helpers/factories/rows';
import { addPackingBagMembers, makePackingBag, makePackingItem } from '../../helpers/factories/packing';
import { PackingBags } from '../../../src/db/entities/PackingBags.entity';
import { PackingCategoryAssignees } from '../../../src/db/entities/PackingCategoryAssignees.entity';
import { PackingItems } from '../../../src/db/entities/PackingItems.entity';
import { PackingTemplateCategories } from '../../../src/db/entities/PackingTemplateCategories.entity';
import { PackingTemplateItems } from '../../../src/db/entities/PackingTemplateItems.entity';
import { PackingTemplates } from '../../../src/db/entities/PackingTemplates.entity';
let orm: TestOrm;
beforeAll(async () => {
orm = await createTestOrm(testDb);
});
beforeEach(() => {
resetTestDb(testDb);
@@ -33,7 +48,8 @@ beforeEach(() => {
delete process.env.DEMO_MODE;
});
afterAll(() => {
afterAll(async () => {
await orm.close();
testDb.close();
});
@@ -99,7 +115,7 @@ describe('Tool: list_packing_bags', () => {
it('returns bags that exist', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
testDb.prepare('INSERT INTO packing_bags (trip_id, name, color) VALUES (?, ?, ?)').run(trip.id, 'Carry-on', '#ff0000');
await makePackingBag(orm, trip.id, { name: 'Carry-on', color: '#ff0000' });
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'list_packing_bags',
@@ -144,7 +160,7 @@ describe('Tool: create_packing_bag', () => {
});
const data = parseToolResult(result) as any;
expect(data.bag.weight_limit_grams).toBe(23000);
const row = testDb.prepare('SELECT weight_limit_grams FROM packing_bags WHERE id = ?').get(data.bag.id) as { weight_limit_grams: number | null };
const row = (await findRow(orm, PackingBags, { id: data.bag.id }))!;
expect(row.weight_limit_grams).toBe(23000);
});
});
@@ -184,8 +200,7 @@ describe('Tool: update_packing_bag', () => {
it('updates bag name and broadcasts', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const r = testDb.prepare('INSERT INTO packing_bags (trip_id, name, color) VALUES (?, ?, ?)').run(trip.id, 'Old Name', '#aabbcc');
const bag = testDb.prepare('SELECT * FROM packing_bags WHERE id = ?').get(r.lastInsertRowid) as any;
const bag = await makePackingBag(orm, trip.id, { name: 'Old Name', color: '#aabbcc' });
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'update_packing_bag',
@@ -220,8 +235,7 @@ describe('Tool: delete_packing_bag', () => {
it('deletes a bag and broadcasts', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const r = testDb.prepare('INSERT INTO packing_bags (trip_id, name, color) VALUES (?, ?, ?)').run(trip.id, 'Delete Me', '#000000');
const bagId = r.lastInsertRowid as number;
const { id: bagId } = await makePackingBag(orm, trip.id, { name: 'Delete Me', color: '#000000' });
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'delete_packing_bag',
@@ -231,17 +245,15 @@ describe('Tool: delete_packing_bag', () => {
expect(data.success).toBe(true);
// { bagId } — aligned with the REST route and the plugin host.
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'packing:bag-deleted', expect.objectContaining({ bagId }));
expect(testDb.prepare('SELECT id FROM packing_bags WHERE id = ?').get(bagId)).toBeUndefined();
expect(await findRow(orm, PackingBags, { id: bagId })).toBeNull();
});
});
it('pings the room to re-read the bag weights (#2191)', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const r = testDb.prepare('INSERT INTO packing_bags (trip_id, name, color) VALUES (?, ?, ?)').run(trip.id, 'Carry-on', '#000000');
const bagId = r.lastInsertRowid as number;
testDb.prepare('INSERT INTO packing_items (trip_id, name, category, checked, bag_id, weight_grams) VALUES (?, ?, ?, 0, ?, ?)')
.run(trip.id, 'Tent', 'Camping', bagId, 3000);
const { id: bagId } = await makePackingBag(orm, trip.id, { name: 'Carry-on', color: '#000000' });
await makePackingItem(orm, trip.id, { name: 'Tent', category: 'Camping', checked: 0, bag: bagId, weight_grams: 3000 });
await withHarness(user.id, async (h) => {
await h.client.callTool({
name: 'delete_packing_bag',
@@ -252,7 +264,7 @@ describe('Tool: delete_packing_bag', () => {
// the REST route and the plugin RPC.
expect(broadcastMock).toHaveBeenCalledWith(String(trip.id), 'packing:bag-totals', {}, undefined);
expect(broadcastMock).toHaveBeenCalledTimes(2);
expect(testDb.prepare('SELECT bag_id FROM packing_items WHERE trip_id = ?').get(trip.id)).toEqual({ bag_id: null });
expect((await findRow(orm, PackingItems, { trip: trip.id }))!.bag_id).toBeNull();
});
});
@@ -278,8 +290,7 @@ describe('Tool: set_bag_members', () => {
it('sets bag members and broadcasts', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const r = testDb.prepare('INSERT INTO packing_bags (trip_id, name, color) VALUES (?, ?, ?)').run(trip.id, 'My Bag', '#123456');
const bagId = r.lastInsertRowid as number;
const { id: bagId } = await makePackingBag(orm, trip.id, { name: 'My Bag', color: '#123456' });
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'set_bag_members',
@@ -295,9 +306,8 @@ describe('Tool: set_bag_members', () => {
it('clears bag members when passed empty array', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const r = testDb.prepare('INSERT INTO packing_bags (trip_id, name, color) VALUES (?, ?, ?)').run(trip.id, 'My Bag', '#123456');
const bagId = r.lastInsertRowid as number;
testDb.prepare('INSERT OR IGNORE INTO packing_bag_members (bag_id, user_id) VALUES (?, ?)').run(bagId, user.id);
const { id: bagId } = await makePackingBag(orm, trip.id, { name: 'My Bag', color: '#123456' });
await addPackingBagMembers(orm, bagId, [user.id]);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'set_bag_members',
@@ -351,7 +361,7 @@ describe('Tool: set_packing_category_assignees', () => {
it('clears assignees when passed empty array', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
testDb.prepare('INSERT INTO packing_category_assignees (trip_id, category_name, user_id) VALUES (?, ?, ?)').run(trip.id, 'Clothing', user.id);
await insertRow(orm, PackingCategoryAssignees, { trip: trip.id, category_name: 'Clothing', user: user.id });
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'set_packing_category_assignees',
@@ -384,16 +394,14 @@ describe('Tool: apply_packing_template', () => {
it('applies a template and pings the bag weights once (#2191)', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const template = testDb.prepare("INSERT INTO packing_templates (name, created_by) VALUES ('Hiking', ?)").run(user.id);
const category = testDb.prepare("INSERT INTO packing_template_categories (template_id, name, sort_order) VALUES (?, 'Gear', 0)")
.run(template.lastInsertRowid);
testDb.prepare("INSERT INTO packing_template_items (category_id, name, sort_order) VALUES (?, 'Boots', 0)")
.run(category.lastInsertRowid);
const templateId = await insertRow(orm, PackingTemplates, { name: 'Hiking', createdByRef: user.id });
const categoryId = await insertRow(orm, PackingTemplateCategories, { template: templateId, name: 'Gear', sort_order: 0 });
await insertRow(orm, PackingTemplateItems, { category: categoryId, name: 'Boots', sort_order: 0 });
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'apply_packing_template',
arguments: { tripId: trip.id, templateId: Number(template.lastInsertRowid) },
arguments: { tripId: trip.id, templateId },
});
expect(result.isError).toBeFalsy();
// Applied items can carry weights, so the room has to re-read the totals.
@@ -531,8 +539,8 @@ describe('Tool: delete_packing_template', () => {
arguments: { templateId: id },
})) as any;
expect(deleted.success).toBe(true);
const remaining = testDb.prepare('SELECT count(*) as cnt FROM packing_templates WHERE id = ?').get(id) as any;
expect(remaining.cnt).toBe(0);
const remaining = await countRows(orm, PackingTemplates, { id });
expect(remaining).toBe(0);
});
});
+85 -77
View File
@@ -32,6 +32,19 @@ import { resetTestDb } from '../../helpers/test-db';
import { createUser, createTrip, createPackingItem, addTripMember } 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 { inContext } from '../../helpers/factories/context';
import { countRows, findRow, insertRow, updateRows } from '../../helpers/factories/rows';
import { addPackingBagMembers, addPackingItemRecipients, makePackingBag } from '../../helpers/factories/packing';
import { setAddonEnabled } from '../../helpers/factories/settings';
import { PackingBags } from '../../../src/db/entities/PackingBags.entity';
import { PackingItems } from '../../../src/db/entities/PackingItems.entity';
let orm: TestOrm;
beforeAll(async () => {
orm = await createTestOrm(testDb);
});
beforeEach(() => {
resetTestDb(testDb);
@@ -39,7 +52,8 @@ beforeEach(() => {
delete process.env.DEMO_MODE;
});
afterAll(() => {
afterAll(async () => {
await orm.close();
testDb.close();
});
@@ -124,17 +138,16 @@ describe('Tool: create_packing_item', () => {
// which is the whole three-tier model the REST route has taken since #858.
// ---------------------------------------------------------------------------
const itemRow = (id: number) =>
testDb.prepare('SELECT * FROM packing_items WHERE id = ?').get(id) as
{ id: number; checked: number; is_private: number; owner_id: number | null; bag_id: number | null; quantity: number; weight_grams: number | null };
const itemRow = async (id: number) => (await findRow(orm, PackingItems, { id }))!;
/** The id of the item a create call just made, typed rather than cast wide open. */
const createdItemId = (result: Parameters<typeof parseToolResult>[0]) =>
(parseToolResult(result) as { item: { id: number } }).item.id;
const recipientIds = (itemId: number) =>
(testDb.prepare('SELECT user_id FROM packing_item_recipients WHERE item_id = ? ORDER BY user_id').all(itemId) as { user_id: number }[])
.map((r) => r.user_id);
const recipientIds = async (itemId: number) =>
(await inContext(orm, (em) => em.getRepository(PackingItems).listRecipientsForItems([itemId])))
.map((r) => r.user_id)
.sort((a, b) => a - b);
describe('Tool: create_packing_item sharing', () => {
it('puts a personal item on the caller\'s own list', async () => {
@@ -145,7 +158,7 @@ describe('Tool: create_packing_item sharing', () => {
name: 'create_packing_item',
arguments: { tripId: trip.id, name: 'Insulin pens', visibility: 'personal' },
});
const row = itemRow(createdItemId(result));
const row = await itemRow(createdItemId(result));
expect(row.is_private).toBe(1);
expect(row.owner_id).toBe(user.id);
});
@@ -163,8 +176,8 @@ describe('Tool: create_packing_item sharing', () => {
arguments: { tripId: trip.id, name: 'Sunscreen', visibility: 'shared', recipient_ids: [mate.id, stranger.id] },
});
const itemId = createdItemId(result);
expect(itemRow(itemId).is_private).toBe(1);
expect(recipientIds(itemId)).toEqual([mate.id]);
expect((await itemRow(itemId)).is_private).toBe(1);
expect(await recipientIds(itemId)).toEqual([mate.id]);
});
});
@@ -176,7 +189,7 @@ describe('Tool: create_packing_item sharing', () => {
name: 'create_packing_item',
arguments: { tripId: trip.id, name: 'Gift', is_private: true },
});
expect(itemRow(createdItemId(result)).is_private).toBe(1);
expect((await itemRow(createdItemId(result))).is_private).toBe(1);
});
});
@@ -188,7 +201,7 @@ describe('Tool: create_packing_item sharing', () => {
name: 'create_packing_item',
arguments: { tripId: trip.id, name: 'Charger', checked: true },
});
expect(itemRow(createdItemId(result)).checked).toBe(1);
expect((await itemRow(createdItemId(result))).checked).toBe(1);
});
});
@@ -230,13 +243,13 @@ describe('Tool: create_packing_item bag, quantity, weight (#2154)', () => {
it('creates the item with its bag, quantity and weight in one write', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const bagId = Number(testDb.prepare('INSERT INTO packing_bags (trip_id, name) VALUES (?, ?)').run(trip.id, 'Carry-On').lastInsertRowid);
const { id: bagId } = await makePackingBag(orm, trip.id, { name: 'Carry-On' });
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'create_packing_item',
arguments: { tripId: trip.id, name: 'Tent', bag_id: bagId, quantity: 3, weight_grams: 250 },
});
const row = itemRow(createdItemId(result));
const row = await itemRow(createdItemId(result));
expect(row.bag_id).toBe(bagId);
expect(row.quantity).toBe(3);
expect(row.weight_grams).toBe(250);
@@ -247,7 +260,7 @@ describe('Tool: create_packing_item bag, quantity, weight (#2154)', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const otherTrip = createTrip(testDb, user.id);
const foreignBag = Number(testDb.prepare('INSERT INTO packing_bags (trip_id, name) VALUES (?, ?)').run(otherTrip.id, 'Foreign').lastInsertRowid);
const { id: foreignBag } = await makePackingBag(orm, otherTrip.id, { name: 'Foreign' });
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'create_packing_item',
@@ -256,7 +269,7 @@ describe('Tool: create_packing_item bag, quantity, weight (#2154)', () => {
expect(result.isError).toBe(true);
expect(errorText(result)).toContain('Bag not found');
});
expect((testDb.prepare('SELECT COUNT(*) AS n FROM packing_items WHERE trip_id = ?').get(trip.id) as { n: number }).n).toBe(0);
expect(await countRows(orm, PackingItems, { trip: trip.id })).toBe(0);
});
});
@@ -333,21 +346,20 @@ describe('Tool: update_packing_item', () => {
// ---------------------------------------------------------------------------
describe('Tool: update_packing_item bag, quantity, weight, privacy', () => {
const makeBag = (tripId: number, name = 'Carry-On') =>
Number(testDb.prepare('INSERT INTO packing_bags (trip_id, name) VALUES (?, ?)').run(tripId, name).lastInsertRowid);
const makeBag = async (tripId: number, name = 'Carry-On') => (await makePackingBag(orm, tripId, { name })).id;
it('moves an item into a bag', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const item = createPackingItem(testDb, trip.id);
const bagId = makeBag(trip.id);
const bagId = await makeBag(trip.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'update_packing_item',
arguments: { tripId: trip.id, itemId: item.id, bag_id: bagId },
});
expect(result.isError).toBeFalsy();
expect(itemRow(item.id).bag_id).toBe(bagId);
expect((await itemRow(item.id)).bag_id).toBe(bagId);
});
});
@@ -355,14 +367,14 @@ describe('Tool: update_packing_item bag, quantity, weight, privacy', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const item = createPackingItem(testDb, trip.id);
const bagId = makeBag(trip.id);
testDb.prepare('UPDATE packing_items SET bag_id = ? WHERE id = ?').run(bagId, item.id);
const bagId = await makeBag(trip.id);
await updateRows(orm, PackingItems, { id: item.id }, { bag: bagId });
await withHarness(user.id, async (h) => {
await h.client.callTool({
name: 'update_packing_item',
arguments: { tripId: trip.id, itemId: item.id, bag_id: null },
});
expect(itemRow(item.id).bag_id).toBeNull();
expect((await itemRow(item.id)).bag_id).toBeNull();
});
});
@@ -375,7 +387,7 @@ describe('Tool: update_packing_item bag, quantity, weight, privacy', () => {
name: 'update_packing_item',
arguments: { tripId: trip.id, itemId: item.id, quantity: 3 },
});
expect(itemRow(item.id).quantity).toBe(3);
expect((await itemRow(item.id)).quantity).toBe(3);
});
});
@@ -388,13 +400,13 @@ describe('Tool: update_packing_item bag, quantity, weight, privacy', () => {
name: 'update_packing_item',
arguments: { tripId: trip.id, itemId: item.id, weight_grams: 850 },
});
expect(itemRow(item.id).weight_grams).toBe(850);
expect((await itemRow(item.id)).weight_grams).toBe(850);
await h.client.callTool({
name: 'update_packing_item',
arguments: { tripId: trip.id, itemId: item.id, weight_grams: null },
});
expect(itemRow(item.id).weight_grams).toBeNull();
expect((await itemRow(item.id)).weight_grams).toBeNull();
});
});
@@ -402,14 +414,14 @@ describe('Tool: update_packing_item bag, quantity, weight, privacy', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const item = createPackingItem(testDb, trip.id);
const bagId = makeBag(trip.id);
testDb.prepare('UPDATE packing_items SET bag_id = ? WHERE id = ?').run(bagId, item.id);
const bagId = await makeBag(trip.id);
await updateRows(orm, PackingItems, { id: item.id }, { bag: bagId });
await withHarness(user.id, async (h) => {
await h.client.callTool({
name: 'update_packing_item',
arguments: { tripId: trip.id, itemId: item.id, name: 'Renamed' },
});
expect(itemRow(item.id).bag_id).toBe(bagId);
expect((await itemRow(item.id)).bag_id).toBe(bagId);
});
});
@@ -423,7 +435,7 @@ describe('Tool: update_packing_item bag, quantity, weight, privacy', () => {
arguments: { tripId: trip.id, itemId: item.id, is_private: true },
});
});
const row = itemRow(item.id);
const row = await itemRow(item.id);
expect(row.is_private).toBe(1);
// An unowned item is claimed by whoever privatizes it, or the visibility
// filter would have nobody to match.
@@ -436,14 +448,14 @@ describe('Tool: update_packing_item bag, quantity, weight, privacy', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const item = createPackingItem(testDb, trip.id);
testDb.prepare('UPDATE packing_items SET is_private = 1, owner_id = ? WHERE id = ?').run(user.id, item.id);
await updateRows(orm, PackingItems, { id: item.id }, { is_private: 1, owner: user.id });
await withHarness(user.id, async (h) => {
await h.client.callTool({
name: 'update_packing_item',
arguments: { tripId: trip.id, itemId: item.id, is_private: false },
});
});
expect(itemRow(item.id).is_private).toBe(0);
expect((await itemRow(item.id)).is_private).toBe(0);
// Created first: the members who never had the row cannot apply an update to it.
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'packing:created', expect.any(Object));
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'packing:updated', expect.any(Object));
@@ -460,7 +472,7 @@ describe('Tool: update_packing_item bag, quantity, weight, privacy', () => {
});
expect(result.isError).toBe(true);
expect(errorText(result)).toContain('bag_id');
expect(itemRow(item.id).bag_id).toBeNull();
expect((await itemRow(item.id)).bag_id).toBeNull();
});
});
@@ -469,7 +481,7 @@ describe('Tool: update_packing_item bag, quantity, weight, privacy', () => {
const trip = createTrip(testDb, user.id);
const otherTrip = createTrip(testDb, user.id);
const item = createPackingItem(testDb, trip.id);
const foreignBag = makeBag(otherTrip.id, 'Foreign');
const foreignBag = await makeBag(otherTrip.id, 'Foreign');
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'update_packing_item',
@@ -477,7 +489,7 @@ describe('Tool: update_packing_item bag, quantity, weight, privacy', () => {
});
expect(result.isError).toBe(true);
expect(errorText(result)).toContain('Bag not found');
expect(itemRow(item.id).bag_id).toBeNull();
expect((await itemRow(item.id)).bag_id).toBeNull();
});
});
});
@@ -498,7 +510,7 @@ describe('Tool: set_packing_item_sharing', () => {
});
expect(result.isError).toBeFalsy();
});
const row = itemRow(item.id);
const row = await itemRow(item.id);
expect(row.is_private).toBe(1);
expect(row.owner_id).toBe(user.id);
});
@@ -516,8 +528,8 @@ describe('Tool: set_packing_item_sharing', () => {
arguments: { tripId: trip.id, itemId: item.id, visibility: 'shared', recipient_ids: [mate.id, stranger.id] },
});
});
expect(itemRow(item.id).is_private).toBe(1);
expect(recipientIds(item.id)).toEqual([mate.id]);
expect((await itemRow(item.id)).is_private).toBe(1);
expect(await recipientIds(item.id)).toEqual([mate.id]);
});
it('returns an item to the common list and forgets its recipients', async () => {
@@ -526,16 +538,16 @@ describe('Tool: set_packing_item_sharing', () => {
const trip = createTrip(testDb, user.id);
addTripMember(testDb, trip.id, mate.id);
const item = createPackingItem(testDb, trip.id);
testDb.prepare('UPDATE packing_items SET is_private = 1, owner_id = ? WHERE id = ?').run(user.id, item.id);
testDb.prepare('INSERT INTO packing_item_recipients (item_id, user_id) VALUES (?, ?)').run(item.id, mate.id);
await updateRows(orm, PackingItems, { id: item.id }, { is_private: 1, owner: user.id });
await addPackingItemRecipients(orm, item.id, [mate.id]);
await withHarness(user.id, async (h) => {
await h.client.callTool({
name: 'set_packing_item_sharing',
arguments: { tripId: trip.id, itemId: item.id, visibility: 'common' },
});
});
expect(itemRow(item.id).is_private).toBe(0);
expect(recipientIds(item.id)).toEqual([]);
expect((await itemRow(item.id)).is_private).toBe(0);
expect(await recipientIds(item.id)).toEqual([]);
});
it('rebuilds the room\'s view: gone for everyone, back for the people who may see it', async () => {
@@ -558,7 +570,7 @@ describe('Tool: set_packing_item_sharing', () => {
const trip = createTrip(testDb, user.id);
addTripMember(testDb, trip.id, mate.id);
const item = createPackingItem(testDb, trip.id);
testDb.prepare('UPDATE packing_items SET owner_id = ? WHERE id = ?').run(mate.id, item.id);
await updateRows(orm, PackingItems, { id: item.id }, { owner: mate.id });
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'set_packing_item_sharing',
@@ -566,7 +578,7 @@ describe('Tool: set_packing_item_sharing', () => {
});
expect(result.isError).toBe(true);
});
expect(itemRow(item.id).is_private).toBe(0);
expect((await itemRow(item.id)).is_private).toBe(0);
});
it('returns error for item not found', async () => {
@@ -593,7 +605,7 @@ describe('Tool: set_packing_item_sharing', () => {
});
expect(result.isError).toBe(true);
});
expect(itemRow(item.id).is_private).toBe(0);
expect((await itemRow(item.id)).is_private).toBe(0);
});
it('blocks demo user', async () => {
@@ -621,7 +633,7 @@ describe('Tool: set_packing_item_sharing', () => {
});
expect(result.isError).toBe(true);
expect(errorText(result)).toContain('visibility');
expect(itemRow(item.id).is_private).toBe(0);
expect((await itemRow(item.id)).is_private).toBe(0);
});
});
});
@@ -631,49 +643,46 @@ describe('Tool: set_packing_item_sharing', () => {
// ---------------------------------------------------------------------------
describe('Tool: update_packing_bag limit and owner', () => {
const bagRow = (id: number) =>
testDb.prepare('SELECT * FROM packing_bags WHERE id = ?').get(id) as
{ id: number; name: string; weight_limit_grams: number | null; user_id: number | null };
const bagRow = async (id: number) => (await findRow(orm, PackingBags, { id }))!;
const makeBag = (tripId: number) =>
Number(testDb.prepare('INSERT INTO packing_bags (trip_id, name) VALUES (?, ?)').run(tripId, 'Carry-On').lastInsertRowid);
const makeBag = async (tripId: number) => (await makePackingBag(orm, tripId, { name: 'Carry-On' })).id;
it('sets a weight limit', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const bagId = makeBag(trip.id);
const bagId = await makeBag(trip.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'update_packing_bag',
arguments: { tripId: trip.id, bagId, weight_limit_grams: 8000 },
});
expect(result.isError).toBeFalsy();
expect(bagRow(bagId).weight_limit_grams).toBe(8000);
expect((await bagRow(bagId)).weight_limit_grams).toBe(8000);
});
});
it('lifts the limit with an explicit null', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const bagId = makeBag(trip.id);
testDb.prepare('UPDATE packing_bags SET weight_limit_grams = 8000 WHERE id = ?').run(bagId);
const bagId = await makeBag(trip.id);
await updateRows(orm, PackingBags, { id: bagId }, { weight_limit_grams: 8000 });
await withHarness(user.id, async (h) => {
await h.client.callTool({
name: 'update_packing_bag',
arguments: { tripId: trip.id, bagId, weight_limit_grams: null },
});
expect(bagRow(bagId).weight_limit_grams).toBeNull();
expect((await bagRow(bagId)).weight_limit_grams).toBeNull();
});
});
it('leaves the limit alone when the key is omitted', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const bagId = makeBag(trip.id);
testDb.prepare('UPDATE packing_bags SET weight_limit_grams = 8000 WHERE id = ?').run(bagId);
const bagId = await makeBag(trip.id);
await updateRows(orm, PackingBags, { id: bagId }, { weight_limit_grams: 8000 });
await withHarness(user.id, async (h) => {
await h.client.callTool({ name: 'update_packing_bag', arguments: { tripId: trip.id, bagId, name: 'Backpack' } });
const row = bagRow(bagId);
const row = await bagRow(bagId);
expect(row.name).toBe('Backpack');
expect(row.weight_limit_grams).toBe(8000);
});
@@ -684,10 +693,10 @@ describe('Tool: update_packing_bag limit and owner', () => {
const { user: mate } = createUser(testDb);
const trip = createTrip(testDb, user.id);
addTripMember(testDb, trip.id, mate.id);
const bagId = makeBag(trip.id);
const bagId = await makeBag(trip.id);
await withHarness(user.id, async (h) => {
await h.client.callTool({ name: 'update_packing_bag', arguments: { tripId: trip.id, bagId, user_id: mate.id } });
expect(bagRow(bagId).user_id).toBe(mate.id);
expect((await bagRow(bagId)).user_id).toBe(mate.id);
});
});
@@ -695,17 +704,17 @@ describe('Tool: update_packing_bag limit and owner', () => {
const { user } = createUser(testDb);
const { user: stranger } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const bagId = makeBag(trip.id);
const bagId = await makeBag(trip.id);
await withHarness(user.id, async (h) => {
await h.client.callTool({ name: 'update_packing_bag', arguments: { tripId: trip.id, bagId, user_id: stranger.id } });
expect(bagRow(bagId).user_id).toBeNull();
expect((await bagRow(bagId)).user_id).toBeNull();
});
});
it('refuses a weight limit that is not a number', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const bagId = makeBag(trip.id);
const bagId = await makeBag(trip.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'update_packing_bag',
@@ -713,7 +722,7 @@ describe('Tool: update_packing_bag limit and owner', () => {
});
expect(result.isError).toBe(true);
expect(errorText(result)).toContain('weight_limit_grams');
expect(bagRow(bagId).weight_limit_grams).toBeNull();
expect((await bagRow(bagId)).weight_limit_grams).toBeNull();
});
});
});
@@ -741,7 +750,7 @@ describe('Tool: toggle_packing_item', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const item = createPackingItem(testDb, trip.id);
testDb.prepare('UPDATE packing_items SET checked = 1 WHERE id = ?').run(item.id);
await updateRows(orm, PackingItems, { id: item.id }, { checked: 1 });
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'toggle_packing_item',
@@ -796,7 +805,7 @@ describe('Tool: delete_packing_item', () => {
const result = await h.client.callTool({ name: 'delete_packing_item', arguments: { tripId: trip.id, itemId: item.id } });
const data = parseToolResult(result) as any;
expect(data.success).toBe(true);
expect(testDb.prepare('SELECT id FROM packing_items WHERE id = ?').get(item.id)).toBeUndefined();
expect(await findRow(orm, PackingItems, { id: item.id })).toBeNull();
});
});
@@ -883,7 +892,7 @@ describe('Packing tools — packing addon gating', () => {
it('registers nothing (tools or resources) 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);
@@ -893,7 +902,7 @@ describe('Packing tools — packing addon gating', () => {
await expect(h.client.readResource({ uri: `trek://trips/${trip.id}/packing/bags` })).rejects.toThrow();
});
} finally {
testDb.prepare('UPDATE addons SET enabled = 1 WHERE id = ?').run(ADDON_IDS.PACKING);
await setAddonEnabled(orm, ADDON_IDS.PACKING, true);
}
});
});
@@ -919,8 +928,7 @@ describe('Resource: trek://trips/{tripId}/packing', () => {
const { user } = createUser(testDb);
const { user: owner } = createUser(testDb);
const trip = createTrip(testDb, user.id);
testDb.prepare('INSERT INTO packing_items (trip_id, name, checked, sort_order, is_private, owner_id) VALUES (?, ?, 0, 0, 1, ?)')
.run(trip.id, 'Secret gift', owner.id);
await insertRow(orm, PackingItems, { trip: trip.id, name: 'Secret gift', checked: 0, sort_order: 0, is_private: 1, owner: owner.id });
await withHarness(user.id, async (h) => {
const result = await h.client.readResource({ uri: `trek://trips/${trip.id}/packing` });
expect(parseResourceResult(result)).toEqual([]);
@@ -954,8 +962,8 @@ describe('Resource: trek://trips/{tripId}/packing/bags', () => {
it('returns the bags with their members', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const bagId = Number(testDb.prepare('INSERT INTO packing_bags (trip_id, name) VALUES (?, ?)').run(trip.id, 'Carry-On').lastInsertRowid);
testDb.prepare('INSERT INTO packing_bag_members (bag_id, user_id) VALUES (?, ?)').run(bagId, user.id);
const { id: bagId } = await makePackingBag(orm, trip.id, { name: 'Carry-On' });
await addPackingBagMembers(orm, bagId, [user.id]);
await withHarness(user.id, async (h) => {
const result = await h.client.readResource({ uri: `trek://trips/${trip.id}/packing/bags` });
const bags = parseResourceResult(result) as { name: string; members: { user_id: number }[] }[];
@@ -1000,13 +1008,13 @@ describe('Resource: trek://trips/{tripId}/packing/bags', () => {
*/
describe('a restricted packing item over MCP', () => {
const makePrivate = (itemId: number, ownerId: number) =>
testDb.prepare('UPDATE packing_items SET is_private = 1, owner_id = ? WHERE id = ?').run(ownerId, itemId);
updateRows(orm, PackingItems, { id: itemId }, { is_private: 1, owner: ownerId });
it('is ticked off for its owner alone, not for the whole trip', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const item = createPackingItem(testDb, trip.id);
makePrivate(item.id, user.id);
await makePrivate(item.id, user.id);
await withHarness(user.id, async (h) => {
await h.client.callTool({
@@ -1027,7 +1035,7 @@ describe('a restricted packing item over MCP', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const item = createPackingItem(testDb, trip.id);
makePrivate(item.id, user.id);
await makePrivate(item.id, user.id);
await withHarness(user.id, async (h) => {
await h.client.callTool({
@@ -1052,7 +1060,7 @@ describe('a restricted packing item over MCP', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const item = createPackingItem(testDb, trip.id);
makePrivate(item.id, user.id);
await makePrivate(item.id, user.id);
await withHarness(user.id, async (h) => {
await h.client.callTool({
+97 -81
View File
@@ -34,17 +34,36 @@ import { createUser, createTrip, createPlace, createDay, createDayAssignment, cr
import { createMcpHarness, parseToolResult, parseResourceResult, type McpHarness } from '../../helpers/mcp-harness';
import { MapsService } from '../../../src/nest/maps/maps.service';
import { PlacesService } from '../../../src/nest/places/places.service';
import { createTestOrm, type TestOrm } from '../../helpers/test-orm';
import { countRows, findRow, findRows, insertRow, updateRows } from '../../helpers/factories/rows';
import { linkTripToJourney } from '../../helpers/factories/journeys';
import { makeTripFile } from '../../helpers/factories/files';
import { makeBudgetItem } from '../../helpers/factories/budget';
import { makeDayAssignment } from '../../helpers/factories/itinerary';
import { BudgetItems } from '../../../src/db/entities/BudgetItems.entity';
import { Categories } from '../../../src/db/entities/Categories.entity';
import { DayAssignments } from '../../../src/db/entities/DayAssignments.entity';
import { JourneyEntries } from '../../../src/db/entities/JourneyEntries.entity';
import { PlaceRatings } from '../../../src/db/entities/PlaceRatings.entity';
import { PlaceRegions } from '../../../src/db/entities/PlaceRegions.entity';
import { Places } from '../../../src/db/entities/Places.entity';
let orm: TestOrm;
beforeAll(async () => {
orm = await createTestOrm(testDb);
});
/** Link a journey to a trip so journey-skeleton sync has a target. */
function linkJourney(journeyId: number, tripId: number) {
testDb.prepare('INSERT INTO journey_trips (journey_id, trip_id, added_at) VALUES (?, ?, ?)').run(journeyId, tripId, Date.now());
return linkTripToJourney(orm, journeyId, tripId);
}
function skeletonFor(journeyId: number, placeId: number) {
return testDb.prepare('SELECT * FROM journey_entries WHERE journey_id = ? AND source_place_id = ?').get(journeyId, placeId) as any;
return findRow(orm, JourneyEntries, { journey: journeyId, sourcePlace: placeId });
}
/** The stored thumbnail, read off the row rather than out of the tool's echo. */
function imageOf(placeId: number): string | null {
return (testDb.prepare('SELECT image_url FROM places WHERE id = ?').get(placeId) as { image_url: string | null }).image_url;
async function imageOf(placeId: number): Promise<string | null | undefined> {
return (await findRow(orm, Places, { id: placeId }))!.image_url;
}
beforeEach(() => {
@@ -55,7 +74,8 @@ beforeEach(() => {
delete process.env.DEMO_MODE;
});
afterAll(() => {
afterAll(async () => {
await orm.close();
testDb.close();
});
@@ -72,7 +92,7 @@ describe('Tool: create_place', () => {
it('creates a place with all fields', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const cat = testDb.prepare('SELECT id FROM categories LIMIT 1').get() as { id: number };
const cat = (await findRow(orm, Categories, {}))!;
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
@@ -122,7 +142,7 @@ describe('Tool: create_place', () => {
});
});
const row = testDb.prepare('SELECT image_url FROM places WHERE trip_id = ? AND name = ?').get(trip.id, 'Pictured') as { image_url: string };
const row = (await findRow(orm, Places, { trip: trip.id, name: 'Pictured' }))!;
expect(row.image_url).toBe('https://cdn.example.com/spot.jpg');
});
@@ -137,7 +157,7 @@ describe('Tool: create_place', () => {
});
});
const row = testDb.prepare('SELECT image_url FROM places WHERE trip_id = ? AND name = ?').get(trip.id, 'Proxied') as { image_url: string };
const row = (await findRow(orm, Places, { trip: trip.id, name: 'Proxied' }))!;
expect(row.image_url).toBe('/api/maps/place-photo/ChIJabc~p0/bytes');
});
@@ -156,7 +176,7 @@ describe('Tool: create_place', () => {
expect((result.content as { text: string }[])[0].text).toMatch(/Invalid arguments/);
});
expect(testDb.prepare('SELECT COUNT(*) AS n FROM places WHERE trip_id = ?').get(trip.id)).toEqual({ n: 0 });
expect(await countRows(orm, Places, { trip: trip.id })).toBe(0);
});
it('broadcasts place:created event', async () => {
@@ -199,8 +219,8 @@ describe('Tool: set_place_image_from_file (#1242)', () => {
const trip = createTrip(testDb, user.id);
const other = createTrip(testDb, user.id);
const place = createPlace(testDb, trip.id);
const pdf = Number(testDb.prepare("INSERT INTO trip_files (trip_id, filename, original_name, mime_type) VALUES (?, 'a.pdf', 'a.pdf', 'application/pdf')").run(trip.id).lastInsertRowid);
const foreign = Number(testDb.prepare("INSERT INTO trip_files (trip_id, filename, original_name, mime_type) VALUES (?, 'b.jpg', 'b.jpg', 'image/jpeg')").run(other.id).lastInsertRowid);
const pdf = (await makeTripFile(orm, trip.id, { filename: 'a.pdf', original_name: 'a.pdf', mime_type: 'application/pdf' })).id;
const foreign = (await makeTripFile(orm, other.id, { filename: 'b.jpg', original_name: 'b.jpg', mime_type: 'image/jpeg' })).id;
await withHarness(user.id, async (h) => {
const notImage = await h.client.callTool({ name: 'set_place_image_from_file', arguments: { tripId: trip.id, placeId: place.id, fileId: pdf } });
expect(notImage.isError).toBe(true);
@@ -261,19 +281,22 @@ describe('Tool: update_place', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const place = createPlace(testDb, trip.id, { name: 'Hotel', lat: 48.8566, lng: 2.3522 });
testDb.prepare("INSERT INTO place_regions (place_id, country_code, region_code, region_name) VALUES (?, 'FR', 'FR-IDF', 'Ile-de-France')").run(place.id);
const cachedRegion = () => testDb.prepare('SELECT country_code FROM place_regions WHERE place_id = ?').get(place.id);
await insertRow(orm, PlaceRegions, { place: place.id, country_code: 'FR', region_code: 'FR-IDF', region_name: 'Ile-de-France' });
const cachedRegion = async () => {
const row = await findRow(orm, PlaceRegions, { place: place.id });
return row ? { country_code: row.country_code } : undefined;
};
await withHarness(user.id, async (h) => {
await h.client.callTool({ name: 'update_place', arguments: { tripId: trip.id, placeId: place.id, name: 'Hotel Adlon' } });
expect(cachedRegion()).toEqual({ country_code: 'FR' });
expect(await cachedRegion()).toEqual({ country_code: 'FR' });
const moved = await h.client.callTool({
name: 'update_place',
arguments: { tripId: trip.id, placeId: place.id, lat: 52.5163, lng: 13.3777, address: 'Unter den Linden 77, Berlin, Germany' },
});
expect(moved.isError).toBeFalsy();
expect(cachedRegion()).toBeUndefined();
expect(await cachedRegion()).toBeUndefined();
});
});
@@ -297,7 +320,7 @@ describe('Tool: update_place', () => {
const refused = await h.client.callTool({ name: 'update_place', arguments: { tripId: trip.id, placeId: place.id, stopType: 'fuel' } });
expect(refused.isError).toBe(true);
expect((refused.content as Array<{ text: string }>)[0].text).toMatch(/Unrecognized key.*stopType/);
expect((testDb.prepare('SELECT stop_type FROM places WHERE id = ?').get(place.id) as { stop_type: string | null }).stop_type).toBeNull();
expect((await findRow(orm, Places, { id: place.id }))!.stop_type).toBeNull();
expect(broadcastMock).not.toHaveBeenCalled();
const fixed = parseToolResult(await h.client.callTool({ name: 'update_place', arguments: { tripId: trip.id, placeId: place.id, stop_type: 'fuel' } })) as any;
expect(fixed.place.stop_type).toBe('fuel');
@@ -316,7 +339,7 @@ describe('Tool: update_place', () => {
});
});
expect(imageOf(place.id)).toBe('https://cdn.example.com/new.jpg');
expect(await imageOf(place.id)).toBe('https://cdn.example.com/new.jpg');
});
// The service reads image_url through a `!== undefined` sentinel, so null is the
@@ -325,11 +348,11 @@ describe('Tool: update_place', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const place = createPlace(testDb, trip.id, { name: 'Pictured' });
testDb.prepare('UPDATE places SET image_url = ? WHERE id = ?').run('/uploads/places/old.jpg', place.id);
await updateRows(orm, Places, { id: place.id }, { image_url: '/uploads/places/old.jpg' });
await withHarness(user.id, async (h) => {
await h.client.callTool({ name: 'update_place', arguments: { tripId: trip.id, placeId: place.id, name: 'Renamed' } });
expect(imageOf(place.id)).toBe('/uploads/places/old.jpg');
expect(await imageOf(place.id)).toBe('/uploads/places/old.jpg');
const result = await h.client.callTool({
name: 'update_place',
@@ -338,14 +361,14 @@ describe('Tool: update_place', () => {
expect((parseToolResult(result) as any).place.image_url).toBeNull();
});
expect(imageOf(place.id)).toBeNull();
expect(await imageOf(place.id)).toBeNull();
});
it('refuses an image_url outside the four allowed shapes', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const place = createPlace(testDb, trip.id, { name: 'Pictured' });
testDb.prepare('UPDATE places SET image_url = ? WHERE id = ?').run('https://cdn.example.com/keep.jpg', place.id);
await updateRows(orm, Places, { id: place.id }, { image_url: 'https://cdn.example.com/keep.jpg' });
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
@@ -356,7 +379,7 @@ describe('Tool: update_place', () => {
expect((result.content as { text: string }[])[0].text).toMatch(/Invalid arguments/);
});
expect(imageOf(place.id)).toBe('https://cdn.example.com/keep.jpg');
expect(await imageOf(place.id)).toBe('https://cdn.example.com/keep.jpg');
});
it('returns error for place not found in trip', async () => {
@@ -430,8 +453,8 @@ describe('Tool: bulk_update_places', () => {
expect((parseToolResult(result) as any).count).toBe(2);
});
expect(imageOf(a.id)).toBe('https://cdn.example.com/batch.jpg');
expect(imageOf(b.id)).toBe('https://cdn.example.com/batch.jpg');
expect(await imageOf(a.id)).toBe('https://cdn.example.com/batch.jpg');
expect(await imageOf(b.id)).toBe('https://cdn.example.com/batch.jpg');
});
// A lone null still counts as a field, which is what makes "strip the pictures
@@ -441,7 +464,7 @@ describe('Tool: bulk_update_places', () => {
const trip = createTrip(testDb, user.id);
const a = createPlace(testDb, trip.id, { name: 'A' });
const b = createPlace(testDb, trip.id, { name: 'B' });
testDb.prepare('UPDATE places SET image_url = ? WHERE id IN (?, ?)').run('https://cdn.example.com/old.jpg', a.id, b.id);
await updateRows(orm, Places, { id: { $in: [a.id, b.id] } }, { image_url: 'https://cdn.example.com/old.jpg' });
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
@@ -451,8 +474,8 @@ describe('Tool: bulk_update_places', () => {
expect((parseToolResult(result) as any).count).toBe(2);
});
expect(imageOf(a.id)).toBeNull();
expect(imageOf(b.id)).toBeNull();
expect(await imageOf(a.id)).toBeNull();
expect(await imageOf(b.id)).toBeNull();
});
it('errors when no update fields are provided', async () => {
@@ -490,7 +513,7 @@ describe('Tool: delete_place', () => {
const result = await h.client.callTool({ name: 'delete_place', arguments: { tripId: trip.id, placeId: place.id } });
const data = parseToolResult(result) as any;
expect(data.success).toBe(true);
expect(testDb.prepare('SELECT id FROM places WHERE id = ?').get(place.id)).toBeUndefined();
expect(await findRow(orm, Places, { id: place.id })).toBeNull();
});
});
@@ -498,13 +521,11 @@ describe('Tool: delete_place', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const place = createPlace(testDb, trip.id);
const itemId = Number(testDb
.prepare("INSERT INTO budget_items (trip_id, name, total_price, place_id) VALUES (?, 'Tickets', 34, ?)")
.run(trip.id, place.id).lastInsertRowid);
const { id: itemId } = await makeBudgetItem(orm, trip.id, { name: 'Tickets', total_price: 34, place: place.id });
await withHarness(user.id, async (h) => {
await h.client.callTool({ name: 'delete_place', arguments: { tripId: trip.id, placeId: place.id } });
expect(testDb.prepare('SELECT id FROM budget_items WHERE id = ?').get(itemId)).toBeUndefined();
expect(await findRow(orm, BudgetItems, { id: itemId })).toBeNull();
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'budget:deleted', expect.objectContaining({ itemId }));
});
});
@@ -546,17 +567,14 @@ describe('Tool: delete_place', () => {
const place = createPlace(testDb, trip.id);
createDayAssignment(testDb, day.id, place.id);
const journey = createJourney(testDb, user.id);
linkJourney(journey.id, trip.id);
await linkJourney(journey.id, trip.id);
// Materialise the skeleton for the assigned place.
testDb.prepare(
`INSERT INTO journey_entries (journey_id, source_trip_id, source_place_id, author_id, type, title, entry_date, sort_order, created_at, updated_at)
VALUES (?, ?, ?, ?, 'skeleton', ?, ?, 0, ?, ?)`,
).run(journey.id, trip.id, place.id, user.id, place.name, '2026-05-01', Date.now(), Date.now());
expect(skeletonFor(journey.id, place.id)).toBeDefined();
await seedSkeleton(journey.id, trip.id, place.id, user.id, place.name);
expect(await skeletonFor(journey.id, place.id)).not.toBeNull();
await withHarness(user.id, async (h) => {
await h.client.callTool({ name: 'delete_place', arguments: { tripId: trip.id, placeId: place.id } });
expect(skeletonFor(journey.id, place.id)).toBeUndefined();
expect(await skeletonFor(journey.id, place.id)).toBeNull();
});
});
});
@@ -571,16 +589,16 @@ describe('Tool: create_and_assign_place', () => {
const trip = createTrip(testDb, user.id);
const day = createDay(testDb, trip.id);
const journey = createJourney(testDb, user.id);
linkJourney(journey.id, trip.id);
await linkJourney(journey.id, trip.id);
await withHarness(user.id, async (h) => {
const result = parseToolResult(
await h.client.callTool({ name: 'create_and_assign_place', arguments: { tripId: trip.id, dayId: day.id, name: 'Fresh POI' } }),
) as any;
const skeleton = skeletonFor(journey.id, result.place.id);
expect(skeleton).toBeDefined();
expect(skeleton.type).toBe('skeleton');
expect(skeleton.title).toBe('Fresh POI');
const skeleton = await skeletonFor(journey.id, result.place.id);
expect(skeleton).not.toBeNull();
expect(skeleton!.type).toBe('skeleton');
expect(skeleton!.title).toBe('Fresh POI');
});
});
@@ -594,7 +612,7 @@ describe('Tool: create_and_assign_place', () => {
name: 'create_and_assign_place',
arguments: { tripId: trip.id, dayId: day.id, name: 'Pictured', image_url: 'https://cdn.example.com/day.jpg' },
})) as any;
expect(imageOf(result.place.id)).toBe('https://cdn.example.com/day.jpg');
expect(await imageOf(result.place.id)).toBe('https://cdn.example.com/day.jpg');
});
});
});
@@ -733,7 +751,7 @@ describe('Tool: list_places', () => {
const place1 = createPlace(testDb, trip.id, { name: 'Orphan Place' });
const place2 = createPlace(testDb, trip.id, { name: 'Assigned Place' });
const day = createDay(testDb, trip.id);
testDb.prepare('INSERT INTO day_assignments (day_id, place_id) VALUES (?, ?)').run(day.id, place2.id);
await makeDayAssignment(orm, day.id, place2.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'list_places', arguments: { tripId: trip.id } });
@@ -748,7 +766,7 @@ describe('Tool: list_places', () => {
const orphan = createPlace(testDb, trip.id, { name: 'Orphan Place' });
const assigned = createPlace(testDb, trip.id, { name: 'Assigned Place' });
const day = createDay(testDb, trip.id);
testDb.prepare('INSERT INTO day_assignments (day_id, place_id) VALUES (?, ?)').run(day.id, assigned.id);
await makeDayAssignment(orm, day.id, assigned.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'list_places', arguments: { tripId: trip.id, assignment: 'unassigned' } });
@@ -764,7 +782,7 @@ describe('Tool: list_places', () => {
const orphan = createPlace(testDb, trip.id, { name: 'Orphan Place' });
const assigned = createPlace(testDb, trip.id, { name: 'Assigned Place' });
const day = createDay(testDb, trip.id);
testDb.prepare('INSERT INTO day_assignments (day_id, place_id) VALUES (?, ?)').run(day.id, assigned.id);
await makeDayAssignment(orm, day.id, assigned.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'list_places', arguments: { tripId: trip.id, assignment: 'assigned' } });
@@ -779,7 +797,7 @@ describe('Tool: list_places', () => {
const trip = createTrip(testDb, user.id);
const place = createPlace(testDb, trip.id, { name: 'Only Place' });
const day = createDay(testDb, trip.id);
testDb.prepare('INSERT INTO day_assignments (day_id, place_id) VALUES (?, ?)').run(day.id, place.id);
await makeDayAssignment(orm, day.id, place.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'list_places', arguments: { tripId: trip.id, assignment: 'unassigned' } });
@@ -794,7 +812,7 @@ describe('Tool: list_places', () => {
const orphan = createPlace(testDb, trip.id, { name: 'Louvre Museum' });
const assigned = createPlace(testDb, trip.id, { name: 'Eiffel Tower' });
const day = createDay(testDb, trip.id);
testDb.prepare('INSERT INTO day_assignments (day_id, place_id) VALUES (?, ?)').run(day.id, assigned.id);
await makeDayAssignment(orm, day.id, assigned.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'list_places', arguments: { tripId: trip.id, assignment: 'unassigned', search: 'Louvre' } });
@@ -829,7 +847,7 @@ describe('Tool: rate_place', () => {
const result = await h.client.callTool({ name: 'rate_place', arguments: { tripId: trip.id, placeId: place.id, rating: 4 } });
const data = parseToolResult(result) as any;
expect(data.place.id).toBe(place.id);
const rows = testDb.prepare('SELECT user_id, rating FROM place_ratings WHERE place_id = ?').all(place.id);
const rows = (await findRows(orm, PlaceRatings, { place: place.id })).map(r => ({ user_id: r.user_id, rating: r.rating }));
expect(rows).toEqual([{ user_id: user.id, rating: 4 }]);
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'place:updated', expect.any(Object));
});
@@ -845,8 +863,7 @@ describe('Tool: rate_place', () => {
const result = await h.client.callTool({ name: 'rate_place', arguments: { tripId: trip.id, placeId: place.id } });
const data = parseToolResult(result) as any;
expect(data.place.id).toBe(place.id);
const rows = testDb.prepare('SELECT COUNT(*) AS n FROM place_ratings WHERE place_id = ?').get(place.id) as { n: number };
expect(rows.n).toBe(0);
expect(await countRows(orm, PlaceRatings, { place: place.id })).toBe(0);
});
});
@@ -886,7 +903,7 @@ describe('Tool: bulk_delete_places', () => {
expect(data.deleted.sort()).toEqual([a.id, b.id].sort());
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'place:deleted', { placeId: a.id, _source: 'mcp' });
// The other trip's place survives.
expect(testDb.prepare('SELECT id FROM places WHERE id = ?').get(foreign.id)).toBeTruthy();
expect(await findRow(orm, Places, { id: foreign.id })).toBeTruthy();
});
});
@@ -1205,7 +1222,7 @@ describe('Tool: create_and_assign_place (failure paths)', () => {
});
expect(result.isError).toBe(true);
expect((result.content as { text: string }[])[0].text).toBe('Day not found.');
expect(testDb.prepare('SELECT COUNT(*) AS n FROM places WHERE trip_id = ?').get(trip.id)).toEqual({ n: 0 });
expect(await countRows(orm, Places, { trip: trip.id })).toBe(0);
});
});
@@ -1222,7 +1239,7 @@ describe('Tool: create_and_assign_place (failure paths)', () => {
});
expect(result.isError).toBe(true);
expect((result.content as { text: string }[])[0].text).toBe('Failed to create place and assignment.');
expect(testDb.prepare('SELECT COUNT(*) AS n FROM day_assignments WHERE day_id = ?').get(day.id)).toEqual({ n: 0 });
expect(await countRows(orm, DayAssignments, { day: day.id })).toBe(0);
});
spy.mockRestore();
});
@@ -1233,11 +1250,11 @@ describe('Tool: create_and_assign_place (failure paths)', () => {
// ---------------------------------------------------------------------------
/** Materialise a skeleton entry for an assigned place (same shape the sync writes). */
function seedSkeleton(journeyId: number, tripId: number, placeId: number, userId: number, name: string) {
testDb.prepare(
`INSERT INTO journey_entries (journey_id, source_trip_id, source_place_id, author_id, type, title, entry_date, sort_order, created_at, updated_at)
VALUES (?, ?, ?, ?, 'skeleton', ?, ?, 0, ?, ?)`,
).run(journeyId, tripId, placeId, userId, name, '2026-05-01', Date.now(), Date.now());
async function seedSkeleton(journeyId: number, tripId: number, placeId: number, userId: number, name: string) {
await insertRow(orm, JourneyEntries, {
journey: journeyId, sourceTrip: tripId, sourcePlace: placeId, author: userId, type: 'skeleton', title: name,
entry_date: '2026-05-01', sort_order: 0, created_at: Date.now(), updated_at: Date.now(),
});
}
describe('journey hooks on the MCP delete paths', () => {
@@ -1246,12 +1263,12 @@ describe('journey hooks on the MCP delete paths', () => {
const trip = createTrip(testDb, user.id);
const other = createTrip(testDb, user.id);
const journey = createJourney(testDb, user.id);
linkJourney(journey.id, other.id);
await linkJourney(journey.id, other.id);
const foreign = createPlace(testDb, other.id, { name: 'Theirs' });
const day = createDay(testDb, other.id);
createDayAssignment(testDb, day.id, foreign.id);
seedSkeleton(journey.id, other.id, foreign.id, user.id, 'Theirs');
expect(skeletonFor(journey.id, foreign.id)).toBeDefined();
await seedSkeleton(journey.id, other.id, foreign.id, user.id, 'Theirs');
expect(await skeletonFor(journey.id, foreign.id)).not.toBeNull();
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'delete_place', arguments: { tripId: trip.id, placeId: foreign.id } });
@@ -1259,8 +1276,8 @@ describe('journey hooks on the MCP delete paths', () => {
});
// The other trip's skeleton entry still points at its place, and the row lives.
expect(skeletonFor(journey.id, foreign.id)).toBeDefined();
expect(testDb.prepare('SELECT id FROM places WHERE id = ?').get(foreign.id)).toBeDefined();
expect(await skeletonFor(journey.id, foreign.id)).not.toBeNull();
expect(await findRow(orm, Places, { id: foreign.id })).not.toBeNull();
});
// Without the ordering fix the hook ran AFTER the DELETE, by which point
@@ -1271,12 +1288,12 @@ describe('journey hooks on the MCP delete paths', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const journey = createJourney(testDb, user.id);
linkJourney(journey.id, trip.id);
await linkJourney(journey.id, trip.id);
const place = createPlace(testDb, trip.id, { name: 'Doomed' });
const day = createDay(testDb, trip.id);
createDayAssignment(testDb, day.id, place.id);
seedSkeleton(journey.id, trip.id, place.id, user.id, 'Doomed');
expect(skeletonFor(journey.id, place.id)).toBeDefined();
await seedSkeleton(journey.id, trip.id, place.id, user.id, 'Doomed');
expect(await skeletonFor(journey.id, place.id)).not.toBeNull();
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'bulk_delete_places', arguments: { tripId: trip.id, placeIds: [place.id] } });
@@ -1285,8 +1302,8 @@ describe('journey hooks on the MCP delete paths', () => {
// The hook ran while the row still existed, so the entry is gone — not
// lingering with a NULL source_place_id.
expect(skeletonFor(journey.id, place.id)).toBeUndefined();
expect(testDb.prepare('SELECT COUNT(*) AS n FROM journey_entries WHERE journey_id = ?').get(journey.id)).toEqual({ n: 0 });
expect(await skeletonFor(journey.id, place.id)).toBeNull();
expect(await countRows(orm, JourneyEntries, { journey: journey.id })).toBe(0);
});
// The MCP bulk path already scoped correctly (it looped over removeMany's
@@ -1297,18 +1314,18 @@ describe('journey hooks on the MCP delete paths', () => {
const trip = createTrip(testDb, user.id);
const other = createTrip(testDb, user.id);
const journey = createJourney(testDb, user.id);
linkJourney(journey.id, other.id);
await linkJourney(journey.id, other.id);
const foreign = createPlace(testDb, other.id, { name: 'Theirs' });
const day = createDay(testDb, other.id);
createDayAssignment(testDb, day.id, foreign.id);
seedSkeleton(journey.id, other.id, foreign.id, user.id, 'Theirs');
await seedSkeleton(journey.id, other.id, foreign.id, user.id, 'Theirs');
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'bulk_delete_places', arguments: { tripId: trip.id, placeIds: [foreign.id] } });
expect((parseToolResult(result) as any).count).toBe(0);
});
expect(skeletonFor(journey.id, foreign.id)).toBeDefined();
expect(await skeletonFor(journey.id, foreign.id)).not.toBeNull();
});
});
@@ -1318,8 +1335,7 @@ describe('journey hooks on the MCP delete paths', () => {
describe('place tools and a website without a scheme (#2483)', () => {
const SITE = 'fr.wikipedia.org/wiki/Chapelle_Sainte-Barbe_du_Faouët';
const websiteOf = (placeId: number) =>
(testDb.prepare('SELECT website FROM places WHERE id = ?').get(placeId) as { website: string | null }).website;
const websiteOf = async (placeId: number) => (await findRow(orm, Places, { id: placeId }))!.website;
it('MCP-PLACES-2483-01: create_place, create_and_assign_place, update_place and bulk_update_places store https', async () => {
const { user } = createUser(testDb);
@@ -1328,19 +1344,19 @@ describe('place tools and a website without a scheme (#2483)', () => {
await withHarness(user.id, async (h) => {
const created = parseToolResult(await h.client.callTool({ name: 'create_place', arguments: { tripId: trip.id, name: 'Chapelle', website: SITE } })) as { place: { id: number } };
expect(websiteOf(created.place.id)).toBe(`https://${SITE}`);
expect(await websiteOf(created.place.id)).toBe(`https://${SITE}`);
const assigned = parseToolResult(await h.client.callTool({
name: 'create_and_assign_place',
arguments: { tripId: trip.id, dayId: day.id, name: 'Halles', website: 'www.example.fr/halles' },
})) as { place: { id: number } };
expect(websiteOf(assigned.place.id)).toBe('https://www.example.fr/halles');
expect(await websiteOf(assigned.place.id)).toBe('https://www.example.fr/halles');
await h.client.callTool({ name: 'update_place', arguments: { tripId: trip.id, placeId: created.place.id, website: '//www.example.fr' } });
expect(websiteOf(created.place.id)).toBe('https://www.example.fr');
expect(await websiteOf(created.place.id)).toBe('https://www.example.fr');
await h.client.callTool({ name: 'bulk_update_places', arguments: { tripId: trip.id, placeIds: [created.place.id, assigned.place.id], website: 'example.fr:8080/x' } });
expect([websiteOf(created.place.id), websiteOf(assigned.place.id)]).toEqual(['https://example.fr:8080/x', 'https://example.fr:8080/x']);
expect([await websiteOf(created.place.id), await websiteOf(assigned.place.id)]).toEqual(['https://example.fr:8080/x', 'https://example.fr:8080/x']);
});
});
@@ -1356,7 +1372,7 @@ describe('place tools and a website without a scheme (#2483)', () => {
}
});
expect(testDb.prepare('SELECT COUNT(*) AS n FROM places WHERE trip_id = ?').get(trip.id)).toEqual({ n: 0 });
expect(await countRows(orm, Places, { trip: trip.id })).toBe(0);
});
it('MCP-PLACES-2483-03: tools/list still describes the field as a plain capped string', async () => {
+23 -12
View File
@@ -9,7 +9,14 @@
*/
import { describe, it, expect, vi, beforeAll, beforeEach, afterEach, afterAll } from 'vitest';
import { db as testDb } from '../../../src/db/database';
import { CAN_ACCESS_TRIP_SQL } from '../../helpers/db-mock';
import { createTestOrm, type TestOrm } from '../../helpers/test-orm';
import { inContext } from '../../helpers/factories/context';
import { findRow, findRows } from '../../helpers/factories/rows';
import { BudgetItems } from '../../../src/db/entities/BudgetItems.entity';
import { PackingItems } from '../../../src/db/entities/PackingItems.entity';
import { TripMembers } from '../../../src/db/entities/TripMembers.entity';
import { Trips } from '../../../src/db/entities/Trips.entity';
import { Users } from '../../../src/db/entities/Users.entity';
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp';
import { Client } from '@modelcontextprotocol/sdk/client/index';
import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory';
@@ -88,9 +95,11 @@ import type { EntityManager } from '@mikro-orm/core';
// The trip-summary prompt moved to the DI-discovered TripsMcp — its cases below
// exercise it through a hand-built registry over a stub TripsService whose
// getTripSummary is the same controllable mock the legacy path used.
let orm: TestOrm;
const tripsStub = {
canAccessTrip: (tripId: number, userId: number) => testDb.prepare(CAN_ACCESS_TRIP_SQL).get(userId, tripId, userId),
getRaw: async (tripId: number) => testDb.prepare('SELECT * FROM trips WHERE id = ?').get(tripId),
canAccessTrip: (tripId: number, userId: number) =>
inContext(orm, (em) => em.getRepository(Trips).findAccessible(tripId, userId)),
getRaw: async (tripId: number) => (await findRow(orm, Trips, { id: Number(tripId) })) ?? undefined,
} as unknown as TripsService;
// getTripSummary moved to TripReadModelService with the trip split; the mock is
// the same controllable one, one constructor slot further along.
@@ -124,6 +133,7 @@ let promptBudget: BudgetService;
let budgetMcp: BudgetMcp;
let tripPromptsMcp: TripPromptsMcp;
beforeAll(async () => {
orm = await createTestOrm(testDb);
promptEm = (await sharedTestOrm(testDb)).em;
promptGuards = new McpToolGuardsService(await createTestTripsRepo(testDb), await createTestUsersRepo(testDb), new PermissionsService(await createTestAppSettingsRepo(testDb), await createTestUnitOfWork(testDb)), new RealtimeService());
tripsMcp = new TripsMcp(
@@ -182,15 +192,15 @@ beforeEach(() => {
// via budget.items/budget.total; packing stays an array (the packing prompt
// tolerates it).
mockGetTripSummary.mockImplementation(async (tripId: any) => {
const trip = testDb.prepare('SELECT * FROM trips WHERE id = ?').get(tripId) as any;
const trip = await findRow(orm, Trips, { id: Number(tripId) });
if (!trip) return null;
const members = testDb.prepare(`
SELECT u.id, u.username as name, u.email
FROM trip_members m JOIN users u ON u.id = m.user_id
WHERE m.trip_id = ?
`).all(tripId) as any[];
const budgetRows = testDb.prepare('SELECT * FROM budget_items WHERE trip_id = ?').all(tripId) as any[];
const packingRows = testDb.prepare('SELECT * FROM packing_items WHERE trip_id = ?').all(tripId) as any[];
const members = [];
for (const m of await findRows(orm, TripMembers, { trip: trip.id }, { id: 'asc' })) {
const u = await findRow(orm, Users, { id: m.user_id });
if (u) members.push({ id: u.id, name: u.username, email: u.email });
}
const budgetRows = await findRows(orm, BudgetItems, { trip: trip.id }, { id: 'asc' });
const packingRows = await findRows(orm, PackingItems, { trip: trip.id }, { id: 'asc' });
// The totals come from the same BudgetService.tripTotals the real summary uses.
const totals = await promptBudget.tripTotals(tripId, trip.currency || 'EUR');
return {
@@ -212,7 +222,8 @@ beforeEach(() => {
});
});
afterAll(() => {
afterAll(async () => {
await orm.close();
testDb.close();
});
@@ -22,6 +22,28 @@ vi.mock('../../../src/websocket', () => ({ broadcast: broadcastMock }));
import { resetTestDb } from '../../helpers/test-db';
import { createUser, createTrip, createDay, createPlace, createReservation, createBudgetItem, createDayAssignment, createDayAccommodation, createCategory, addTripMember } from '../../helpers/factories';
import { createMcpHarness, parseToolResult, parseResourceResult, type McpHarness } from '../../helpers/mcp-harness';
import { createTestOrm, type TestOrm } from '../../helpers/test-orm';
import { countRows, findRow, findRows, insertRow, updateRows } from '../../helpers/factories/rows';
import { BudgetItems } from '../../../src/db/entities/BudgetItems.entity';
import { DayAccommodations } from '../../../src/db/entities/DayAccommodations.entity';
import { ReservationTravelers } from '../../../src/db/entities/ReservationTravelers.entity';
import { Reservations } from '../../../src/db/entities/Reservations.entity';
import { Trips } from '../../../src/db/entities/Trips.entity';
let orm: TestOrm;
beforeAll(async () => {
orm = await createTestOrm(testDb);
});
async function reservationRow(id: number) {
return (await findRow(orm, Reservations, { id }))!;
}
async function reservationUrl(id: number) {
const row = await findRow(orm, Reservations, { id });
return row ? { url: row.url } : undefined;
}
beforeEach(() => {
resetTestDb(testDb);
@@ -29,7 +51,8 @@ beforeEach(() => {
delete process.env.DEMO_MODE;
});
afterAll(() => {
afterAll(async () => {
await orm.close();
testDb.close();
});
@@ -97,7 +120,7 @@ describe('Tool: create_reservation', () => {
expect(data.reservation.type).toBe('hotel');
expect(data.reservation.accommodation_id).not.toBeNull();
// accommodation was created
const acc = testDb.prepare('SELECT * FROM day_accommodations WHERE id = ?').get(data.reservation.accommodation_id) as any;
const acc = (await findRow(orm, DayAccommodations, { id: Number(data.reservation.accommodation_id) }))!;
expect(acc.place_id).toBe(hotel.id);
expect(acc.check_in).toBe('15:00');
});
@@ -281,7 +304,7 @@ describe('Tool: delete_reservation', () => {
const result = await h.client.callTool({ name: 'delete_reservation', arguments: { tripId: trip.id, reservationId: reservation.id } });
const data = parseToolResult(result) as any;
expect(data.success).toBe(true);
expect(testDb.prepare('SELECT id FROM reservations WHERE id = ?').get(reservation.id)).toBeUndefined();
expect(await findRow(orm, Reservations, { id: reservation.id })).toBeNull();
});
});
@@ -301,14 +324,14 @@ describe('Tool: delete_reservation', () => {
reservationId = (parseToolResult(r) as any).reservation.id;
});
const accId = (testDb.prepare('SELECT accommodation_id FROM reservations WHERE id = ?').get(reservationId!) as any).accommodation_id;
const accId = (await reservationRow(reservationId!)).accommodation_id;
expect(accId).not.toBeNull();
await withHarness(user.id, async (h) => {
await h.client.callTool({ name: 'delete_reservation', arguments: { tripId: trip.id, reservationId } });
});
expect(testDb.prepare('SELECT id FROM day_accommodations WHERE id = ?').get(accId)).toBeUndefined();
expect(await findRow(orm, DayAccommodations, { id: Number(accId) })).toBeNull();
});
it('broadcasts reservation:deleted event', async () => {
@@ -330,12 +353,12 @@ describe('Tool: delete_reservation', () => {
const fare = createBudgetItem(testDb, trip.id, { name: 'Fare' });
const luggage = createBudgetItem(testDb, trip.id, { name: 'Luggage' });
const unrelated = createBudgetItem(testDb, trip.id, { name: 'Coffee' });
testDb.prepare('UPDATE budget_items SET reservation_id = ? WHERE id IN (?, ?)').run(reservation.id, fare.id, luggage.id);
await updateRows(orm, BudgetItems, { id: { $in: [fare.id, luggage.id] } }, { reservation: reservation.id });
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'delete_reservation', arguments: { tripId: trip.id, reservationId: reservation.id } });
expect(parseToolResult(result)).toEqual({ success: true });
});
expect(testDb.prepare('SELECT id FROM budget_items WHERE trip_id = ?').all(trip.id)).toEqual([{ id: unrelated.id }]);
expect((await findRows(orm, BudgetItems, { trip: trip.id }, { id: 'asc' })).map(r => ({ id: r.id }))).toEqual([{ id: unrelated.id }]);
const deleted = broadcastMock.mock.calls.filter(c => c[1] === 'budget:deleted').map(c => (c[2] as { itemId: number }).itemId);
expect(deleted).toEqual([fare.id, luggage.id]);
// The expenses go out before the booking, so no client is left holding a link to nothing.
@@ -536,7 +559,7 @@ describe('Tool: set_reservation_travelers', () => {
});
const data = parseToolResult(result) as any;
expect(data.travelers.map((t: any) => t.user_id).sort()).toEqual([owner.id, friend.id].sort());
const rows = testDb.prepare('SELECT user_id FROM reservation_travelers WHERE reservation_id = ?').all(reservationId) as any[];
const rows = await findRows(orm, ReservationTravelers, { reservation: reservationId });
expect(rows).toHaveLength(2);
});
});
@@ -597,7 +620,7 @@ describe('Tool: set_reservation_travelers', () => {
arguments: { tripId: trip.id, reservationId, user_ids: [] },
});
expect((parseToolResult(result) as any).travelers).toEqual([]);
expect(testDb.prepare('SELECT user_id FROM reservation_travelers WHERE reservation_id = ?').all(reservationId)).toEqual([]);
expect(await findRows(orm, ReservationTravelers, { reservation: reservationId })).toEqual([]);
});
});
@@ -716,7 +739,7 @@ describe('Reservation tools: url and reservation_end_time', () => {
});
const data = parseToolResult(result) as any;
expect(data.reservation.url).toBe('https://example.com/booking/abc123');
const row = testDb.prepare('SELECT url FROM reservations WHERE id = ?').get(data.reservation.id) as any;
const row = await reservationRow(data.reservation.id);
expect(row.url).toBe('https://example.com/booking/abc123');
});
});
@@ -733,7 +756,7 @@ describe('Reservation tools: url and reservation_end_time', () => {
},
});
const data = parseToolResult(result) as any;
const row = testDb.prepare('SELECT reservation_time, reservation_end_time FROM reservations WHERE id = ?').get(data.reservation.id) as any;
const row = await reservationRow(data.reservation.id);
expect(row.reservation_time).toBe('2025-06-01T19:00:00');
expect(row.reservation_end_time).toBe('2025-06-01T21:30:00');
});
@@ -756,7 +779,7 @@ describe('Reservation tools: url and reservation_end_time', () => {
url: 'https://tours.example/booking/9', reservation_end_time: '2025-06-02T16:00:00',
},
});
const row = testDb.prepare('SELECT url, reservation_end_time FROM reservations WHERE id = ?').get(reservation.id) as any;
const row = await reservationRow(reservation.id);
expect(row.url).toBe('https://tours.example/booking/9');
expect(row.reservation_end_time).toBe('2025-06-02T16:00:00');
});
@@ -771,7 +794,7 @@ describe('Reservation tools: url and reservation_end_time', () => {
arguments: { tripId: trip.id, type: 'other', title: 'Nope', url: 'javascript:alert(1)' },
});
expect(result.isError).toBe(true);
expect(testDb.prepare('SELECT COUNT(*) AS n FROM reservations').get()).toEqual({ n: 0 });
expect(await countRows(orm, Reservations)).toBe(0);
});
});
@@ -799,13 +822,13 @@ describe('Reservation tools: url and reservation_end_time', () => {
},
});
const { reservation } = parseToolResult(created) as { reservation: { id: number } };
expect(testDb.prepare('SELECT url FROM reservations WHERE id = ?').get(reservation.id)).toEqual({ url: 'https://flix.example/booking/8891' });
expect(await reservationUrl(reservation.id)).toEqual({ url: 'https://flix.example/booking/8891' });
await h.client.callTool({
name: 'update_transport',
arguments: { tripId: trip.id, reservationId: reservation.id, url: 'https://flix.example/booking/8892' },
});
expect(testDb.prepare('SELECT url FROM reservations WHERE id = ?').get(reservation.id)).toEqual({ url: 'https://flix.example/booking/8892' });
expect(await reservationUrl(reservation.id)).toEqual({ url: 'https://flix.example/booking/8892' });
});
});
});
@@ -832,10 +855,10 @@ describe('Tool: list_upcoming_reservations', () => {
title: string,
time: string | null,
extra: Partial<{ type: string; status: string }> = {},
): number {
return Number(testDb.prepare(
'INSERT INTO reservations (trip_id, title, type, status, reservation_time) VALUES (?, ?, ?, ?, ?)',
).run(tripId, title, extra.type ?? 'restaurant', extra.status ?? 'pending', time).lastInsertRowid);
): Promise<number> {
return insertRow(orm, Reservations, {
trip: tripId, title, type: extra.type ?? 'restaurant', status: extra.status ?? 'pending', reservation_time: time,
});
}
async function upcoming(userId: number, args: Record<string, unknown> = {}) {
@@ -857,9 +880,9 @@ describe('Tool: list_upcoming_reservations', () => {
const stranger = createTrip(testDb, other.id);
addTripMember(testDb, joined.id, user.id);
seedBooking(owned.id, 'Flight to Paris', `${dateInDays(4)}T08:00:00`, { type: 'flight' });
seedBooking(joined.id, 'Group dinner', `${dateInDays(2)}T19:00:00`);
seedBooking(stranger.id, 'Not mine', `${dateInDays(1)}T07:00:00`);
await seedBooking(owned.id, 'Flight to Paris', `${dateInDays(4)}T08:00:00`, { type: 'flight' });
await seedBooking(joined.id, 'Group dinner', `${dateInDays(2)}T19:00:00`);
await seedBooking(stranger.id, 'Not mine', `${dateInDays(1)}T07:00:00`);
const rows = await upcoming(user.id);
expect(rows.map((r) => r.title)).toEqual(['Group dinner', 'Flight to Paris']);
@@ -885,11 +908,11 @@ describe('Tool: list_upcoming_reservations', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const archived = createTrip(testDb, user.id);
testDb.prepare('UPDATE trips SET is_archived = 1 WHERE id = ?').run(archived.id);
await updateRows(orm, Trips, { id: archived.id }, { is_archived: 1 });
seedBooking(trip.id, 'Still on', `${dateInDays(2)}T12:00:00`);
seedBooking(trip.id, 'Called off', `${dateInDays(1)}T12:00:00`, { status: 'cancelled' });
seedBooking(archived.id, 'Last year', `${dateInDays(3)}T12:00:00`);
await seedBooking(trip.id, 'Still on', `${dateInDays(2)}T12:00:00`);
await seedBooking(trip.id, 'Called off', `${dateInDays(1)}T12:00:00`, { status: 'cancelled' });
await seedBooking(archived.id, 'Last year', `${dateInDays(3)}T12:00:00`);
const rows = await upcoming(user.id);
expect(rows.map((r) => r.title)).toEqual(['Still on']);
@@ -898,7 +921,7 @@ describe('Tool: list_upcoming_reservations', () => {
it('returns the dashboard six by default and honours a limit', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
for (let i = 1; i <= 8; i++) seedBooking(trip.id, `Booking ${i}`, `${dateInDays(i)}T09:00:00`);
for (let i = 1; i <= 8; i++) await seedBooking(trip.id, `Booking ${i}`, `${dateInDays(i)}T09:00:00`);
expect((await upcoming(user.id)).map((r) => r.title)).toEqual([
'Booking 1', 'Booking 2', 'Booking 3', 'Booking 4', 'Booking 5', 'Booking 6',
+36 -23
View File
@@ -31,11 +31,19 @@ import { addTripMember } from '../../helpers/factories';
import { PermissionsService } from '../../../src/nest/permissions/permissions.service';
import { DEMO_EMAIL_PRIMARY } from '../../../src/nest/common/demo';
import { createTestUnitOfWork, createTestAppSettingsRepo } from '../../helpers/test-uow';
import { createTestOrm, type TestOrm } from '../../helpers/test-orm';
import { countRows, findRow, updateRows } from '../../helpers/factories/rows';
import { Places } from '../../../src/db/entities/Places.entity';
import { RoadtripPreferences } from '../../../src/db/entities/RoadtripPreferences.entity';
import { RoadtripVias } from '../../../src/db/entities/RoadtripVias.entity';
let orm: TestOrm;
// The permissions cache is module-scoped, so a write through any instance is
// what the tool's own check reads back.
let savePermissions: PermissionsService['savePermissions'];
beforeAll(async () => {
orm = await createTestOrm(testDb);
const permissionsService = new PermissionsService(await createTestAppSettingsRepo(testDb), await createTestUnitOfWork(testDb));
savePermissions = permissionsService.savePermissions.bind(permissionsService);
});
@@ -47,7 +55,10 @@ beforeEach(() => {
setAddonEnabled(testDb, ADDON_IDS.ROADTRIP, true);
});
afterAll(() => { testDb.close(); });
afterAll(async () => {
await orm.close();
testDb.close();
});
async function withHarness(userId: number, fn: (h: McpHarness) => Promise<void>) {
const h = await createMcpHarness({ userId, withResources: false, scopes: null });
@@ -55,12 +66,12 @@ async function withHarness(userId: number, fn: (h: McpHarness) => Promise<void>)
}
/** A trip with one day, plus a place carrying a route geometry to use as a track. */
function scenario() {
async function scenario() {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Norway' });
const day = createDay(testDb, trip.id, { day_number: 1 });
const track = createPlace(testDb, trip.id, { name: 'Scenic' });
testDb.prepare("UPDATE places SET route_geometry = '[[1,2],[3,4]]' WHERE id = ?").run(track.id);
await updateRows(orm, Places, { id: track.id }, { route_geometry: '[[1,2],[3,4]]' });
return { user, trip, day, track };
}
@@ -88,7 +99,7 @@ describe('road-trip MCP tools', () => {
});
it('MCP-ROADTRIP-003: adding a via, then reading it back for the day and for the trip', async () => {
const { user, trip, day } = scenario();
const { user, trip, day } = await scenario();
await withHarness(user.id, async (h) => {
const added = await h.client.callTool({
@@ -118,7 +129,7 @@ describe('road-trip MCP tools', () => {
it('MCP-ROADTRIP-004: a day from another trip is refused on every tool', async () => {
// The same check the REST routes make, which is what the repo means by the
// two surfaces moving together.
const { user, trip } = scenario();
const { user, trip } = await scenario();
const other = createTrip(testDb, user.id, { title: 'Elsewhere' });
const otherDay = createDay(testDb, other.id, { day_number: 1 });
@@ -134,7 +145,7 @@ describe('road-trip MCP tools', () => {
const res = await h.client.callTool({ name, arguments: args });
expect(res.isError, name).toBeTruthy();
}
expect(testDb.prepare('SELECT COUNT(*) c FROM roadtrip_vias').get()).toEqual({ c: 0 });
expect(await countRows(orm, RoadtripVias)).toBe(0);
});
});
@@ -154,7 +165,7 @@ describe('road-trip MCP tools', () => {
});
it('MCP-ROADTRIP-006: a chain lands in one call and records its track', async () => {
const { user, trip, day, track } = scenario();
const { user, trip, day, track } = await scenario();
await withHarness(user.id, async (h) => {
const res = await h.client.callTool({
@@ -178,11 +189,11 @@ describe('road-trip MCP tools', () => {
});
it('MCP-ROADTRIP-007: a track from another trip cannot label this day', async () => {
const { user, trip, day } = scenario();
const { user, trip, day } = await scenario();
const stranger = createUser(testDb, { email: 'other2@example.test' });
const theirs = createTrip(testDb, stranger.user.id, { title: 'Theirs' });
const theirTrack = createPlace(testDb, theirs.id, { name: 'Not yours' });
testDb.prepare("UPDATE places SET route_geometry = '[[1,2]]' WHERE id = ?").run(theirTrack.id);
await updateRows(orm, Places, { id: theirTrack.id }, { route_geometry: '[[1,2]]' });
await withHarness(user.id, async (h) => {
const res = await h.client.callTool({
@@ -195,12 +206,12 @@ describe('road-trip MCP tools', () => {
},
});
expect(res.isError).toBeTruthy();
expect(testDb.prepare('SELECT COUNT(*) c FROM roadtrip_vias').get()).toEqual({ c: 0 });
expect(await countRows(orm, RoadtripVias)).toBe(0);
});
});
it('MCP-ROADTRIP-008: re-anchoring moves what it names and removes what it lists', async () => {
const { user, trip, day } = scenario();
const { user, trip, day } = await scenario();
await withHarness(user.id, async (h) => {
const first = parseToolResult(await h.client.callTool({
@@ -231,7 +242,7 @@ describe('road-trip MCP tools', () => {
// The permission the controller demands is the permission the tool demands:
// an assistant must never be able to do through a tool what the person it
// is acting for cannot do through the UI.
const { user, trip, day } = scenario();
const { user, trip, day } = await scenario();
const member = createUser(testDb, { email: 'member@example.test' });
addTripMember(testDb, trip.id, member.user.id);
await savePermissions({ day_edit: 'trip_owner' });
@@ -254,7 +265,7 @@ describe('road-trip MCP tools', () => {
expect(res.isError, name).toBeTruthy();
}
});
expect(testDb.prepare('SELECT COUNT(*) c FROM roadtrip_vias').get()).toEqual({ c: 0 });
expect(await countRows(orm, RoadtripVias)).toBe(0);
} finally {
await savePermissions({ day_edit: 'trip_member' });
}
@@ -269,7 +280,7 @@ describe('road-trip MCP tools', () => {
});
it('MCP-ROADTRIP-011: the demo account may look but never write', async () => {
const { trip, day } = scenario();
const { trip, day } = await scenario();
const demo = createUser(testDb, { email: DEMO_EMAIL_PRIMARY });
addTripMember(testDb, trip.id, demo.user.id);
@@ -292,13 +303,15 @@ describe('road-trip MCP tools', () => {
} finally {
delete process.env.DEMO_MODE;
}
expect(testDb.prepare('SELECT COUNT(*) c FROM roadtrip_vias').get()).toEqual({ c: 0 });
expect(await countRows(orm, RoadtripVias)).toBe(0);
});
it('MCP-ROADTRIP-012: the stay switch is off until it is set, and round-trips through the settings tools', async () => {
const { user, trip } = scenario();
const stored = () =>
testDb.prepare("SELECT value FROM roadtrip_preferences WHERE trip_id = ? AND key = 'roadtrip_hotel_bookends'").get(trip.id);
const { user, trip } = await scenario();
const stored = async () => {
const row = await findRow(orm, RoadtripPreferences, { trip: trip.id, key: 'roadtrip_hotel_bookends' });
return row ? { value: row.value } : undefined;
};
await withHarness(user.id, async (h) => {
const before = parseToolResult(await h.client.callTool({ name: 'get_roadtrip_settings', arguments: { tripId: trip.id } })) as {
@@ -306,7 +319,7 @@ describe('road-trip MCP tools', () => {
};
// Missing means off: nothing is written for a trip that never touched it.
expect(before.settings).not.toHaveProperty('roadtrip_hotel_bookends');
expect(stored()).toBeUndefined();
expect(await stored()).toBeUndefined();
const saved = await h.client.callTool({
name: 'update_roadtrip_settings',
@@ -318,11 +331,11 @@ describe('road-trip MCP tools', () => {
};
expect(after.settings.roadtrip_hotel_bookends).toBe(true);
});
expect(stored()).toEqual({ value: 'true' });
expect(await stored()).toEqual({ value: 'true' });
});
it('MCP-ROADTRIP-013: a stay switch that is not a boolean is refused, and nothing is stored', async () => {
const { user, trip } = scenario();
const { user, trip } = await scenario();
await withHarness(user.id, async (h) => {
for (const value of ['yes', 1, null]) {
@@ -333,7 +346,7 @@ describe('road-trip MCP tools', () => {
expect(res.isError, String(value)).toBeTruthy();
}
});
expect(testDb.prepare('SELECT COUNT(*) c FROM roadtrip_preferences WHERE trip_id = ?').get(trip.id)).toEqual({ c: 0 });
expect(await countRows(orm, RoadtripPreferences, { trip: trip.id })).toBe(0);
});
it('MCP-ROADTRIP-014: both via tools say that a via on the drive into a booked night is kept and not used', async () => {
@@ -351,7 +364,7 @@ describe('road-trip MCP tools', () => {
});
it('MCP-ROADTRIP-009: removing one that is not on the day is refused, not silently ignored', async () => {
const { user, trip, day } = scenario();
const { user, trip, day } = await scenario();
await withHarness(user.id, async (h) => {
const res = await h.client.callTool({
@@ -25,6 +25,28 @@ import { resetTestDb } from '../../helpers/test-db';
import { invalidatePermissionsCache } from '../../../src/nest/permissions/permissions-cache';
import { createUser, createAdmin, createTrip, addTripMember } from '../../helpers/factories';
import { createMcpHarness, parseToolResult, type McpHarness } from '../../helpers/mcp-harness';
import { createTestOrm, type TestOrm } from '../../helpers/test-orm';
import { countRows, findRow } from '../../helpers/factories/rows';
import { setAppSetting } from '../../helpers/factories/settings';
import { makeShareToken } from '../../helpers/factories/trips';
import { ShareTokens } from '../../../src/db/entities/ShareTokens.entity';
import { TripMembers } from '../../../src/db/entities/TripMembers.entity';
import { Trips } from '../../../src/db/entities/Trips.entity';
import { Users } from '../../../src/db/entities/Users.entity';
let orm: TestOrm;
beforeAll(async () => {
orm = await createTestOrm(testDb);
});
function memberRow(tripId: number, userId: number) {
return findRow(orm, TripMembers, { trip: tripId, user: userId });
}
async function userRow(id: number) {
return (await findRow(orm, Users, { id }))!;
}
beforeEach(async () => {
resetTestDb(testDb);
@@ -35,7 +57,8 @@ beforeEach(async () => {
await invalidatePermissionsCache();
});
afterAll(() => {
afterAll(async () => {
await orm.close();
testDb.close();
});
@@ -46,7 +69,7 @@ async function withHarness(userId: number, fn: (h: McpHarness) => Promise<void>)
/** Lower a configurable action the way the admin permission panel does. */
async function setPermission(action: string, level: string): Promise<void> {
testDb.prepare('INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)').run(`perm_${action}`, level);
await setAppSetting(orm, `perm_${action}`, level);
await invalidatePermissionsCache();
}
@@ -164,9 +187,9 @@ describe('Tool: add_trip_member', () => {
arguments: { tripId: trip.id, identifier: outsider.username },
});
expect(result.isError).toBeFalsy();
const row = testDb.prepare('SELECT invited_by FROM trip_members WHERE trip_id = ? AND user_id = ?').get(trip.id, outsider.id) as any;
const row = await memberRow(trip.id, outsider.id);
expect(row).toBeTruthy();
expect(row.invited_by).toBe(collaborator.id);
expect(row!.invited_by).toBe(collaborator.id);
});
});
@@ -182,7 +205,7 @@ describe('Tool: add_trip_member', () => {
arguments: { tripId: trip.id, identifier: outsider.username },
});
expect(result.isError).toBeFalsy();
expect(testDb.prepare('SELECT user_id FROM trip_members WHERE trip_id = ? AND user_id = ?').get(trip.id, outsider.id)).toBeTruthy();
expect(await memberRow(trip.id, outsider.id)).toBeTruthy();
});
});
@@ -198,7 +221,7 @@ describe('Tool: add_trip_member', () => {
arguments: { tripId: trip.id, identifier: outsider.username },
});
expect(result.isError).toBe(true);
expect(testDb.prepare('SELECT user_id FROM trip_members WHERE trip_id = ? AND user_id = ?').get(trip.id, outsider.id)).toBeUndefined();
expect(await memberRow(trip.id, outsider.id)).toBeNull();
});
});
});
@@ -220,8 +243,8 @@ describe('Tool: remove_trip_member', () => {
});
const data = parseToolResult(result) as any;
expect(data.success).toBe(true);
const row = testDb.prepare('SELECT * FROM trip_members WHERE trip_id = ? AND user_id = ?').get(trip.id, member.id);
expect(row).toBeUndefined();
const row = await memberRow(trip.id, member.id);
expect(row).toBeNull();
});
});
@@ -263,7 +286,7 @@ describe('Tool: remove_trip_member', () => {
arguments: { tripId: trip.id, memberId: other.id },
});
expect(result.isError).toBe(true);
expect(testDb.prepare('SELECT user_id FROM trip_members WHERE trip_id = ? AND user_id = ?').get(trip.id, other.id)).toBeTruthy();
expect(await memberRow(trip.id, other.id)).toBeTruthy();
});
});
@@ -281,7 +304,7 @@ describe('Tool: remove_trip_member', () => {
arguments: { tripId: trip.id, memberId: other.id },
});
expect(result.isError).toBeFalsy();
expect(testDb.prepare('SELECT user_id FROM trip_members WHERE trip_id = ? AND user_id = ?').get(trip.id, other.id)).toBeUndefined();
expect(await memberRow(trip.id, other.id)).toBeNull();
});
});
@@ -298,7 +321,7 @@ describe('Tool: remove_trip_member', () => {
arguments: { tripId: trip.id, memberId: other.id },
});
expect(result.isError).toBeFalsy();
expect(testDb.prepare('SELECT user_id FROM trip_members WHERE trip_id = ? AND user_id = ?').get(trip.id, other.id)).toBeUndefined();
expect(await memberRow(trip.id, other.id)).toBeNull();
});
});
@@ -313,7 +336,7 @@ describe('Tool: remove_trip_member', () => {
arguments: { tripId: trip.id, memberId: member.id },
});
expect((parseToolResult(result) as any).success).toBe(true);
expect(testDb.prepare('SELECT user_id FROM trip_members WHERE trip_id = ? AND user_id = ?').get(trip.id, member.id)).toBeUndefined();
expect(await memberRow(trip.id, member.id)).toBeNull();
});
});
});
@@ -331,8 +354,8 @@ describe('Tool: leave_trip', () => {
await withHarness(member.id, async (h) => {
const result = await h.client.callTool({ name: 'leave_trip', arguments: { tripId: trip.id } });
expect((parseToolResult(result) as any).success).toBe(true);
expect(testDb.prepare('SELECT user_id FROM trip_members WHERE trip_id = ? AND user_id = ?').get(trip.id, member.id)).toBeUndefined();
expect(testDb.prepare('SELECT id FROM trips WHERE id = ?').get(trip.id)).toBeTruthy();
expect(await memberRow(trip.id, member.id)).toBeNull();
expect(await findRow(orm, Trips, { id: trip.id })).toBeTruthy();
});
});
@@ -356,7 +379,7 @@ describe('Tool: leave_trip', () => {
addTripMember(testDb, trip.id, other.id);
await withHarness(member.id, async (h) => {
await h.client.callTool({ name: 'leave_trip', arguments: { tripId: trip.id } });
expect(testDb.prepare('SELECT user_id FROM trip_members WHERE trip_id = ? AND user_id = ?').get(trip.id, other.id)).toBeTruthy();
expect(await memberRow(trip.id, other.id)).toBeTruthy();
});
});
@@ -366,7 +389,7 @@ describe('Tool: leave_trip', () => {
await withHarness(owner.id, async (h) => {
const result = await h.client.callTool({ name: 'leave_trip', arguments: { tripId: trip.id } });
expect(result.isError).toBe(true);
const row = testDb.prepare('SELECT user_id FROM trips WHERE id = ?').get(trip.id) as any;
const row = (await findRow(orm, Trips, { id: trip.id }))!;
expect(row.user_id).toBe(owner.id);
});
});
@@ -390,7 +413,7 @@ describe('Tool: leave_trip', () => {
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'leave_trip', arguments: { tripId: trip.id } });
expect(result.isError).toBe(true);
expect(testDb.prepare('SELECT user_id FROM trip_members WHERE trip_id = ? AND user_id = ?').get(trip.id, user.id)).toBeTruthy();
expect(await memberRow(trip.id, user.id)).toBeTruthy();
});
});
});
@@ -408,8 +431,8 @@ describe('Tool: copy_trip', () => {
const data = parseToolResult(result) as any;
expect(data.trip).toBeTruthy();
// New trip should be a different row
const count = testDb.prepare('SELECT COUNT(*) as cnt FROM trips').get() as any;
expect(count.cnt).toBe(2);
const count = await countRows(orm, Trips);
expect(count).toBe(2);
});
});
@@ -418,7 +441,7 @@ describe('Tool: copy_trip', () => {
const trip = createTrip(testDb, user.id, { title: 'Original' });
await withHarness(user.id, async (h) => {
await h.client.callTool({ name: 'copy_trip', arguments: { tripId: trip.id, title: 'My Copy' } });
const newTrip = testDb.prepare("SELECT * FROM trips WHERE title = 'My Copy'").get() as any;
const newTrip = await findRow(orm, Trips, { title: 'My Copy' });
expect(newTrip).toBeTruthy();
});
});
@@ -503,9 +526,9 @@ describe('Tool: get_share_link', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
// Create a share link directly
testDb.prepare(
'INSERT INTO share_tokens (trip_id, token, created_by, share_map, share_bookings, share_packing, share_budget, share_collab) VALUES (?, ?, ?, 1, 1, 0, 0, 0)'
).run(trip.id, 'test-token-123', user.id);
await makeShareToken(orm, trip.id, user.id, {
token: 'test-token-123', share_map: 1, share_bookings: 1, share_packing: 0, share_budget: 0, share_collab: 0,
});
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'get_share_link', arguments: { tripId: trip.id } });
const data = parseToolResult(result) as any;
@@ -533,9 +556,9 @@ describe('Tool: create_share_link', () => {
it('updates existing share link permissions', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
testDb.prepare(
'INSERT INTO share_tokens (trip_id, token, created_by, share_map, share_bookings, share_packing, share_budget, share_collab) VALUES (?, ?, ?, 1, 1, 0, 0, 0)'
).run(trip.id, 'existing-token', user.id);
await makeShareToken(orm, trip.id, user.id, {
token: 'existing-token', share_map: 1, share_bookings: 1, share_packing: 0, share_budget: 0, share_collab: 0,
});
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'create_share_link',
@@ -561,15 +584,15 @@ describe('Tool: delete_share_link', () => {
it('revokes the share link', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
testDb.prepare(
'INSERT INTO share_tokens (trip_id, token, created_by, share_map, share_bookings, share_packing, share_budget, share_collab) VALUES (?, ?, ?, 1, 1, 0, 0, 0)'
).run(trip.id, 'to-delete', user.id);
await makeShareToken(orm, trip.id, user.id, {
token: 'to-delete', share_map: 1, share_bookings: 1, share_packing: 0, share_budget: 0, share_collab: 0,
});
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'delete_share_link', arguments: { tripId: trip.id } });
const data = parseToolResult(result) as any;
expect(data.success).toBe(true);
const row = testDb.prepare('SELECT token FROM share_tokens WHERE trip_id = ?').get(trip.id);
expect(row).toBeUndefined();
const row = await findRow(orm, ShareTokens, { trip: trip.id });
expect(row).toBeNull();
});
});
});
@@ -603,7 +626,7 @@ describe('Tool: create_trip_guest', () => {
expect(data.member.role).toBe('member');
expect(data.member.is_guest).toBe(true);
const row = testDb.prepare('SELECT is_guest, password_hash, display_name, email FROM users WHERE id = ?').get(data.member.id) as any;
const row = await userRow(data.member.id);
expect(row.is_guest).toBe(1);
expect(row.password_hash).toBe('');
expect(row.display_name).toBe('Anna');
@@ -641,7 +664,7 @@ describe('Tool: create_trip_guest', () => {
arguments: { tripId: trip.id, name: ' ' },
});
expect(result.isError).toBe(true);
expect(testDb.prepare('SELECT COUNT(*) AS n FROM users WHERE is_guest = 1').get()).toEqual({ n: 0 });
expect(await countRows(orm, Users, { is_guest: 1 })).toBe(0);
});
});
@@ -697,7 +720,7 @@ describe('Tool: rename_trip_guest', () => {
arguments: { tripId: trip.id, guestId, name: 'Anna B.' },
});
expect((parseToolResult(result) as any).success).toBe(true);
const row = testDb.prepare('SELECT display_name FROM users WHERE id = ?').get(guestId) as any;
const row = await userRow(guestId);
expect(row.display_name).toBe('Anna B.');
});
});
@@ -712,7 +735,7 @@ describe('Tool: rename_trip_guest', () => {
arguments: { tripId: trip.id, guestId, name: ' ' },
});
expect(result.isError).toBe(true);
const row = testDb.prepare('SELECT display_name FROM users WHERE id = ?').get(guestId) as any;
const row = await userRow(guestId);
expect(row.display_name).toBe('Anna');
});
});
@@ -728,7 +751,7 @@ describe('Tool: rename_trip_guest', () => {
arguments: { tripId: ownTrip.id, guestId, name: 'Renamed' },
});
expect(result.isError).toBe(true);
const row = testDb.prepare('SELECT display_name FROM users WHERE id = ?').get(guestId) as any;
const row = await userRow(guestId);
expect(row.display_name).toBe('Anna');
});
});
@@ -774,8 +797,8 @@ describe('Tool: delete_trip_guest', () => {
arguments: { tripId: trip.id, guestId },
});
expect((parseToolResult(result) as any).success).toBe(true);
expect(testDb.prepare('SELECT id FROM users WHERE id = ?').get(guestId)).toBeUndefined();
expect(testDb.prepare('SELECT user_id FROM trip_members WHERE user_id = ?').get(guestId)).toBeUndefined();
expect(await findRow(orm, Users, { id: guestId })).toBeNull();
expect(await findRow(orm, TripMembers, { user: guestId })).toBeNull();
});
});
@@ -801,7 +824,7 @@ describe('Tool: delete_trip_guest', () => {
arguments: { tripId: ownTrip.id, guestId },
});
expect(result.isError).toBe(true);
expect(testDb.prepare('SELECT id FROM users WHERE id = ?').get(guestId)).toBeDefined();
expect(await findRow(orm, Users, { id: guestId })).not.toBeNull();
});
});
@@ -816,7 +839,7 @@ describe('Tool: delete_trip_guest', () => {
arguments: { tripId: trip.id, guestId: collaborator.id },
});
expect(result.isError).toBe(true);
expect(testDb.prepare('SELECT id FROM users WHERE id = ?').get(collaborator.id)).toBeDefined();
expect(await findRow(orm, Users, { id: collaborator.id })).not.toBeNull();
});
});
@@ -833,7 +856,7 @@ describe('Tool: delete_trip_guest', () => {
arguments: { tripId: trip.id, guestId },
});
expect(result.isError).toBe(true);
expect(testDb.prepare('SELECT id FROM users WHERE id = ?').get(guestId)).toBeDefined();
expect(await findRow(orm, Users, { id: guestId })).not.toBeNull();
});
});
@@ -880,8 +903,8 @@ describe('Guest tools stay owner-only', () => {
})).isError).toBe(true);
});
const row = testDb.prepare('SELECT display_name FROM users WHERE id = ?').get(guestId) as any;
const row = await userRow(guestId);
expect(row.display_name).toBe('Anna');
expect(testDb.prepare('SELECT COUNT(*) AS n FROM users WHERE is_guest = 1').get()).toEqual({ n: 1 });
expect(await countRows(orm, Users, { is_guest: 1 })).toBe(1);
});
});
+94 -75
View File
@@ -28,6 +28,22 @@ import { resetTestDb } from '../../helpers/test-db';
import { createUser, createTrip, createDay, createPlace, addTripMember, createBudgetItem, createPackingItem, createReservation, createDayNote, createCollabNote, createDayAssignment, createDayAccommodation } from '../../helpers/factories';
import { createMcpHarness, parseToolResult, parseResourceResult, type McpHarness } from '../../helpers/mcp-harness';
import { MAX_TRIP_DAYS } from '@trek/shared';
import { createTestOrm, type TestOrm } from '../../helpers/test-orm';
import { countRows, findRow, findRows, insertRow, insertRows, updateRows } from '../../helpers/factories/rows';
import { setUserSetting } from '../../helpers/factories/settings';
import { makeVacayEntry, makeVacayPlan, addVacayPlanMember } from '../../helpers/factories/vacay';
import { makePackingItem } from '../../helpers/factories/packing';
import { Days } from '../../../src/db/entities/Days.entity';
import { Trips } from '../../../src/db/entities/Trips.entity';
import { VacayEntries } from '../../../src/db/entities/VacayEntries.entity';
import { VacayUserYears } from '../../../src/db/entities/VacayUserYears.entity';
import { VacayYears } from '../../../src/db/entities/VacayYears.entity';
let orm: TestOrm;
beforeAll(async () => {
orm = await createTestOrm(testDb);
});
beforeEach(() => {
resetTestDb(testDb);
@@ -40,10 +56,29 @@ afterEach(() => {
vi.unstubAllGlobals();
});
afterAll(() => {
afterAll(async () => {
await orm.close();
testDb.close();
});
async function tripRow(id: number) {
return (await findRow(orm, Trips, { id }))!;
}
/** The vacay entry dates of the user in the plan between `from` and `to`, both included, in date order. */
async function vacayDatesBetween(planId: number, userId: number, from: string, to: string): Promise<string[]> {
const rows = await findRows(orm, VacayEntries, { plan: planId, user: userId, date: { $gte: from, $lte: to } }, { date: 'asc' });
return rows.map(r => r.date);
}
/** A vacay plan for the user with the year 2026 open and 30 days in it. */
async function vacayPlanWithYear(userId: number): Promise<number> {
const { id: planId } = await makeVacayPlan(orm, userId);
await insertRow(orm, VacayYears, { plan: planId, year: 2026 });
await insertRow(orm, VacayUserYears, { user: userId, plan: planId, year: 2026, vacation_days: 30, carried_over: 0 });
return planId;
}
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(); }
@@ -61,8 +96,8 @@ describe('Tool: create_trip', () => {
const data = parseToolResult(result) as any;
expect(data.trip).toBeTruthy();
expect(data.trip.title).toBe('Summer Escape');
const days = testDb.prepare('SELECT COUNT(*) as c FROM days WHERE trip_id = ?').get(data.trip.id) as { c: number };
expect(days.c).toBe(7);
const days = await countRows(orm, Days, { trip: data.trip.id });
expect(days).toBe(7);
});
});
@@ -74,8 +109,8 @@ describe('Tool: create_trip', () => {
arguments: { title: 'Paris Trip', start_date: '2026-07-01', end_date: '2026-07-05' },
});
const data = parseToolResult(result) as any;
const days = testDb.prepare('SELECT COUNT(*) as c FROM days WHERE trip_id = ?').get(data.trip.id) as { c: number };
expect(days.c).toBe(5);
const days = await countRows(orm, Days, { trip: data.trip.id });
expect(days).toBe(5);
});
});
@@ -87,8 +122,8 @@ describe('Tool: create_trip', () => {
arguments: { title: 'Long Trip', start_date: '2025-01-26', end_date: '2026-01-28' },
});
const data = parseToolResult(result) as any;
const days = testDb.prepare('SELECT COUNT(*) as c FROM days WHERE trip_id = ?').get(data.trip.id) as { c: number };
expect(days.c).toBe(368);
const days = await countRows(orm, Days, { trip: data.trip.id });
expect(days).toBe(368);
});
});
@@ -101,7 +136,7 @@ describe('Tool: create_trip', () => {
});
expect(result.isError).toBe(true);
expect(JSON.stringify(result.content)).toContain(`at most ${MAX_TRIP_DAYS} days`);
expect(testDb.prepare('SELECT COUNT(*) as c FROM trips').get()).toEqual({ c: 0 });
expect(await countRows(orm, Trips)).toBe(0);
});
});
@@ -138,9 +173,9 @@ describe('Tool: create_trip', () => {
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'create_trip', arguments: { title: 'Open Ended', day_count: 12 } });
const data = parseToolResult(result) as any;
const days = testDb.prepare('SELECT COUNT(*) as c FROM days WHERE trip_id = ?').get(data.trip.id) as { c: number };
expect(days.c).toBe(12);
const row = testDb.prepare('SELECT start_date, end_date FROM trips WHERE id = ?').get(data.trip.id) as any;
const days = await countRows(orm, Days, { trip: data.trip.id });
expect(days).toBe(12);
const row = await tripRow(data.trip.id);
expect(row.start_date).toBeNull();
expect(row.end_date).toBeNull();
});
@@ -151,7 +186,7 @@ describe('Tool: create_trip', () => {
await withHarness(user.id, async (h) => {
expect((await h.client.callTool({ name: 'create_trip', arguments: { title: 'Zero', day_count: 0 } })).isError).toBe(true);
expect((await h.client.callTool({ name: 'create_trip', arguments: { title: 'Huge', day_count: MAX_TRIP_DAYS + 1 } })).isError).toBe(true);
expect(testDb.prepare('SELECT COUNT(*) as c FROM trips').get()).toEqual({ c: 0 });
expect(await countRows(orm, Trips)).toBe(0);
});
});
@@ -160,7 +195,7 @@ describe('Tool: create_trip', () => {
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'create_trip', arguments: { title: 'Reminded', reminder_days: 14 } });
const data = parseToolResult(result) as any;
const row = testDb.prepare('SELECT reminder_days FROM trips WHERE id = ?').get(data.trip.id) as any;
const row = await tripRow(data.trip.id);
expect(row.reminder_days).toBe(14);
});
});
@@ -170,7 +205,7 @@ describe('Tool: create_trip', () => {
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'create_trip', arguments: { title: 'Quiet', reminder_days: 0 } });
const data = parseToolResult(result) as any;
const row = testDb.prepare('SELECT reminder_days FROM trips WHERE id = ?').get(data.trip.id) as any;
const row = await tripRow(data.trip.id);
expect(row.reminder_days).toBe(0);
});
});
@@ -180,13 +215,13 @@ describe('Tool: create_trip', () => {
await withHarness(user.id, async (h) => {
expect((await h.client.callTool({ name: 'create_trip', arguments: { title: 'Early', reminder_days: 31 } })).isError).toBe(true);
expect((await h.client.callTool({ name: 'create_trip', arguments: { title: 'Negative', reminder_days: -1 } })).isError).toBe(true);
expect(testDb.prepare('SELECT COUNT(*) as c FROM trips').get()).toEqual({ c: 0 });
expect(await countRows(orm, Trips)).toBe(0);
});
});
it('gives a trip without a currency the display currency from the settings, not EUR', async () => {
const { user } = createUser(testDb);
testDb.prepare("INSERT INTO settings (user_id, key, value) VALUES (?, 'default_currency', ?)").run(user.id, JSON.stringify('USD'));
await setUserSetting(orm, user.id, 'default_currency', JSON.stringify('USD'));
await withHarness(user.id, async (h) => {
const fromSettings = parseToolResult(await h.client.callTool({ name: 'create_trip', arguments: { title: 'Road trip' } })) as any;
expect(fromSettings.trip.currency).toBe('USD');
@@ -224,7 +259,7 @@ describe('Tool: update_trip', () => {
const trip = createTrip(testDb, user.id, { title: 'My Trip', description: 'A great trip' });
await withHarness(user.id, async (h) => {
await h.client.callTool({ name: 'update_trip', arguments: { tripId: trip.id, title: 'Renamed' } });
const updated = testDb.prepare('SELECT * FROM trips WHERE id = ?').get(trip.id) as any;
const updated = await tripRow(trip.id);
expect(updated.title).toBe('Renamed');
expect(updated.description).toBe('A great trip');
});
@@ -264,14 +299,9 @@ describe('Tool: update_trip', () => {
const trip = createTrip(testDb, user.id, { start_date: '2026-08-01', end_date: '2026-08-09' });
// Materialize active vacay plan for owner and entries in old trip window.
const planRes = testDb.prepare('INSERT INTO vacay_plans (owner_id) VALUES (?)').run(user.id);
const planId = Number(planRes.lastInsertRowid);
testDb.prepare('INSERT INTO vacay_years (plan_id, year) VALUES (?, ?)').run(planId, 2026);
testDb.prepare(
'INSERT INTO vacay_user_years (user_id, plan_id, year, vacation_days, carried_over) VALUES (?, ?, ?, 30, 0)'
).run(user.id, planId, 2026);
const planId = await vacayPlanWithYear(user.id);
for (const d of ['2026-08-03', '2026-08-04', '2026-08-05', '2026-08-06', '2026-08-07']) {
testDb.prepare('INSERT INTO vacay_entries (plan_id, user_id, date, note) VALUES (?, ?, ?, ?)').run(planId, user.id, d, '');
await makeVacayEntry(orm, planId, user.id, d, { note: '' });
}
await withHarness(user.id, async (h) => {
@@ -284,15 +314,11 @@ describe('Tool: update_trip', () => {
expect(data.trip.end_date).toBe('2026-08-16');
});
const oldWindow = testDb.prepare(
"SELECT date FROM vacay_entries WHERE plan_id = ? AND user_id = ? AND date BETWEEN '2026-08-01' AND '2026-08-09'"
).all(planId, user.id) as { date: string }[];
const oldWindow = await vacayDatesBetween(planId, user.id, '2026-08-01', '2026-08-09');
expect(oldWindow).toHaveLength(0);
const shifted = testDb.prepare(
"SELECT date FROM vacay_entries WHERE plan_id = ? AND user_id = ? AND date BETWEEN '2026-08-08' AND '2026-08-16' ORDER BY date"
).all(planId, user.id) as { date: string }[];
expect(shifted.map(r => r.date)).toEqual([
const shifted = await vacayDatesBetween(planId, user.id, '2026-08-08', '2026-08-16');
expect(shifted).toEqual([
'2026-08-10',
'2026-08-11',
'2026-08-12',
@@ -307,20 +333,14 @@ describe('Tool: update_trip', () => {
const trip = createTrip(testDb, user.id, { start_date: '2026-09-01', end_date: '2026-09-07' });
// Own plan with entries that should be shifted.
const ownPlanRes = testDb.prepare('INSERT INTO vacay_plans (owner_id) VALUES (?)').run(user.id);
const ownPlanId = Number(ownPlanRes.lastInsertRowid);
testDb.prepare('INSERT INTO vacay_years (plan_id, year) VALUES (?, ?)').run(ownPlanId, 2026);
testDb.prepare(
'INSERT INTO vacay_user_years (user_id, plan_id, year, vacation_days, carried_over) VALUES (?, ?, ?, 30, 0)'
).run(user.id, ownPlanId, 2026);
const ownPlanId = await vacayPlanWithYear(user.id);
for (const d of ['2026-09-02', '2026-09-03']) {
testDb.prepare('INSERT INTO vacay_entries (plan_id, user_id, date, note) VALUES (?, ?, ?, ?)').run(ownPlanId, user.id, d, '');
await makeVacayEntry(orm, ownPlanId, user.id, d, { note: '' });
}
// Different accepted plan becomes "active" for the owner.
const foreignPlanRes = testDb.prepare('INSERT INTO vacay_plans (owner_id) VALUES (?)').run(otherOwner.id);
const foreignPlanId = Number(foreignPlanRes.lastInsertRowid);
testDb.prepare('INSERT INTO vacay_plan_members (plan_id, user_id, status) VALUES (?, ?, ?)').run(foreignPlanId, user.id, 'accepted');
const { id: foreignPlanId } = await makeVacayPlan(orm, otherOwner.id);
await addVacayPlanMember(orm, foreignPlanId, user.id, 'accepted');
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
@@ -330,15 +350,11 @@ describe('Tool: update_trip', () => {
expect(result.isError).toBeFalsy();
});
const oldWindow = testDb.prepare(
"SELECT date FROM vacay_entries WHERE plan_id = ? AND user_id = ? AND date BETWEEN '2026-09-01' AND '2026-09-07' ORDER BY date"
).all(ownPlanId, user.id) as { date: string }[];
const oldWindow = await vacayDatesBetween(ownPlanId, user.id, '2026-09-01', '2026-09-07');
expect(oldWindow).toHaveLength(0);
const shifted = testDb.prepare(
"SELECT date FROM vacay_entries WHERE plan_id = ? AND user_id = ? AND date BETWEEN '2026-09-08' AND '2026-09-14' ORDER BY date"
).all(ownPlanId, user.id) as { date: string }[];
expect(shifted.map(r => r.date)).toEqual(['2026-09-09', '2026-09-10']);
const shifted = await vacayDatesBetween(ownPlanId, user.id, '2026-09-08', '2026-09-14');
expect(shifted).toEqual(['2026-09-09', '2026-09-10']);
});
it('clear_dates turns a dated trip back into a dateless one and un-dates its days', async () => {
@@ -347,11 +363,11 @@ describe('Tool: update_trip', () => {
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'update_trip', arguments: { tripId: trip.id, clear_dates: true } });
expect(result.isError).toBeFalsy();
const row = testDb.prepare('SELECT start_date, end_date FROM trips WHERE id = ?').get(trip.id) as any;
const row = await tripRow(trip.id);
expect(row.start_date).toBeNull();
expect(row.end_date).toBeNull();
const dated = testDb.prepare('SELECT COUNT(*) as c FROM days WHERE trip_id = ? AND date IS NOT NULL').get(trip.id) as { c: number };
expect(dated.c).toBe(0);
const dated = await countRows(orm, Days, { trip: trip.id, date: { $ne: null } });
expect(dated).toBe(0);
});
});
@@ -360,8 +376,8 @@ describe('Tool: update_trip', () => {
const trip = createTrip(testDb, user.id, { start_date: '2026-07-01', end_date: '2026-07-05' });
await withHarness(user.id, async (h) => {
await h.client.callTool({ name: 'update_trip', arguments: { tripId: trip.id, clear_dates: true, day_count: 3 } });
const days = testDb.prepare('SELECT COUNT(*) as c FROM days WHERE trip_id = ?').get(trip.id) as { c: number };
expect(days.c).toBe(3);
const days = await countRows(orm, Days, { trip: trip.id });
expect(days).toBe(3);
});
});
@@ -374,7 +390,7 @@ describe('Tool: update_trip', () => {
arguments: { tripId: trip.id, clear_dates: true, start_date: '2026-08-01' },
});
expect(result.isError).toBe(true);
const row = testDb.prepare('SELECT start_date FROM trips WHERE id = ?').get(trip.id) as any;
const row = await tripRow(trip.id);
expect(row.start_date).toBe('2026-07-01');
});
});
@@ -382,11 +398,14 @@ describe('Tool: update_trip', () => {
it('resizes a dateless trip with day_count', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
testDb.prepare('INSERT INTO days (trip_id, day_number, date) VALUES (?, 1, NULL), (?, 2, NULL)').run(trip.id, trip.id);
await insertRows(orm, Days, [
{ trip: trip.id, day_number: 1, date: null },
{ trip: trip.id, day_number: 2, date: null },
]);
await withHarness(user.id, async (h) => {
await h.client.callTool({ name: 'update_trip', arguments: { tripId: trip.id, day_count: 6 } });
const days = testDb.prepare('SELECT COUNT(*) as c FROM days WHERE trip_id = ?').get(trip.id) as { c: number };
expect(days.c).toBe(6);
const days = await countRows(orm, Days, { trip: trip.id });
expect(days).toBe(6);
});
});
@@ -396,7 +415,7 @@ describe('Tool: update_trip', () => {
await withHarness(user.id, async (h) => {
expect((await h.client.callTool({ name: 'update_trip', arguments: { tripId: trip.id, day_count: 0 } })).isError).toBe(true);
expect((await h.client.callTool({ name: 'update_trip', arguments: { tripId: trip.id, day_count: MAX_TRIP_DAYS + 1 } })).isError).toBe(true);
expect(testDb.prepare('SELECT COUNT(*) as c FROM days WHERE trip_id = ?').get(trip.id)).toEqual({ c: 0 });
expect(await countRows(orm, Days, { trip: trip.id })).toBe(0);
});
});
@@ -412,7 +431,7 @@ describe('Tool: update_trip', () => {
it('lists the days an earlier end removed, as they stood before, and none for a rename', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { start_date: '2026-07-01', end_date: '2026-07-05' });
const days = testDb.prepare('SELECT id FROM days WHERE trip_id = ? ORDER BY day_number').all(trip.id) as { id: number }[];
const days = await findRows(orm, Days, { trip: trip.id }, { day_number: 'asc' });
await withHarness(user.id, async (h) => {
type Answer = { trip: { end_date: string; title: string }; removed_days?: unknown[] };
const shortened = parseToolResult(await h.client.callTool({ name: 'update_trip', arguments: { tripId: trip.id, end_date: '2026-07-03' } })) as Answer;
@@ -434,7 +453,7 @@ describe('Tool: update_trip', () => {
const result = await h.client.callTool({ name: 'update_trip', arguments: { tripId: trip.id, end_date: '2036-07-01' } });
expect(result.isError).toBe(true);
expect(JSON.stringify(result.content)).toContain(`at most ${MAX_TRIP_DAYS} days`);
const row = testDb.prepare('SELECT end_date FROM trips WHERE id = ?').get(trip.id) as { end_date: string };
const row = await tripRow(trip.id);
expect(row.end_date).toBe('2026-07-07');
});
});
@@ -444,9 +463,9 @@ describe('Tool: update_trip', () => {
const trip = createTrip(testDb, user.id);
await withHarness(user.id, async (h) => {
await h.client.callTool({ name: 'update_trip', arguments: { tripId: trip.id, reminder_days: 10 } });
expect((testDb.prepare('SELECT reminder_days FROM trips WHERE id = ?').get(trip.id) as any).reminder_days).toBe(10);
expect((await tripRow(trip.id)).reminder_days).toBe(10);
await h.client.callTool({ name: 'update_trip', arguments: { tripId: trip.id, reminder_days: 0 } });
expect((testDb.prepare('SELECT reminder_days FROM trips WHERE id = ?').get(trip.id) as any).reminder_days).toBe(0);
expect((await tripRow(trip.id)).reminder_days).toBe(0);
});
});
@@ -456,7 +475,7 @@ describe('Tool: update_trip', () => {
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'update_trip', arguments: { tripId: trip.id, reminder_days: 31 } });
expect(result.isError).toBe(true);
expect((testDb.prepare('SELECT reminder_days FROM trips WHERE id = ?').get(trip.id) as any).reminder_days).toBe(3);
expect((await tripRow(trip.id)).reminder_days).toBe(3);
});
});
@@ -465,7 +484,7 @@ describe('Tool: update_trip', () => {
const trip = createTrip(testDb, user.id, { description: 'A great trip' });
await withHarness(user.id, async (h) => {
await h.client.callTool({ name: 'update_trip', arguments: { tripId: trip.id, description: null } });
const row = testDb.prepare('SELECT description FROM trips WHERE id = ?').get(trip.id) as any;
const row = await tripRow(trip.id);
expect(row.description).toBeNull();
});
});
@@ -473,10 +492,10 @@ describe('Tool: update_trip', () => {
it('clears the cover image with an explicit null', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
testDb.prepare('UPDATE trips SET cover_image = ? WHERE id = ?').run('/uploads/covers/old.jpg', trip.id);
await updateRows(orm, Trips, { id: trip.id }, { cover_image: '/uploads/covers/old.jpg' });
await withHarness(user.id, async (h) => {
await h.client.callTool({ name: 'update_trip', arguments: { tripId: trip.id, cover_image: null } });
const row = testDb.prepare('SELECT cover_image FROM trips WHERE id = ?').get(trip.id) as any;
const row = await tripRow(trip.id);
expect(row.cover_image).toBeNull();
});
});
@@ -542,7 +561,7 @@ describe('Tool: search_cover_images', () => {
stubUnsplash({ results: [] });
await withHarness(user.id, async (h) => {
await h.client.callTool({ name: 'search_cover_images', arguments: { query: 'Lisbon' } });
const row = testDb.prepare('SELECT cover_image FROM trips WHERE id = ?').get(trip.id) as any;
const row = await tripRow(trip.id);
expect(row.cover_image).toBeNull();
});
});
@@ -560,8 +579,8 @@ describe('Tool: delete_trip', () => {
const result = await h.client.callTool({ name: 'delete_trip', arguments: { tripId: trip.id } });
const data = parseToolResult(result) as any;
expect(data.success).toBe(true);
const gone = testDb.prepare('SELECT id FROM trips WHERE id = ?').get(trip.id);
expect(gone).toBeUndefined();
const gone = await findRow(orm, Trips, { id: trip.id });
expect(gone).toBeNull();
});
});
@@ -573,7 +592,7 @@ describe('Tool: delete_trip', () => {
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'delete_trip', arguments: { tripId: trip.id } });
expect(result.isError).toBe(true);
const stillExists = testDb.prepare('SELECT id FROM trips WHERE id = ?').get(trip.id);
const stillExists = await findRow(orm, Trips, { id: trip.id });
expect(stillExists).toBeTruthy();
});
});
@@ -616,7 +635,7 @@ describe('Tool: list_trips', () => {
const { user } = createUser(testDb);
createTrip(testDb, user.id, { title: 'Active' });
const archived = createTrip(testDb, user.id, { title: 'Archived' });
testDb.prepare('UPDATE trips SET is_archived = 1 WHERE id = ?').run(archived.id);
await updateRows(orm, Trips, { id: archived.id }, { is_archived: 1 });
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'list_trips', arguments: {} });
@@ -630,7 +649,7 @@ describe('Tool: list_trips', () => {
const { user } = createUser(testDb);
createTrip(testDb, user.id, { title: 'Active' });
const archived = createTrip(testDb, user.id, { title: 'Archived' });
testDb.prepare('UPDATE trips SET is_archived = 1 WHERE id = ?').run(archived.id);
await updateRows(orm, Trips, { id: archived.id }, { is_archived: 1 });
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'list_trips', arguments: { include_archived: true } });
@@ -771,8 +790,8 @@ describe('Tool: get_trip_summary', () => {
const { user: member } = createUser(testDb);
const trip = createTrip(testDb, owner.id, { title: 'Shared Trip' });
addTripMember(testDb, trip.id, member.id);
testDb.prepare("INSERT INTO packing_items (trip_id, name, category, checked, is_private, owner_id) VALUES (?, 'Secret gift', 'Misc', 0, 1, ?)").run(trip.id, owner.id);
testDb.prepare("INSERT INTO packing_items (trip_id, name, category, checked, is_private, owner_id) VALUES (?, 'Sunscreen', 'Misc', 0, 0, ?)").run(trip.id, owner.id);
await makePackingItem(orm, trip.id, { name: 'Secret gift', category: 'Misc', checked: 0, is_private: 1, owner: owner.id });
await makePackingItem(orm, trip.id, { name: 'Sunscreen', category: 'Misc', checked: 0, is_private: 0, owner: owner.id });
await withHarness(member.id, async (h) => {
const result = await h.client.callTool({ name: 'get_trip_summary', arguments: { tripId: trip.id } });
@@ -814,7 +833,7 @@ describe('Resource: trek://trips', () => {
const { user } = createUser(testDb);
createTrip(testDb, user.id, { title: 'Active Trip' });
const archived = createTrip(testDb, user.id, { title: 'Archived Trip' });
testDb.prepare('UPDATE trips SET is_archived = 1 WHERE id = ?').run(archived.id);
await updateRows(orm, Trips, { id: archived.id }, { is_archived: 1 });
await withHarness(user.id, async (harness) => {
const result = await harness.client.readResource({ uri: 'trek://trips' });
+44 -25
View File
@@ -38,6 +38,25 @@ import { setAddonEnabled } from '../../helpers/test-db';
import { ADDON_IDS } from '../../../src/addons';
import { createMcpHarness, parseToolResult, parseResourceResult, type McpHarness } from '../../helpers/mcp-harness';
import { VacayService } from '../../../src/nest/vacay/vacay.service';
import { createTestOrm, type TestOrm } from '../../helpers/test-orm';
import { findRow, insertRow } from '../../helpers/factories/rows';
import { VacayEntries } from '../../../src/db/entities/VacayEntries.entity';
import { VacayHolidayCalendars } from '../../../src/db/entities/VacayHolidayCalendars.entity';
import { VacayPlanMembers } from '../../../src/db/entities/VacayPlanMembers.entity';
import { VacayPlans } from '../../../src/db/entities/VacayPlans.entity';
import { VacayShares } from '../../../src/db/entities/VacayShares.entity';
import { VacayUserSettings } from '../../../src/db/entities/VacayUserSettings.entity';
let orm: TestOrm;
beforeAll(async () => {
orm = await createTestOrm(testDb);
});
/** The plan the user was invited into. */
async function invitedPlanId(userId: number): Promise<number> {
return (await findRow(orm, VacayPlanMembers, { user: userId }))!.plan_id;
}
// The plan-settings tests below need the real SQL write, so keep the
// implementation the blanket stub replaces and delegate to it for one call.
@@ -70,7 +89,8 @@ beforeEach(() => {
delete process.env.DEMO_MODE;
});
afterAll(() => {
afterAll(async () => {
await orm.close();
testDb.close();
});
@@ -94,7 +114,7 @@ async function fusePlan(ownerId: number, memberId: number): Promise<number> {
await withHarness(ownerId, async (h) => {
await h.client.callTool({ name: 'send_vacay_invite', arguments: { targetUserId: memberId } });
});
const planId = (testDb.prepare('SELECT plan_id FROM vacay_plan_members WHERE user_id = ?').get(memberId) as { plan_id: number }).plan_id;
const planId = await invitedPlanId(memberId);
await withHarness(memberId, async (h) => {
await h.client.callTool({ name: 'accept_vacay_invite', arguments: { planId } });
});
@@ -148,7 +168,7 @@ describe('Tool: update_vacay_plan', () => {
const data = parseToolResult(result) as any;
expect(data.plan.school_holidays_enabled).toBe(true);
const row = testDb.prepare('SELECT school_holidays_enabled, weekend_days, week_start FROM vacay_plans WHERE owner_id = ?').get(user.id) as any;
const row = (await findRow(orm, VacayPlans, { owner: user.id }))!;
expect(row.school_holidays_enabled).toBe(1);
expect(row.weekend_days).toBe('5,6');
expect(row.week_start).toBe(0);
@@ -160,7 +180,7 @@ describe('Tool: update_vacay_plan', () => {
updatePlanSpy.mockImplementationOnce(realUpdatePlan);
await withHarness(user.id, async (h) => {
await h.client.callTool({ name: 'update_vacay_plan', arguments: { weekend_days: '1,2' } });
const row = testDb.prepare('SELECT weekend_days, week_start, school_holidays_enabled FROM vacay_plans WHERE owner_id = ?').get(user.id) as any;
const row = (await findRow(orm, VacayPlans, { owner: user.id }))!;
expect(row.weekend_days).toBe('1,2');
// Column defaults, not values this call wrote.
expect(row.week_start).toBe(1);
@@ -205,8 +225,7 @@ describe('Tool: get_vacay_year_settings', () => {
it('returns a stored fiscal window', async () => {
const { user } = createUser(testDb);
testDb.prepare('INSERT INTO vacay_user_settings (user_id, year_type, year_start_month, year_start_day, hire_date) VALUES (?, ?, ?, ?, ?)')
.run(user.id, 'fiscal', 4, 6, null);
await insertRow(orm, VacayUserSettings, { user: user.id, year_type: 'fiscal', year_start_month: 4, year_start_day: 6, hire_date: null });
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'get_vacay_year_settings', arguments: {} });
const data = parseToolResult(result) as any;
@@ -346,7 +365,7 @@ describe('Tool: toggle_vacay_entry', () => {
const data = parseToolResult(result) as any;
expect(data.action).toBe('added');
const row = testDb.prepare('SELECT fraction, kind FROM vacay_entries WHERE user_id = ? AND date = ?').get(user.id, '2025-06-16') as any;
const row = (await findRow(orm, VacayEntries, { user: user.id, date: '2025-06-16' }))!;
expect(row.fraction).toBe(0.5);
expect(row.kind).toBe('vacation');
});
@@ -356,7 +375,7 @@ describe('Tool: toggle_vacay_entry', () => {
const { user } = createUser(testDb);
await withHarness(user.id, async (h) => {
await h.client.callTool({ name: 'toggle_vacay_entry', arguments: { date: '2025-06-17', kind: 'comp' } });
const row = testDb.prepare('SELECT fraction, kind FROM vacay_entries WHERE user_id = ? AND date = ?').get(user.id, '2025-06-17') as any;
const row = (await findRow(orm, VacayEntries, { user: user.id, date: '2025-06-17' }))!;
expect(row.kind).toBe('comp');
expect(row.fraction).toBe(1);
});
@@ -364,19 +383,19 @@ describe('Tool: toggle_vacay_entry', () => {
it('converts the day in place on a different fraction and clears it on a repeat', async () => {
const { user } = createUser(testDb);
const row = () => testDb.prepare('SELECT fraction, kind FROM vacay_entries WHERE user_id = ? AND date = ?').get(user.id, '2025-06-18') as any;
const row = () => findRow(orm, VacayEntries, { user: user.id, date: '2025-06-18' });
await withHarness(user.id, async (h) => {
await h.client.callTool({ name: 'toggle_vacay_entry', arguments: { date: '2025-06-18', fraction: 0.5, kind: 'comp' } });
expect(row().fraction).toBe(0.5);
expect((await row())!.fraction).toBe(0.5);
const converted = await h.client.callTool({ name: 'toggle_vacay_entry', arguments: { date: '2025-06-18', fraction: 1, kind: 'comp' } });
expect((parseToolResult(converted) as any).action).toBe('updated');
expect(row().fraction).toBe(1);
expect(row().kind).toBe('comp');
expect((await row())!.fraction).toBe(1);
expect((await row())!.kind).toBe('comp');
const cleared = await h.client.callTool({ name: 'toggle_vacay_entry', arguments: { date: '2025-06-18', fraction: 1, kind: 'comp' } });
expect((parseToolResult(cleared) as any).action).toBe('removed');
expect(row()).toBeUndefined();
expect(await row()).toBeNull();
});
});
@@ -392,7 +411,7 @@ describe('Tool: toggle_vacay_entry', () => {
});
expect((parseToolResult(result) as any).action).toBe('added');
const row = testDb.prepare('SELECT user_id, fraction FROM vacay_entries WHERE date = ?').get('2025-06-19') as any;
const row = (await findRow(orm, VacayEntries, { date: '2025-06-19' }))!;
expect(row.user_id).toBe(member.id);
expect(row.fraction).toBe(0.5);
});
@@ -408,7 +427,7 @@ describe('Tool: toggle_vacay_entry', () => {
});
expect(result.isError).toBe(true);
expect(errorText(result)).toBe('User not in plan');
expect(testDb.prepare('SELECT id FROM vacay_entries WHERE user_id = ?').get(stranger.id)).toBeUndefined();
expect(await findRow(orm, VacayEntries, { user: stranger.id })).toBeNull();
});
});
@@ -417,7 +436,7 @@ describe('Tool: toggle_vacay_entry', () => {
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'toggle_vacay_entry', arguments: { date: '2025-06-21', fraction: 0.25 } });
expect(result.isError).toBe(true);
expect(testDb.prepare('SELECT id FROM vacay_entries WHERE date = ?').get('2025-06-21')).toBeUndefined();
expect(await findRow(orm, VacayEntries, { date: '2025-06-21' })).toBeNull();
});
});
@@ -426,7 +445,7 @@ describe('Tool: toggle_vacay_entry', () => {
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'toggle_vacay_entry', arguments: { date: '2025-06-22', kind: 'sabbatical' } });
expect(result.isError).toBe(true);
expect(testDb.prepare('SELECT id FROM vacay_entries WHERE date = ?').get('2025-06-22')).toBeUndefined();
expect(await findRow(orm, VacayEntries, { date: '2025-06-22' })).toBeNull();
});
});
@@ -542,7 +561,7 @@ describe('Tool: add_holiday_calendar', () => {
arguments: { region: 'DE-BY', type: 'school_holiday' },
});
const data = parseToolResult(result) as any;
const row = testDb.prepare('SELECT type, region, color FROM vacay_holiday_calendars WHERE id = ?').get(data.calendar.id) as any;
const row = (await findRow(orm, VacayHolidayCalendars, { id: data.calendar.id }))!;
expect(row.type).toBe('school_holiday');
expect(row.region).toBe('DE-BY');
expect(row.color).toBe('#a5f3fc');
@@ -554,7 +573,7 @@ describe('Tool: add_holiday_calendar', () => {
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'add_holiday_calendar', arguments: { region: 'GB' } });
const data = parseToolResult(result) as any;
const row = testDb.prepare('SELECT type, color FROM vacay_holiday_calendars WHERE id = ?').get(data.calendar.id) as any;
const row = (await findRow(orm, VacayHolidayCalendars, { id: data.calendar.id }))!;
expect(row.type).toBe('public_holiday');
expect(row.color).toBe('#fecaca');
});
@@ -565,7 +584,7 @@ describe('Tool: add_holiday_calendar', () => {
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'add_holiday_calendar', arguments: { region: 'DE', type: 'bank_holiday' } });
expect(result.isError).toBe(true);
expect(testDb.prepare('SELECT id FROM vacay_holiday_calendars WHERE region = ?').get('DE')).toBeUndefined();
expect(await findRow(orm, VacayHolidayCalendars, { region: 'DE' })).toBeNull();
});
});
@@ -829,8 +848,8 @@ describe('Tool: share_vacay_calendar', () => {
const result = await h.client.callTool({ name: 'share_vacay_calendar', arguments: { targetUserId: other.id } });
const data = parseToolResult(result) as any;
expect(data.success).toBe(true);
const row = testDb.prepare('SELECT id FROM vacay_shares WHERE owner_id = ? AND user_id = ?').get(user.id, other.id);
expect(row).toBeDefined();
const row = await findRow(orm, VacayShares, { owner: user.id, user: other.id });
expect(row).not.toBeNull();
});
});
@@ -863,12 +882,12 @@ describe('Tool: unshare_vacay_calendar', () => {
const { user: other } = createUser(testDb);
await withHarness(user.id, async (h) => {
await h.client.callTool({ name: 'share_vacay_calendar', arguments: { targetUserId: other.id } });
const shareId = (testDb.prepare('SELECT id FROM vacay_shares WHERE owner_id = ?').get(user.id) as any).id;
const shareId = (await findRow(orm, VacayShares, { owner: user.id }))!.id;
const result = await h.client.callTool({ name: 'unshare_vacay_calendar', arguments: { shareId } });
const data = parseToolResult(result) as any;
expect(data.success).toBe(true);
expect(testDb.prepare('SELECT id FROM vacay_shares WHERE id = ?').get(shareId)).toBeUndefined();
expect(await findRow(orm, VacayShares, { id: shareId })).toBeNull();
});
});
@@ -964,7 +983,7 @@ describe('Tool: decline_vacay_invite', () => {
await h.client.callTool({ name: 'send_vacay_invite', arguments: { targetUserId: invitee.id } });
});
await withHarness(invitee.id, async (h) => {
const result = await h.client.callTool({ name: 'decline_vacay_invite', arguments: { planId: (testDb.prepare('SELECT plan_id FROM vacay_plan_members WHERE user_id = ?').get(invitee.id) as { plan_id: number }).plan_id } });
const result = await h.client.callTool({ name: 'decline_vacay_invite', arguments: { planId: await invitedPlanId(invitee.id) } });
const data = parseToolResult(result) as any;
expect(data.success).toBe(true);
});