Files
TREK/server/tests/unit/days/days-itinerary.rpc.test.ts
T
Maurice 2f6f921aef feat(dayplan): a day can be deleted from the reorder dialog, after a warning that says what goes with it
Every row of the reorder dialog, and of the days sheet on a phone, now has a delete button. It first asks, and the question lists what goes with the day: planned places stay in the place list, notes and day texts are deleted, bookings stay without a day, a stay that checks in or out on the day is cancelled with its booking and expense (shown in the danger tone), and on a dated trip the later days and their bookings move one date earlier or the trip ends a day earlier.

The server side moved from a bare DELETE into DayRemovalService, which REST, the delete_day tool and the plugin RPC all use. It closes the numbering, keeps the dates on their positions like a reorder, re-stamps the moved bookings, shifts the road trip boundaries, cancels touching stays through the accommodations domain, refuses the last day of a trip, and fans out day:deleted, day:reordered and trip:updated so collaborators renumber and see the new day count and end date. The trip query moved into a leaf file so the days domain can read it without a module cycle.

On the client the delete is online only, runs optimistically through the days slice with the same reducer collaborators apply, and the ConfirmDialog gained a slot for extra content, an Escape that only closes itself, and the danger and accent tokens.
2026-09-23 22:40:46 +02:00

190 lines
11 KiB
TypeScript

