Files
TREK/server/tests/unit/mcp/tools-assignments.test.ts
T
Maurice cc1c16ec34 fix(assignments): keep untimed stops in place when a start is saved
Saving a start on a stop rewrote the whole day as the timed stops in
time order followed by every untimed stop. Its own comment and the
commit that added it promised that untimed stops keep their place, but
a day of two untimed stops and a third one given 14:00 came back with
the third one first. On a road trip the stop order is the drive, so
the day was re-routed, its vias landed on other legs, and collaborators,
who only got the one edited row, saw a third order until they reloaded.

The intended rule, an untimed stop inheriting the time of the timed
stop before it, now lives once as chronoOrder in @trek/shared, used by
both the day plan (dayMerge) and AssignmentsService.updateTime. The
server still reads a booked night's hour from its check-in, and a value
without a colon still sorts after every clock time.

The day is sorted only when the stop's start actually changed, so
taking an End off or saving an unchanged start no longer snaps back a
day somebody dragged out of time order. A sorted day is numbered from 0
like a drag. In the same transaction the day's road trip vias follow
the stop they were drawn behind, the planner's own drag rule, and a
via behind a last stop that stays last is kept, so the drive into the
next day keeps its road.

PUT /assignments/:id/time and the MCP update_assignment_time tool both
broadcast assignment:reordered with the whole day, to the writer as
well, and roadtripVia:changed when vias moved, so every client ends up
with the same order.

ASG-SVC-022, 023 and 027 pinned the old behaviour and now pin the
intended one.
2026-09-19 19:24:38 +02:00

579 lines
26 KiB
TypeScript

