diff --git a/server/tests/unit/mcp/tools-assignments-reservations-extra.test.ts b/server/tests/unit/mcp/tools-assignments-reservations-extra.test.ts index 9f86b71f5..5d645791e 100644 --- a/server/tests/unit/mcp/tools-assignments-reservations-extra.test.ts +++ b/server/tests/unit/mcp/tools-assignments-reservations-extra.test.ts @@ -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', diff --git a/server/tests/unit/mcp/tools-assignments.test.ts b/server/tests/unit/mcp/tools-assignments.test.ts index 87f80f712..0d7992faf 100644 --- a/server/tests/unit/mcp/tools-assignments.test.ts +++ b/server/tests/unit/mcp/tools-assignments.test.ts @@ -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 { + 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 }); }); }); }); diff --git a/server/tests/unit/mcp/tools-atlas.test.ts b/server/tests/unit/mcp/tools-atlas.test.ts index 18b877f69..58b41ed12 100644 --- a/server/tests/unit/mcp/tools-atlas.test.ts +++ b/server/tests/unit/mcp/tools-atlas.test.ts @@ -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(); }); }); diff --git a/server/tests/unit/mcp/tools-categories.test.ts b/server/tests/unit/mcp/tools-categories.test.ts index 0aea19d7c..56cccec69 100644 --- a/server/tests/unit/mcp/tools-categories.test.ts +++ b/server/tests/unit/mcp/tools-categories.test.ts @@ -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 { + 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(); }); }); }); diff --git a/server/tests/unit/mcp/tools-days-accommodations.test.ts b/server/tests/unit/mcp/tools-days-accommodations.test.ts index 32ba89463..c67075b2e 100644 --- a/server/tests/unit/mcp/tools-days-accommodations.test.ts +++ b/server/tests/unit/mcp/tools-days-accommodations.test.ts @@ -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); }); }); }); diff --git a/server/tests/unit/mcp/tools-notifications.test.ts b/server/tests/unit/mcp/tools-notifications.test.ts index 586bc5e41..9df3fa25a 100644 --- a/server/tests/unit/mcp/tools-notifications.test.ts +++ b/server/tests/unit/mcp/tools-notifications.test.ts @@ -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) { @@ -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; diff --git a/server/tests/unit/mcp/tools-packing-advanced.test.ts b/server/tests/unit/mcp/tools-packing-advanced.test.ts index 32dd54d4c..48585933f 100644 --- a/server/tests/unit/mcp/tools-packing-advanced.test.ts +++ b/server/tests/unit/mcp/tools-packing-advanced.test.ts @@ -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); }); }); diff --git a/server/tests/unit/mcp/tools-packing.test.ts b/server/tests/unit/mcp/tools-packing.test.ts index 8987c1926..c1c8e4bfb 100644 --- a/server/tests/unit/mcp/tools-packing.test.ts +++ b/server/tests/unit/mcp/tools-packing.test.ts @@ -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[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({ diff --git a/server/tests/unit/mcp/tools-places.test.ts b/server/tests/unit/mcp/tools-places.test.ts index ee49b452a..e25489988 100644 --- a/server/tests/unit/mcp/tools-places.test.ts +++ b/server/tests/unit/mcp/tools-places.test.ts @@ -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 { + 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 () => { diff --git a/server/tests/unit/mcp/tools-prompts.test.ts b/server/tests/unit/mcp/tools-prompts.test.ts index d6cdef89f..8f74095fd 100644 --- a/server/tests/unit/mcp/tools-prompts.test.ts +++ b/server/tests/unit/mcp/tools-prompts.test.ts @@ -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(); }); diff --git a/server/tests/unit/mcp/tools-reservations.test.ts b/server/tests/unit/mcp/tools-reservations.test.ts index b8bf45cf1..a45f81132 100644 --- a/server/tests/unit/mcp/tools-reservations.test.ts +++ b/server/tests/unit/mcp/tools-reservations.test.ts @@ -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 { + 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 = {}) { @@ -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', diff --git a/server/tests/unit/mcp/tools-roadtrip.test.ts b/server/tests/unit/mcp/tools-roadtrip.test.ts index 2241cb352..95a35c2fc 100644 --- a/server/tests/unit/mcp/tools-roadtrip.test.ts +++ b/server/tests/unit/mcp/tools-roadtrip.test.ts @@ -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) { const h = await createMcpHarness({ userId, withResources: false, scopes: null }); @@ -55,12 +66,12 @@ async function withHarness(userId: number, fn: (h: McpHarness) => Promise) } /** 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({ diff --git a/server/tests/unit/mcp/tools-trip-members.test.ts b/server/tests/unit/mcp/tools-trip-members.test.ts index 9cf1d61ab..e65a444e0 100644 --- a/server/tests/unit/mcp/tools-trip-members.test.ts +++ b/server/tests/unit/mcp/tools-trip-members.test.ts @@ -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) /** Lower a configurable action the way the admin permission panel does. */ async function setPermission(action: string, level: string): Promise { - 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); }); }); diff --git a/server/tests/unit/mcp/tools-trips.test.ts b/server/tests/unit/mcp/tools-trips.test.ts index 39ae32b17..cb8292980 100644 --- a/server/tests/unit/mcp/tools-trips.test.ts +++ b/server/tests/unit/mcp/tools-trips.test.ts @@ -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 { + 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 { + 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) { 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' }); diff --git a/server/tests/unit/mcp/tools-vacay.test.ts b/server/tests/unit/mcp/tools-vacay.test.ts index 5d50aaca4..08959173a 100644 --- a/server/tests/unit/mcp/tools-vacay.test.ts +++ b/server/tests/unit/mcp/tools-vacay.test.ts @@ -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 { + 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 { 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); });