/**
* The day and itinerary plugin surfaces after they moved onto @PluginMethod.
*
* They are tested together because they share the gate: assigning a place to a day
* counts as a DAY edit in the app, so itinerary.* runs under day_edit rather than a
* permission of its own, and both halves scope their rows to the trip before writing.
*/
import { describe, it, expect, vi } from 'vitest';
import { expectRegisteredProvider } from '../../helpers/module-providers';
import { PluginRpcHost } from '../../../src/nest/plugins/host/rpc-host';
import { createTestPluginRegistry } from '../../../src/nest/plugins/host/rpc-kit/testing';
import { PluginGuards } from '../../../src/nest/plugins/host/plugin-guards.service';
import { DaysRpc } from '../../../src/nest/days/days.rpc';
import { DaysModule } from '../../../src/nest/days/days.module';
import { ItineraryRpc } from '../../../src/nest/assignments/itinerary.rpc';
import { AssignmentsModule } from '../../../src/nest/assignments/assignments.module';
import type { DaysService } from '../../../src/nest/days/days.service';
import { DayDeleteError, type DayRemoval, type DayRemovalService } from '../../../src/nest/days/day-removal.service';
import type { AssignmentsService } from '../../../src/nest/assignments/assignments.service';
import type { RealtimeService } from '../../../src/nest/realtime/realtime.service';
import type { DatabaseService } from '../../../src/nest/database/database.service';
import type { PermissionsService } from '../../../src/nest/permissions/permissions.service';
import type { AddonsService } from '../../../src/nest/addons/addons.service';
import type { RpcRequest, RpcError } from '../../../src/nest/plugins/protocol/envelope';
import { makeDeps } from '../../helpers/rpc-host-deps';
const req = (method: string, params: Record<string, unknown> = {}): RpcRequest => ({ k: 'req', id: 'x', method, params });
/** Trip 1 belongs to user 42; day 3, place 7 and assignment 30 sit on it. */
function build(opts: { canEdit?: boolean } = {}) {
const days = {
create: vi.fn((tripId: number, date?: string) => ({ id: 20, trip_id: tripId, date })),
getDay: vi.fn((dayId: number, tripId: number) => (dayId === 3 && tripId === 1 ? { id: 3 } : undefined)),
update: vi.fn((id: number) => ({ id })),
} as unknown as DaysService & Record<string, ReturnType<typeof vi.fn>>;
const removed: DayRemoval = {
dayId: 3, orderedIds: [4], stayIds: [], reservationIds: [], budgetItemIds: [], mirrors: [],
boundaries: null, endDate: null, trip: { id: 1 },
};
// announce() is the removal service's own fan-out (DAY-DEL-014); the stub sends
// one event through each sender so the case can see where they lead.
const removal = {
remove: vi.fn(() => removed),
announce: vi.fn((_tripId: number, r: DayRemoval, senders: Parameters<DayRemovalService['announce']>[2]) => {
senders.others('day:deleted', { dayId: r.dayId });
senders.all('day:reordered', { orderedIds: r.orderedIds });
}),
} as unknown as DayRemovalService;
const assignments = {
dayExists: vi.fn((dayId: number, tripId: number) => dayId === 3 && tripId === 1),
placeExists: vi.fn((placeId: number, tripId: number) => placeId === 7 && tripId === 1),
createAssignment: vi.fn(() => ({ id: 30, day_id: 3, place_id: 7 })),
getAssignmentForTrip: vi.fn((id: number, tripId: number) => (id === 30 && tripId === 1 ? { id: 30, day_id: 3 } : undefined)),
deleteAssignment: vi.fn(),
reconcile: vi.fn(),
} as unknown as AssignmentsService & Record<string, ReturnType<typeof vi.fn>>;
const realtime = { broadcast: vi.fn() } as unknown as RealtimeService & { broadcast: ReturnType<typeof vi.fn> };
const guards = new PluginGuards(
{
canAccessTrip: vi.fn((tripId: number, userId: number) => (tripId === 1 && userId === 42 ? { id: 1, user_id: 42 } : undefined)),
prepare: vi.fn(() => ({ get: () => ({ role: 'user' }) })),
} as unknown as DatabaseService,
{ checkPermission: vi.fn(() => opts.canEdit ?? true) } as unknown as PermissionsService,
{ isAddonEnabled: vi.fn(() => true) } as unknown as AddonsService,
);
const registry = createTestPluginRegistry([new DaysRpc(days, realtime, guards, removal), new ItineraryRpc(assignments, realtime, guards)]);
const host = (...grants: string[]) => new PluginRpcHost('p', new Set(grants), makeDeps(), registry);
return { days, assignments, realtime, host, removal, removed };
}
describe('DaysRpc', () => {
it('DAYS-RPC-001 writes need the grant, the edit right and a bound user', async () => {
const f = build();
const host = f.host('db:write:days');
expect((await host.dispatch(req('days.create', { tripId: 1, input: { date: '2027-01-01' } }), 42)).ok).toBe(true);
expect(((await host.dispatch(req('days.create', { tripId: 2, input: {} }), 42)) as RpcError).error.code).toBe('RESOURCE_FORBIDDEN');
const noUser = (await host.dispatch(req('days.create', { tripId: 1, input: {} }), undefined)) as RpcError;
expect(noUser.error.message).toBe('day writes require an authenticated user context');
});
it('DAYS-RPC-002 a day on another trip is refused for update and delete alike', async () => {
const f = build();
const host = f.host('db:write:days');
expect(((await host.dispatch(req('days.update', { tripId: 1, dayId: 404, input: {} }), 42)) as RpcError).error.message)
.toBe('no day 404 on trip 1');
expect(((await host.dispatch(req('days.delete', { tripId: 1, dayId: 404 }), 42)) as RpcError).error.message)
.toBe('no day 404 on trip 1');
expect(f.removal.remove).not.toHaveBeenCalled();
});
it('DAYS-RPC-003 every write broadcasts its event', async () => {
const f = build();
const host = f.host('db:write:days');
await host.dispatch(req('days.create', { tripId: 1, input: {} }), 42);
await host.dispatch(req('days.update', { tripId: 1, dayId: 3, input: { notes: 'x' } }), 42);
await host.dispatch(req('days.delete', { tripId: 1, dayId: 3 }), 42);
expect(f.realtime.broadcast.mock.calls.map((c) => c[1])).toEqual(['day:created', 'day:updated', 'day:deleted', 'day:reordered']);
});
it('DAYS-RPC-004 the class is listed in its module providers', () => {
expectRegisteredProvider(DaysModule, DaysRpc);
});
it('DAYS-RPC-005 the last day of a trip is BAD_PARAMS with the sentence REST and MCP give, and nothing is announced', async () => {
const f = build();
vi.mocked(f.removal.remove).mockImplementation(() => { throw new DayDeleteError('A trip needs at least one day.'); });
const res = (await f.host('db:write:days').dispatch(req('days.delete', { tripId: 1, dayId: 3 }), 42)) as RpcError;
expect(res.error).toMatchObject({ code: 'BAD_PARAMS', message: 'A trip needs at least one day.' });
expect(f.removal.announce).not.toHaveBeenCalled();
expect(f.realtime.broadcast).not.toHaveBeenCalled();
});
it('DAYS-RPC-006 a delete runs as the bound user and fans out through the shared announce, to every socket', async () => {
const f = build();
const res = await f.host('db:write:days').dispatch(req('days.delete', { tripId: 1, dayId: 3 }), 42);
expect(res).toMatchObject({ ok: true, result: { deleted: true } });
expect(f.removal.remove).toHaveBeenCalledWith(1, 3, { userId: 42 });
const senders = vi.mocked(f.removal.announce).mock.calls[0][2];
// A plugin has no socket of its own, so nobody is left out.
expect(senders.all).toBe(senders.others);
expect(f.realtime.broadcast.mock.calls).toEqual([
[1, 'day:deleted', { dayId: 3 }],
[1, 'day:reordered', { orderedIds: [4] }],
]);
});
it('DAYS-RPC-006b anything but the last-day refusal is not dressed up as bad params', async () => {
const f = build();
vi.mocked(f.removal.remove).mockImplementation(() => { throw new Error('db is down'); });
const res = (await f.host('db:write:days').dispatch(req('days.delete', { tripId: 1, dayId: 3 }), 42)) as RpcError;
expect(res.error.code).not.toBe('BAD_PARAMS');
});
});
describe('ItineraryRpc', () => {
it('ITIN-RPC-001 assign links a day and a place that both belong to the trip', async () => {
const f = build();
const res = await f.host('db:write:itinerary').dispatch(req('itinerary.assign', { tripId: 1, dayId: 3, placeId: 7 }), 42);
expect(res.ok).toBe(true);
expect(f.assignments.createAssignment).toHaveBeenCalledWith(3, 7, null);
});
it('ITIN-RPC-002 a day or place from another trip is refused, so nothing cross-links', async () => {
const f = build();
const host = f.host('db:write:itinerary');
expect(((await host.dispatch(req('itinerary.assign', { tripId: 1, dayId: 404, placeId: 7 }), 42)) as RpcError).error.message)
.toBe('no day 404 on trip 1');
expect(((await host.dispatch(req('itinerary.assign', { tripId: 1, dayId: 3, placeId: 404 }), 42)) as RpcError).error.message)
.toBe('no place 404 on trip 1');
expect(f.assignments.createAssignment).not.toHaveBeenCalled();
});
it('ITIN-RPC-003 it runs under day_edit, not an itinerary permission of its own', async () => {
const f = build({ canEdit: false });
const res = (await f.host('db:write:itinerary').dispatch(req('itinerary.assign', { tripId: 1, dayId: 3, placeId: 7 }), 42)) as RpcError;
expect(res.error.message).toBe('no permission to edit trip 1');
});
it('ITIN-RPC-004 notes are optional and pass through as null when absent', async () => {
const f = build();
await f.host('db:write:itinerary').dispatch(req('itinerary.assign', { tripId: 1, dayId: 3, placeId: 7, notes: 'lunch' }), 42);
expect(f.assignments.createAssignment).toHaveBeenCalledWith(3, 7, 'lunch');
});
it('ITIN-RPC-005 unassign carries the dayId, which the client keys its eviction on', async () => {
const f = build();
await f.host('db:write:itinerary').dispatch(req('itinerary.unassign', { tripId: 1, assignmentId: 30 }), 42);
expect(f.realtime.broadcast).toHaveBeenCalledWith(1, 'assignment:deleted', { assignmentId: 30, dayId: 3 });
});
it('ITIN-RPC-006 both writes re-mirror the journey skeletons afterwards', async () => {
const f = build();
const host = f.host('db:write:itinerary');
await host.dispatch(req('itinerary.assign', { tripId: 1, dayId: 3, placeId: 7 }), 42);
await host.dispatch(req('itinerary.unassign', { tripId: 1, assignmentId: 30 }), 42);
expect(f.assignments.reconcile).toHaveBeenCalledTimes(2);
});
it('ITIN-RPC-007 an assignment on another trip is refused and nothing is deleted', async () => {
const f = build();
const res = (await f.host('db:write:itinerary').dispatch(req('itinerary.unassign', { tripId: 1, assignmentId: 404 }), 42)) as RpcError;
expect(res.error.message).toBe('no assignment 404 on trip 1');
expect(f.assignments.deleteAssignment).not.toHaveBeenCalled();
});
it('ITIN-RPC-008 the class is listed in its module providers', () => {
expectRegisteredProvider(AssignmentsModule, ItineraryRpc);
});
});