/**
* Unit tests for MCP assignment tools: assign_place_to_day, unassign_place,
* reorder_day_assignments, update_assignment_time, update_assignment_notes.
*/
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
const { testDb, dbMock } = vi.hoisted(() => {
const Database = require('better-sqlite3');
const db = new Database(':memory:');
db.exec('PRAGMA journal_mode = WAL');
db.exec('PRAGMA foreign_keys = ON');
db.exec('PRAGMA busy_timeout = 5000');
const mock = {
db,
closeDb: () => {},
reinitialize: () => {},
getPlaceWithTags: () => null,
canAccessTrip: (tripId: any, userId: number) =>
db.prepare(`SELECT t.id, t.user_id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)`).get(userId, tripId, userId),
isOwner: (tripId: any, userId: number) =>
!!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId),
};
return { testDb: db, dbMock: mock };
});
vi.mock('../../../src/db/database', () => dbMock);
vi.mock('../../../src/config', () => ({
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
updateJwtSecret: () => {},
}));
const { broadcastMock } = vi.hoisted(() => ({ broadcastMock: vi.fn() }));
vi.mock('../../../src/websocket', () => ({ broadcast: broadcastMock }));
import { createTables } from '../../../src/db/schema';
import { runMigrations } from '../../../src/db/migrations';
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';
/** 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());
}
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;
}
beforeAll(() => {
createTables(testDb);
runMigrations(testDb);
});
beforeEach(() => {
resetTestDb(testDb);
broadcastMock.mockClear();
delete process.env.DEMO_MODE;
});
afterAll(() => {
testDb.close();
});
async function withHarness(userId: number, fn: (h: McpHarness) => Promise<void>) {
const h = await createMcpHarness({ userId, withResources: false });
try { await fn(h); } finally { await h.cleanup(); }
}
// ---------------------------------------------------------------------------
// assign_place_to_day
// ---------------------------------------------------------------------------
describe('Tool: assign_place_to_day', () => {
it('sets and clears a visit day end through the shared service', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const day = createDay(testDb, trip.id);
const place = createPlace(testDb, trip.id);
const visit = createDayAssignment(testDb, day.id, place.id);
await withHarness(user.id, async h => {
for (const end_day of [true, false]) {
const changed = await h.client.callTool({ name: 'set_assignment_end_day', arguments: { tripId: trip.id, assignmentId: visit.id, end_day } });
expect(parseToolResult(changed)).toMatchObject({ assignment: { end_day } });
}
const other = createTrip(testDb, user.id);
const refused = await h.client.callTool({ name: 'set_assignment_end_day', arguments: { tripId: other.id, assignmentId: visit.id, end_day: true } });
expect(refused.isError).toBe(true);
});
});
it('assigns a place to a day', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const day = createDay(testDb, trip.id);
const place = createPlace(testDb, trip.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'assign_place_to_day',
arguments: { tripId: trip.id, dayId: day.id, placeId: place.id },
});
const data = parseToolResult(result) as any;
expect(data.assignment).toBeTruthy();
expect(data.assignment.day_id).toBe(day.id);
expect(data.assignment.place_id).toBe(place.id);
expect(data.assignment.order_index).toBe(0);
});
});
it('creates a skeleton suggestion in a linked journey', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
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 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');
});
});
it('auto-increments order_index for subsequent assignments', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const day = createDay(testDb, trip.id);
const place1 = createPlace(testDb, trip.id, { name: 'P1' });
const place2 = createPlace(testDb, trip.id, { name: 'P2' });
createDayAssignment(testDb, day.id, place1.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'assign_place_to_day',
arguments: { tripId: trip.id, dayId: day.id, placeId: place2.id },
});
const data = parseToolResult(result) as any;
expect(data.assignment.order_index).toBe(1);
});
});
it('broadcasts assignment:created event', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const day = createDay(testDb, trip.id);
const place = createPlace(testDb, 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 } });
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'assignment:created', expect.any(Object));
});
});
it('returns error when day does not belong to trip', async () => {
const { user } = createUser(testDb);
const trip1 = createTrip(testDb, user.id);
const trip2 = createTrip(testDb, user.id);
const dayFromTrip2 = createDay(testDb, trip2.id);
const place = createPlace(testDb, trip1.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'assign_place_to_day',
arguments: { tripId: trip1.id, dayId: dayFromTrip2.id, placeId: place.id },
});
expect(result.isError).toBe(true);
});
});
it('returns error when place does not belong to trip', async () => {
const { user } = createUser(testDb);
const trip1 = createTrip(testDb, user.id);
const trip2 = createTrip(testDb, user.id);
const day = createDay(testDb, trip1.id);
const placeFromTrip2 = createPlace(testDb, trip2.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'assign_place_to_day',
arguments: { tripId: trip1.id, dayId: day.id, placeId: placeFromTrip2.id },
});
expect(result.isError).toBe(true);
});
});
it('returns access denied for non-member', async () => {
const { user } = createUser(testDb);
const { user: other } = createUser(testDb);
const trip = createTrip(testDb, other.id);
const day = createDay(testDb, trip.id);
const place = createPlace(testDb, trip.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'assign_place_to_day', arguments: { tripId: trip.id, dayId: day.id, placeId: place.id } });
expect(result.isError).toBe(true);
});
});
});
// ---------------------------------------------------------------------------
// unassign_place
// ---------------------------------------------------------------------------
describe('Tool: unassign_place', () => {
it('removes a place assignment from a day', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const day = createDay(testDb, trip.id);
const place = createPlace(testDb, trip.id);
const assignment = createDayAssignment(testDb, day.id, place.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'unassign_place',
arguments: { tripId: trip.id, dayId: day.id, assignmentId: assignment.id },
});
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();
});
});
it('broadcasts assignment:deleted event', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const day = createDay(testDb, trip.id);
const place = createPlace(testDb, trip.id);
const assignment = createDayAssignment(testDb, day.id, place.id);
await withHarness(user.id, async (h) => {
await h.client.callTool({ name: 'unassign_place', arguments: { tripId: trip.id, dayId: day.id, assignmentId: assignment.id } });
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'assignment:deleted', expect.any(Object));
});
});
it('removes the linked journey skeleton when the place is unassigned', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const day = createDay(testDb, trip.id);
const place = createPlace(testDb, trip.id);
const journey = createJourney(testDb, user.id);
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();
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();
});
});
it('returns error when assignment is not found', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const day = createDay(testDb, trip.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'unassign_place', arguments: { tripId: trip.id, dayId: day.id, assignmentId: 99999 } });
expect(result.isError).toBe(true);
});
});
it('returns access denied for non-member', async () => {
const { user } = createUser(testDb);
const { user: other } = createUser(testDb);
const trip = createTrip(testDb, other.id);
const day = createDay(testDb, trip.id);
const place = createPlace(testDb, trip.id);
const assignment = createDayAssignment(testDb, day.id, place.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'unassign_place', arguments: { tripId: trip.id, dayId: day.id, assignmentId: assignment.id } });
expect(result.isError).toBe(true);
});
});
});
// ---------------------------------------------------------------------------
// reorder_day_assignments
// ---------------------------------------------------------------------------
describe('Tool: reorder_day_assignments', () => {
it('reorders assignments by updating order_index', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const day = createDay(testDb, trip.id);
const place1 = createPlace(testDb, trip.id, { name: 'First' });
const place2 = createPlace(testDb, trip.id, { name: 'Second' });
const a1 = createDayAssignment(testDb, day.id, place1.id, { order_index: 0 });
const a2 = createDayAssignment(testDb, day.id, place2.id, { order_index: 1 });
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'reorder_day_assignments',
arguments: { tripId: trip.id, dayId: day.id, assignmentIds: [a2.id, a1.id] },
});
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 };
expect(a2Updated.order_index).toBe(0);
expect(a1Updated.order_index).toBe(1);
});
});
it('broadcasts assignment:reordered event', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const day = createDay(testDb, trip.id);
const place = createPlace(testDb, trip.id);
const a = createDayAssignment(testDb, day.id, place.id);
await withHarness(user.id, async (h) => {
await h.client.callTool({ name: 'reorder_day_assignments', arguments: { tripId: trip.id, dayId: day.id, assignmentIds: [a.id] } });
expect(broadcastMock).toHaveBeenCalledWith(
trip.id,
'assignment:reordered',
expect.objectContaining({ dayId: day.id, orderedIds: [a.id] }),
);
});
});
it('returns error when day does not belong to trip', async () => {
const { user } = createUser(testDb);
const trip1 = createTrip(testDb, user.id);
const trip2 = createTrip(testDb, user.id);
const day = createDay(testDb, trip2.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'reorder_day_assignments', arguments: { tripId: trip1.id, dayId: day.id, assignmentIds: [1] } });
expect(result.isError).toBe(true);
});
});
it('returns access denied for non-member', async () => {
const { user } = createUser(testDb);
const { user: other } = createUser(testDb);
const trip = createTrip(testDb, other.id);
const day = createDay(testDb, trip.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'reorder_day_assignments', arguments: { tripId: trip.id, dayId: day.id, assignmentIds: [1] } });
expect(result.isError).toBe(true);
});
});
});
// ---------------------------------------------------------------------------
// update_assignment_time
// ---------------------------------------------------------------------------
describe('Tool: update_assignment_time', () => {
it('sets start and end times for an assignment', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const day = createDay(testDb, trip.id);
const place = createPlace(testDb, trip.id);
const assignment = createDayAssignment(testDb, day.id, place.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'update_assignment_time',
arguments: { tripId: trip.id, assignmentId: assignment.id, place_time: '09:00', end_time: '11:30' },
});
const data = parseToolResult(result) as any;
expect(data.assignment.assignment_time).toBe('09:00');
expect(data.assignment.assignment_end_time).toBe('11:30');
});
});
it('clears times with null', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
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 withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'update_assignment_time',
arguments: { tripId: trip.id, assignmentId: assignment.id, place_time: null, end_time: null },
});
const data = parseToolResult(result) as any;
expect(data.assignment.assignment_time).toBeNull();
expect(data.assignment.assignment_end_time).toBeNull();
});
});
it('broadcasts assignment:updated event', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const day = createDay(testDb, trip.id);
const place = createPlace(testDb, trip.id);
const assignment = createDayAssignment(testDb, day.id, place.id);
await withHarness(user.id, async (h) => {
await h.client.callTool({ name: 'update_assignment_time', arguments: { tripId: trip.id, assignmentId: assignment.id, place_time: '10:00' } });
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'assignment:updated', expect.any(Object));
});
});
// Same service method as PUT /assignments/:id/time, so the same order and the same
// three events. A: untimed, B: 15:00, C: untimed, D gets 10:00.
it('keeps untimed stops in place, sorts the timed ones and sends the day and its vias like REST', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
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 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);
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(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,
vias: [expect.objectContaining({ id: via, after_order_index: 2 })],
}));
});
it('sends only the row when the start leaves the day as it was', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const day = createDay(testDb, trip.id);
const place = createPlace(testDb, trip.id);
const [a, b, c] = [0, 1, 2].map(i => createDayAssignment(testDb, day.id, place.id, { order_index: i }).id);
await withHarness(user.id, async (h) => {
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);
expect(order).toEqual([a, b, c]);
expect(broadcastMock.mock.calls.map(call => call[1])).toEqual(['assignment:updated']);
});
it('leaves a day out of time order as it is when the call names only the end', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const day = createDay(testDb, trip.id);
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 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' } });
expect((parseToolResult(result) as { assignment: { assignment_time: string; assignment_end_time: string } }).assignment)
.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);
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(broadcastMock.mock.calls.map(call => call[1])).toEqual(['assignment:updated']);
});
it('returns error when assignment not found', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'update_assignment_time', arguments: { tripId: trip.id, assignmentId: 99999, place_time: '09:00' } });
expect(result.isError).toBe(true);
});
});
it('returns access denied for non-member', async () => {
const { user } = createUser(testDb);
const { user: other } = createUser(testDb);
const trip = createTrip(testDb, other.id);
const day = createDay(testDb, trip.id);
const place = createPlace(testDb, trip.id);
const assignment = createDayAssignment(testDb, day.id, place.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'update_assignment_time', arguments: { tripId: trip.id, assignmentId: assignment.id, place_time: '09:00' } });
expect(result.isError).toBe(true);
});
});
});
// ---------------------------------------------------------------------------
// update_assignment_notes (#2163)
// ---------------------------------------------------------------------------
describe('Tool: update_assignment_notes', () => {
it('sets the day-specific note on an assignment', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const day = createDay(testDb, trip.id);
const place = createPlace(testDb, trip.id);
const assignment = createDayAssignment(testDb, day.id, place.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'update_assignment_notes',
arguments: { tripId: trip.id, assignmentId: assignment.id, notes: 'Book the 10:00 timed entry and arrive 15 minutes early' },
});
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' });
});
});
it('clears the note with null and with an empty string', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
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 withHarness(user.id, async (h) => {
const cleared = parseToolResult(await h.client.callTool({
name: '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);
const emptied = parseToolResult(await h.client.callTool({
name: 'update_assignment_notes',
arguments: { tripId: trip.id, assignmentId: assignment.id, notes: '' },
})) as any;
expect(emptied.assignment.notes).toBeNull();
});
});
it('broadcasts assignment:updated event', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const day = createDay(testDb, trip.id);
const place = createPlace(testDb, trip.id);
const assignment = createDayAssignment(testDb, day.id, place.id);
await withHarness(user.id, async (h) => {
await h.client.callTool({ name: 'update_assignment_notes', arguments: { tripId: trip.id, assignmentId: assignment.id, notes: 'n' } });
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'assignment:updated', expect.any(Object));
});
});
it('returns error when assignment not found', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'update_assignment_notes', arguments: { tripId: trip.id, assignmentId: 99999, notes: 'n' } });
expect(result.isError).toBe(true);
});
});
it('returns access denied for non-member', async () => {
const { user } = createUser(testDb);
const { user: other } = createUser(testDb);
const trip = createTrip(testDb, other.id);
const day = createDay(testDb, trip.id);
const place = createPlace(testDb, trip.id);
const assignment = createDayAssignment(testDb, day.id, place.id);
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 });
});
});
});