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.
This commit is contained in:
Maurice
2026-09-23 18:42:02 +02:00
parent 436f0740fb
commit 2f6f921aef
78 changed files with 3086 additions and 170 deletions
+32 -3
View File
@@ -23,7 +23,8 @@ const { db } = vi.hoisted(() => {
tmp.exec(`CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT NOT NULL,
email TEXT NOT NULL UNIQUE, role TEXT NOT NULL DEFAULT 'user', password_version INTEGER NOT NULL DEFAULT 0,
avatar TEXT);`);
tmp.exec('CREATE TABLE trips (id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER, title TEXT, end_date TEXT);');
// start_date + updated_at: deleting a dated day can end the trip a day earlier.
tmp.exec('CREATE TABLE trips (id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER, title TEXT, start_date TEXT, end_date TEXT, feed_token TEXT, updated_at TEXT);');
tmp.exec('CREATE TABLE trip_members (trip_id INTEGER NOT NULL, user_id INTEGER NOT NULL);');
// The tables DaysService really queries (real SQL, no service mock).
tmp.exec(`CREATE TABLE days (id INTEGER PRIMARY KEY AUTOINCREMENT, trip_id INTEGER NOT NULL,
@@ -54,6 +55,9 @@ const { db } = vi.hoisted(() => {
metadata TEXT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP);`);
tmp.exec(`CREATE TABLE reservation_endpoints (id INTEGER PRIMARY KEY AUTOINCREMENT, reservation_id INTEGER NOT NULL,
local_date TEXT);`);
// A deleted day moves the road trip boundaries after it up with their days.
tmp.exec(`CREATE TABLE roadtrip_day_boundaries (trip_id INTEGER NOT NULL, day_number INTEGER NOT NULL CHECK (day_number >= 1),
from_assignment_id INTEGER NOT NULL, to_assignment_id INTEGER, fraction REAL NOT NULL, PRIMARY KEY (trip_id, day_number));`);
// StorageRegistryService (behind StorageModule, now in this module chain) reads
// this at onModuleInit.
tmp.exec('CREATE TABLE app_settings (key TEXT PRIMARY KEY, value TEXT);');
@@ -197,13 +201,38 @@ describe('Days + day-notes e2e (real auth guard + temp SQLite, real day SQL)', (
expect(bad.body).toEqual({ error: 'orderedIds must be a permutation of the trip day ids.' });
});
it('200 delete day removes the row', async () => {
it('200 delete day removes the row, closes the gap and answers with the trip', async () => {
db.prepare('INSERT INTO trips (id, user_id, title) VALUES (7, 1, ?)').run('Delete');
const id = Number(db.prepare('INSERT INTO days (trip_id, day_number) VALUES (7, 1)').run().lastInsertRowid);
const kept = Number(db.prepare('INSERT INTO days (trip_id, day_number) VALUES (7, 2)').run().lastInsertRowid);
const res = await request(server).delete(`/api/trips/7/days/${id}`).set('Cookie', sessionCookie(1));
expect(res.status).toBe(200);
expect(res.body).toEqual({ success: true });
expect(res.body).toMatchObject({ success: true, trip: { id: 7, day_count: 1, is_owner: 1 } });
expect(db.prepare('SELECT * FROM days WHERE id = ?').get(id)).toBeUndefined();
expect(db.prepare('SELECT day_number FROM days WHERE id = ?').get(kept)).toEqual({ day_number: 1 });
});
it('200 delete a dated day with no spare day ends the trip a day earlier', async () => {
db.prepare('INSERT INTO trips (id, user_id, title, start_date, end_date) VALUES (8, 1, ?, ?, ?)').run('Shrink', '2026-09-01', '2026-09-02');
const first = Number(db.prepare('INSERT INTO days (trip_id, day_number, date) VALUES (8, 1, ?)').run('2026-09-01').lastInsertRowid);
const second = Number(db.prepare('INSERT INTO days (trip_id, day_number, date) VALUES (8, 2, ?)').run('2026-09-02').lastInsertRowid);
const res = await request(server).delete(`/api/trips/8/days/${first}`).set('Cookie', sessionCookie(1));
expect(res.status).toBe(200);
expect(res.body.trip).toMatchObject({ id: 8, end_date: '2026-09-01', day_count: 1 });
expect(db.prepare('SELECT day_number, date FROM days WHERE id = ?').get(second)).toEqual({ day_number: 1, date: '2026-09-01' });
});
it('400 delete the last day of a trip, 403 without day_edit', async () => {
db.prepare('INSERT INTO trips (id, user_id, title) VALUES (9, 1, ?)').run('Last');
const only = Number(db.prepare('INSERT INTO days (trip_id, day_number) VALUES (9, 1)').run().lastInsertRowid);
const last = await request(server).delete(`/api/trips/9/days/${only}`).set('Cookie', sessionCookie(1));
expect(last.status).toBe(400);
expect(last.body).toEqual({ error: 'A trip needs at least one day.' });
expect(db.prepare('SELECT id FROM days WHERE id = ?').get(only)).toEqual({ id: only });
checkPermission.mockReturnValue(false);
const forbidden = await request(server).delete(`/api/trips/9/days/${only}`).set('Cookie', sessionCookie(1));
expect(forbidden.status).toBe(403);
expect(forbidden.body).toEqual({ error: 'No permission' });
});
it('201 create note (real insert: trim, empty-string coercions), 400 on over-long text (before access)', async () => {
+4 -1
View File
@@ -23,6 +23,7 @@ import { DayNotesMcp } from '../../src/nest/day-notes/day-notes.mcp';
import { DayNotesService } from '../../src/nest/day-notes/day-notes.service';
import { DaysMcp } from '../../src/nest/days/days.mcp';
import { DaysService } from '../../src/nest/days/days.service';
import { DayRemovalService } from '../../src/nest/days/day-removal.service';
import { MapsMcp } from '../../src/nest/maps/maps.mcp';
import { WeatherMcp } from '../../src/nest/weather/weather.mcp';
import { WeatherService } from '../../src/nest/weather/weather.service';
@@ -177,6 +178,8 @@ export function createMcpTestRegistry(): McpRegistry {
);
// Built after it: a hotel booking writes the stay's day stop through this one.
const reservationsService = new ReservationsService(dbService, permissionsService, budgetService, realtimeService, notificationsStub(), new ReservationsReadRepository(dbService), accommodationsService);
// Deleting a day cancels the stays on it through the same accommodations service.
const dayRemovalService = new DayRemovalService(dbService, daysService, accommodationsService, assignmentsService);
const membersService = new TripMembersService(dbService, budgetService, new UserCleanupService(dbService, budgetService), permissionsService, realtimeService, notificationsStub());
const tripsService = new TripsService(
dbService,
@@ -223,7 +226,7 @@ export function createMcpTestRegistry(): McpRegistry {
new BudgetMcp(budgetService, exchangeRatesService, dbService, new RuntimeEnvService(), new TripMembershipService(dbService), addonsService, guards),
new ReservationsMcp(reservationsService, daysService, budgetService, authService, assignmentsService, guards),
new DayNotesMcp(new DayNotesService(dbService, permissionsService, realtimeService), authService, guards),
new DaysMcp(daysService, authService, guards),
new DaysMcp(daysService, authService, guards, dayRemovalService),
new RoadtripMcp(new RoadtripService(dbService, realtimeService), dbService, guards, authService, addonsService),
new RoadtripPreferencesMcp(new RoadtripPreferencesService(dbService, realtimeService), authService, addonsService, dbService, guards),
new FilesMcp(new FilesService(dbService, permissionsService, realtimeService, new EphemeralTokenService(), generalStorage), authService, guards),
+2 -1
View File
@@ -21,6 +21,7 @@ import { TodoService } from '../../src/nest/todo/todo.service';
import { PackingService } from '../../src/nest/packing/packing.service';
import { DayNotesService } from '../../src/nest/day-notes/day-notes.service';
import { DaysService } from '../../src/nest/days/days.service';
import { DayRemovalService } from '../../src/nest/days/day-removal.service';
import { AssignmentsService } from '../../src/nest/assignments/assignments.service';
import { LlmConfigResolver } from '../../src/nest/llm-parse/llm-config.resolver';
import { SettingsService } from '../../src/nest/settings/settings.service';
@@ -127,7 +128,7 @@ export function createPluginRpcHostFactory(dbs: DatabaseService): PluginRpcHostF
new PackingRpc(packing, realtime, guards),
new FilesRpc(files, realtime, dbs, guards, generalStorage),
new PlacesRpc(places, journey, realtime, guards),
new DaysRpc(days, realtime, guards),
new DaysRpc(days, realtime, guards, new DayRemovalService(dbs, days, accommodations, assignments)),
new AccommodationsRpc(accommodations, realtime, guards),
new ItineraryRpc(assignments, realtime, guards),
new TripsRpc(trips, reservations, days, membership, dbs, realtime, guards, accommodations, members),
+1 -1
View File
@@ -69,7 +69,7 @@ export function allRpcControllers(): object[] {
new PackingRpc(anyService(), anyService(), anyService()),
new FilesRpc(anyService(), anyService(), anyService(), anyService(), anyService()),
new PlacesRpc(anyService(), anyService(), anyService(), anyService()),
new DaysRpc(anyService(), anyService(), anyService()),
new DaysRpc(anyService(), anyService(), anyService(), anyService()),
new ItineraryRpc(anyService(), anyService(), anyService()),
new TripsRpc(anyService(), anyService(), anyService(), anyService(), anyService(), anyService(), anyService(), anyService(), anyService()),
new CostsRpc(anyService(), anyService(), anyService(), anyService(), anyService()),
+1
View File
@@ -42,6 +42,7 @@ const RESET_TABLES = [
// one case into the next.
'roadtrip_day_tracks',
'roadtrip_vias',
'roadtrip_day_boundaries',
// Dawarich (#2279). The suggestions reference places and bucket_list, so they
// are cleared before both; the connection row hangs off the user.
'dawarich_visit_suggestions',
+70 -1
View File
@@ -1,6 +1,6 @@
/**
* Days & Accommodations API integration tests.
* Covers DAY-001 through DAY-006 and ACCOM-001 through ACCOM-003.
* Covers DAY-001 through DAY-010 and ACCOM-001 through ACCOM-006.
*/
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
import request from 'supertest';
@@ -267,6 +267,7 @@ describe('Delete day', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Trip' });
const day = createDay(testDb, trip.id);
createDay(testDb, trip.id);
const res = await request(app)
.delete(`/api/trips/${trip.id}/days/${day.id}`)
@@ -290,6 +291,74 @@ describe('Delete day', () => {
expect(res.status).toBe(404);
expect(res.body.error).toMatch(/not found/i);
});
it('DAY-007: the days after a deleted one close up, and the answer carries the trip with its new day count', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Trip' });
const [a, b, c] = [createDay(testDb, trip.id), createDay(testDb, trip.id), createDay(testDb, trip.id)];
const res = await request(app)
.delete(`/api/trips/${trip.id}/days/${b.id}`)
.set('Cookie', authCookie(user.id));
expect(res.status).toBe(200);
expect(res.body).toMatchObject({ success: true, trip: { id: trip.id, day_count: 2 } });
const list = await request(app).get(`/api/trips/${trip.id}/days`).set('Cookie', authCookie(user.id));
expect(list.body.days.map((d: { id: number; day_number: number }) => [d.id, d.day_number])).toEqual([[a.id, 1], [c.id, 2]]);
});
it('DAY-008: deleting a dated day with no day left to take the last date ends the trip a day earlier', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Trip', start_date: '2026-08-01', end_date: '2026-08-03' });
const first = testDb.prepare('SELECT id FROM days WHERE trip_id = ? ORDER BY day_number LIMIT 1').get(trip.id) as { id: number };
const res = await request(app)
.delete(`/api/trips/${trip.id}/days/${first.id}`)
.set('Cookie', authCookie(user.id));
expect(res.status).toBe(200);
expect(res.body.trip).toMatchObject({ start_date: '2026-08-01', end_date: '2026-08-02' });
const reread = await request(app).get(`/api/trips/${trip.id}`).set('Cookie', authCookie(user.id));
expect(reread.body.trip).toMatchObject({ start_date: '2026-08-01', end_date: '2026-08-02' });
const list = await request(app).get(`/api/trips/${trip.id}/days`).set('Cookie', authCookie(user.id));
expect(list.body.days.map((d: { date: string }) => d.date)).toEqual(['2026-08-01', '2026-08-02']);
});
it('DAY-009: the last day of a trip is refused with 400 and stays', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Trip' });
const day = createDay(testDb, trip.id);
const res = await request(app)
.delete(`/api/trips/${trip.id}/days/${day.id}`)
.set('Cookie', authCookie(user.id));
expect(res.status).toBe(400);
expect(res.body).toEqual({ error: 'A trip needs at least one day.' });
expect(testDb.prepare('SELECT id FROM days WHERE id = ?').get(day.id)).toBeDefined();
});
it('DAY-010: a member without day_edit gets 403 and the day stays', async () => {
const { user: owner } = createUser(testDb);
const { user: member } = createUser(testDb);
const trip = createTrip(testDb, owner.id, { title: 'Trip' });
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();
const { invalidatePermissionsCache } = await import('../../src/nest/permissions/permissions-cache');
invalidatePermissionsCache();
try {
const res = await request(app)
.delete(`/api/trips/${trip.id}/days/${day.id}`)
.set('Cookie', authCookie(member.id));
expect(res.status).toBe(403);
expect(testDb.prepare('SELECT id FROM days WHERE id = ?').get(day.id)).toBeDefined();
} finally {
testDb.prepare("DELETE FROM app_settings WHERE key = 'perm_day_edit'").run();
invalidatePermissionsCache();
}
});
});
// ─────────────────────────────────────────────────────────────────────────────
@@ -15,6 +15,7 @@ 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';
@@ -31,8 +32,20 @@ function build(opts: { canEdit?: boolean } = {}) {
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 })),
remove: vi.fn(),
} 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),
@@ -50,9 +63,9 @@ function build(opts: { canEdit?: boolean } = {}) {
{ 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), new ItineraryRpc(assignments, realtime, guards)]);
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 };
return { days, assignments, realtime, host, removal, removed };
}
describe('DaysRpc', () => {
@@ -72,7 +85,7 @@ describe('DaysRpc', () => {
.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.days.remove).not.toHaveBeenCalled();
expect(f.removal.remove).not.toHaveBeenCalled();
});
it('DAYS-RPC-003 every write broadcasts its event', async () => {
@@ -81,12 +94,42 @@ describe('DaysRpc', () => {
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']);
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', () => {
@@ -51,7 +51,9 @@ 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, createDayAccommodation } from '../../helpers/factories';
import { createUser, createTrip, createDay, createPlace, createDayAccommodation, addTripMember } from '../../helpers/factories';
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';
beforeAll(() => {
@@ -155,19 +157,94 @@ describe('Tool: create_day', () => {
// ---------------------------------------------------------------------------
describe('Tool: delete_day', () => {
it('deletes a day and broadcasts', async () => {
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 }[];
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);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'delete_day',
arguments: { tripId: trip.id, dayId: d2.id },
});
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([
{ 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' });
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 }));
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'day:reordered', expect.objectContaining({ orderedIds: [d1.id, d3.id] }));
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'trip:updated', expect.objectContaining({ trip: expect.objectContaining({ id: trip.id, end_date: '2026-01-02' }) }));
});
});
it('cancels a stay checking in on the day with its booking, and says so', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const d1 = createDay(testDb, trip.id);
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);
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(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]);
});
});
it('refuses the last day of a trip with the REST sentence and leaves it in place', 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: 'delete_day',
arguments: { tripId: trip.id, dayId: day.id },
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(broadcastMock).not.toHaveBeenCalled();
});
});
it('lets an unexpected failure surface as one, not as the last-day refusal', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const day = createDay(testDb, trip.id);
createDay(testDb, trip.id);
const boom = vi.spyOn(DayRemovalService.prototype, 'remove').mockImplementation(() => { throw new Error('disk full'); });
try {
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(JSON.stringify(result.content)).not.toContain('A trip needs at least one day.');
expect(broadcastMock).not.toHaveBeenCalled();
});
const data = parseToolResult(result) as any;
expect(data.success).toBe(true);
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'day:deleted', expect.objectContaining({ dayId: day.id }));
expect(testDb.prepare('SELECT id FROM days WHERE id = ?').get(day.id)).toBeUndefined();
} finally {
boom.mockRestore();
}
});
it('reports a day that is not on the trip', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
createDay(testDb, trip.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'delete_day', arguments: { tripId: trip.id, dayId: 999999 } });
expect(result.isError).toBe(true);
expect(JSON.stringify(result.content)).toContain('Day not found.');
});
});
@@ -181,6 +258,40 @@ describe('Tool: delete_day', () => {
expect(result.isError).toBe(true);
});
});
it('refuses a member without day_edit, the same right the REST route asks for', async () => {
const { user: owner } = createUser(testDb);
const { user: member } = createUser(testDb);
const trip = createTrip(testDb, owner.id);
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();
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();
});
} finally {
testDb.prepare("DELETE FROM app_settings WHERE key = 'perm_day_edit'").run();
invalidatePermissionsCache();
}
});
it('blocks demo user', async () => {
process.env.DEMO_MODE = 'true';
const { user } = createUser(testDb, { email: 'demo@nomad.app' });
const trip = createTrip(testDb, user.id);
const day = createDay(testDb, trip.id);
createDay(testDb, trip.id);
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();
});
});
});
// ---------------------------------------------------------------------------
@@ -0,0 +1,427 @@
/**
* DayRemovalService: deleting one day, the way the reorder dialog, the MCP tool
* and the plugin RPC all do it. DAY-DEL-001 to DAY-DEL-018.
*
* Real in-memory SQLite with the real accommodations and assignments services,
* so the foreign key cascades and the stay cancellation are the ones production runs.
*/
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll, afterEach } 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: number | string, userId: number) =>
db.prepare(`SELECT t.id, t.user_id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)`).get(userId, tripId, userId),
isOwner: (tripId: number | string, userId: number) =>
!!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId),
};
return { testDb: db, dbMock: mock };
});
vi.mock('../../../src/db/database', () => dbMock);
vi.mock('../../../src/config', () => ({
JWT_SECRET: 'test-secret',
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
updateJwtSecret: () => {},
}));
vi.mock('../../../src/websocket', () => ({ broadcast: vi.fn() }));
import { createTables } from '../../../src/db/schema';
import { runMigrations } from '../../../src/db/migrations';
import { resetTestDb } from '../../helpers/test-db';
import { createUser, createTrip, createDay, createPlace, createDayAssignment, createDayAccommodation } from '../../helpers/factories';
import { DatabaseService } from '../../../src/nest/database/database.service';
import { PermissionsService } from '../../../src/nest/permissions/permissions.service';
import { RealtimeService } from '../../../src/nest/realtime/realtime.service';
import { QueryHelpersService } from '../../../src/nest/query-helpers/query-helpers.service';
import { DaysService } from '../../../src/nest/days/days.service';
import { DayRemovalService, DayDeleteError, LAST_DAY_MESSAGE, type DayRemoval } from '../../../src/nest/days/day-removal.service';
import { JourneyDomainService } from '../../../src/nest/journey/journey-domain.service';
import { TrekPhotosRepository } from '../../../src/nest/photos/trek-photos.repository';
import { AssignmentsService } from '../../../src/nest/assignments/assignments.service';
import { AccommodationsService, type MirrorSender } from '../../../src/nest/accommodations/accommodations.service';
const dbs = new DatabaseService(testDb);
const permissions = new PermissionsService(dbs);
const realtime = new RealtimeService();
const queryHelpers = new QueryHelpersService(dbs);
const days = new DaysService(dbs, permissions, realtime, queryHelpers);
const journey = new JourneyDomainService(dbs, realtime, new TrekPhotosRepository(dbs));
const assignments = new AssignmentsService(dbs, permissions, realtime, queryHelpers, journey);
const accommodations = new AccommodationsService(dbs, permissions, realtime, assignments);
const removal = new DayRemovalService(dbs, days, accommodations, assignments);
beforeAll(() => {
createTables(testDb);
runMigrations(testDb);
});
beforeEach(() => {
resetTestDb(testDb);
});
afterEach(() => {
vi.restoreAllMocks();
});
afterAll(() => {
testDb.close();
});
type Row = { id: number; day_number: number; date: string | null };
const dayRows = (tripId: number): Row[] =>
testDb.prepare('SELECT id, day_number, date FROM days WHERE trip_id = ? ORDER BY day_number').all(tripId) as Row[];
const range = (tripId: number) =>
testDb.prepare('SELECT start_date, end_date FROM trips WHERE id = ?').get(tripId) as { start_date: string | null; end_date: string | null };
const reservationRow = (id: number) =>
testDb.prepare('SELECT day_id, end_day_id, reservation_time, reservation_end_time FROM reservations WHERE id = ?').get(id) as
{ day_id: number | null; end_day_id: number | null; reservation_time: string | null; reservation_end_time: string | null } | undefined;
function booking(tripId: number, dayId: number | null, time: string | null, extra: { endDayId?: number; endTime?: string } = {}): number {
return Number(testDb.prepare(
'INSERT INTO reservations (trip_id, day_id, end_day_id, title, type, reservation_time, reservation_end_time) VALUES (?, ?, ?, ?, ?, ?, ?)',
).run(tripId, dayId, extra.endDayId ?? null, 'Booking', 'restaurant', time, extra.endTime ?? null).lastInsertRowid);
}
/** A dated trip whose days the factory generated, one per date. */
function datedTrip(start: string, end: string) {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { start_date: start, end_date: end });
return { user, trip, rows: dayRows(trip.id) };
}
/** A booked night: the stay, its hotel booking, the stop on its check-in day and an expense. */
function bookedNight(tripId: number, startDayId: number, endDayId: number) {
const place = createPlace(testDb, tripId, { name: 'Harbour Hotel' });
const { accommodation } = accommodations.createAccommodation(tripId, { place_id: place.id, start_day_id: startDayId, end_day_id: endDayId });
const stayId = (accommodation as { id: number }).id;
const reservationId = (testDb.prepare('SELECT id FROM reservations WHERE accommodation_id = ?').get(stayId) as { id: number }).id;
const budgetId = Number(testDb.prepare(
'INSERT INTO budget_items (trip_id, name, category, total_price, reservation_id) VALUES (?, ?, ?, ?, ?)',
).run(tripId, 'Harbour Hotel', 'Accommodation', 240, reservationId).lastInsertRowid);
const stop = testDb.prepare('SELECT id, day_id FROM day_assignments WHERE accommodation_id = ?').get(stayId) as { id: number; day_id: number };
return { place, stayId, reservationId, budgetId, stop };
}
function boundary(tripId: number, dayNumber: number, fromAssignmentId: number) {
testDb.prepare(
'INSERT INTO roadtrip_day_boundaries (trip_id, day_number, from_assignment_id, to_assignment_id, fraction) VALUES (?, ?, ?, NULL, 1)',
).run(tripId, dayNumber, fromAssignmentId);
}
describe('DayRemovalService.remove', () => {
it('DAY-DEL-001 an undated day only closes the gap in the numbering', () => {
const { user } = createUser(testDb);
const dateless = createTrip(testDb, user.id);
const [a, b, c] = [createDay(testDb, dateless.id), createDay(testDb, dateless.id), createDay(testDb, dateless.id)];
const result = removal.remove(dateless.id, b.id, { userId: user.id });
expect(dayRows(dateless.id)).toEqual([
{ id: a.id, day_number: 1, date: null },
{ id: c.id, day_number: 2, date: null },
]);
expect(result).toMatchObject({ dayId: b.id, orderedIds: [a.id, c.id], endDate: null, boundaries: null, stayIds: [] });
// The extra day of a dated trip, the case the dialog was asked for: no date moves.
const dated = datedTrip('2026-03-01', '2026-03-02');
const spare = createDay(testDb, dated.trip.id);
const dinner = booking(dated.trip.id, dated.rows[1].id, '2026-03-02T19:00');
removal.remove(dated.trip.id, spare.id, { userId: dated.user.id });
expect(dayRows(dated.trip.id)).toEqual(dated.rows);
expect(range(dated.trip.id)).toEqual({ start_date: '2026-03-01', end_date: '2026-03-02' });
expect(reservationRow(dinner)?.reservation_time).toBe('2026-03-02T19:00');
});
it('DAY-DEL-002 a dated day with a spare day after it: the dates stay on their slots and the bookings move along', () => {
const { user, trip, rows } = datedTrip('2026-01-01', '2026-01-03');
const [d1, d2, d3] = rows;
const spare = createDay(testDb, trip.id);
const flight = booking(trip.id, d3.id, '2026-01-03T10:00', { endDayId: d3.id, endTime: '2026-01-03T12:00' });
const leg = Number(testDb.prepare(
'INSERT INTO reservation_endpoints (reservation_id, role, sequence, name, lat, lng, local_date) VALUES (?, ?, ?, ?, ?, ?, ?)',
).run(flight, 'from', 0, 'HEL', 60.31, 24.96, '2026-01-03').lastInsertRowid);
const result = removal.remove(trip.id, d2.id, { userId: user.id });
expect(dayRows(trip.id)).toEqual([
{ id: d1.id, day_number: 1, date: '2026-01-01' },
{ id: d3.id, day_number: 2, date: '2026-01-02' },
{ id: spare.id, day_number: 3, date: '2026-01-03' },
]);
expect(reservationRow(flight)).toMatchObject({ reservation_time: '2026-01-02T10:00', reservation_end_time: '2026-01-02T12:00' });
expect(testDb.prepare('SELECT local_date FROM reservation_endpoints WHERE id = ?').get(leg)).toEqual({ local_date: '2026-01-02' });
// The spare day took the last date, so the trip keeps its range.
expect(range(trip.id)).toEqual({ start_date: '2026-01-01', end_date: '2026-01-03' });
expect(result.endDate).toBeNull();
});
it('DAY-DEL-003 a dated day with no spare day left: the last date goes and the trip ends a day earlier', () => {
const { user, trip, rows } = datedTrip('2026-01-01', '2026-01-03');
const [d1, d2, d3] = rows;
const result = removal.remove(trip.id, d1.id, { userId: user.id });
expect(dayRows(trip.id)).toEqual([
{ id: d2.id, day_number: 1, date: '2026-01-01' },
{ id: d3.id, day_number: 2, date: '2026-01-02' },
]);
expect(range(trip.id)).toEqual({ start_date: '2026-01-01', end_date: '2026-01-02' });
expect(result.endDate).toBe('2026-01-02');
// The trip in list shape for the trip header, without its feed credential.
expect(result.trip).toMatchObject({ id: trip.id, end_date: '2026-01-02', day_count: 2, is_owner: 1, feed_token: null });
});
it('DAY-DEL-004 the last day of a trip stays, and nothing is written', () => {
const { user, trip, rows } = datedTrip('2026-05-01', '2026-05-01');
const night = bookedNight(trip.id, rows[0].id, rows[0].id);
expect(() => removal.remove(trip.id, rows[0].id, { userId: user.id })).toThrow(new DayDeleteError(LAST_DAY_MESSAGE));
expect(dayRows(trip.id)).toEqual(rows);
expect(testDb.prepare('SELECT id FROM day_accommodations WHERE id = ?').get(night.stayId)).toBeDefined();
expect(reservationRow(night.reservationId)).toBeDefined();
});
it('DAY-DEL-005 a stay checking in on the day is cancelled with its booking and that booking\'s expense', () => {
const { user, trip, rows } = datedTrip('2026-01-01', '2026-01-03');
const night = bookedNight(trip.id, rows[1].id, rows[2].id);
expect(night.stop.day_id).toBe(rows[1].id);
const result = removal.remove(trip.id, rows[1].id, { userId: user.id });
expect(testDb.prepare('SELECT id FROM day_accommodations WHERE id = ?').get(night.stayId)).toBeUndefined();
expect(reservationRow(night.reservationId)).toBeUndefined();
expect(testDb.prepare('SELECT id FROM budget_items WHERE id = ?').get(night.budgetId)).toBeUndefined();
// The hotel itself is a place and stays on the list.
expect(testDb.prepare('SELECT id FROM places WHERE id = ?').get(night.place.id)).toBeDefined();
expect(result).toMatchObject({ stayIds: [night.stayId], reservationIds: [night.reservationId], budgetItemIds: [night.budgetId] });
// Its stop sat on the deleted day, which day:deleted already covers.
expect(result.mirrors[0].removed).toEqual([]);
});
it('DAY-DEL-006 a stay checking out on the day is cancelled too, and its stop on the check-in day goes with it', () => {
const { user, trip, rows } = datedTrip('2026-01-01', '2026-01-03');
const night = bookedNight(trip.id, rows[0].id, rows[1].id);
const result = removal.remove(trip.id, rows[1].id, { userId: user.id });
expect(testDb.prepare('SELECT id FROM day_accommodations WHERE id = ?').get(night.stayId)).toBeUndefined();
expect(reservationRow(night.reservationId)).toBeUndefined();
expect(testDb.prepare('SELECT id FROM day_assignments WHERE id = ?').get(night.stop.id)).toBeUndefined();
expect(result.mirrors[0].removed).toEqual([{ id: night.stop.id, dayId: rows[0].id }]);
});
it('DAY-DEL-007 a stay that only runs across the day keeps standing', () => {
const { user, trip, rows } = datedTrip('2026-01-01', '2026-01-03');
const night = bookedNight(trip.id, rows[0].id, rows[2].id);
const result = removal.remove(trip.id, rows[1].id, { userId: user.id });
expect(testDb.prepare('SELECT start_day_id, end_day_id FROM day_accommodations WHERE id = ?').get(night.stayId))
.toEqual({ start_day_id: rows[0].id, end_day_id: rows[2].id });
expect(reservationRow(night.reservationId)).toBeDefined();
expect(result.stayIds).toEqual([]);
});
it('DAY-DEL-008 bookings on the day are let go of, with their dates untouched', () => {
const { user, trip, rows } = datedTrip('2026-01-01', '2026-01-03');
const [d1, d2] = rows;
const dinner = booking(trip.id, d2.id, '2026-01-02T19:00');
const car = booking(trip.id, d1.id, '2026-01-01T09:00', { endDayId: d2.id, endTime: '2026-01-02T10:00' });
removal.remove(trip.id, d2.id, { userId: user.id });
expect(reservationRow(dinner)).toEqual({ day_id: null, end_day_id: null, reservation_time: '2026-01-02T19:00', reservation_end_time: null });
expect(reservationRow(car)).toEqual({ day_id: d1.id, end_day_id: null, reservation_time: '2026-01-01T09:00', reservation_end_time: '2026-01-02T10:00' });
});
it('DAY-DEL-009 the road trip boundaries after the day move up with their days', () => {
const { user, trip, rows } = datedTrip('2026-01-01', '2026-01-04');
const place = createPlace(testDb, trip.id);
const stops = rows.map(r => createDayAssignment(testDb, r.id, place.id));
rows.forEach((_, i) => boundary(trip.id, i + 1, stops[i].id));
const result = removal.remove(trip.id, rows[1].id, { userId: user.id });
const expected = [
{ day_number: 1, from_assignment_id: stops[0].id, to_assignment_id: null, fraction: 1 },
{ day_number: 2, from_assignment_id: stops[2].id, to_assignment_id: null, fraction: 1 },
{ day_number: 3, from_assignment_id: stops[3].id, to_assignment_id: null, fraction: 1 },
];
expect(testDb.prepare('SELECT day_number, from_assignment_id, to_assignment_id, fraction FROM roadtrip_day_boundaries WHERE trip_id = ? ORDER BY day_number').all(trip.id))
.toEqual(expected);
expect(result.boundaries).toEqual(expected);
});
it('DAY-DEL-010 deleting the day an insert slotted in gives back the trip as it was', () => {
const { user, trip, rows } = datedTrip('2026-01-01', '2026-01-03');
const train = booking(trip.id, rows[2].id, '2026-01-03T08:00');
const before = { rows: dayRows(trip.id), range: range(trip.id), train: reservationRow(train) };
const inserted = days.insert(trip.id, 2);
expect(range(trip.id).end_date).toBe('2026-01-04');
removal.remove(trip.id, inserted.id, { userId: user.id });
expect({ rows: dayRows(trip.id), range: range(trip.id), train: reservationRow(train) }).toEqual(before);
});
it('DAY-DEL-011 appending a day, moving it into a slot and deleting the day it pushed out keeps every date', () => {
const { user, trip, rows } = datedTrip('2026-01-01', '2026-01-03');
const [d1, d2, d3] = rows;
const lunch = booking(trip.id, d2.id, '2026-01-02T12:00');
const tour = booking(trip.id, d3.id, '2026-01-03T09:00');
const appended = days.create(trip.id);
days.reorder(trip.id, [d1.id, d2.id, appended.id, d3.id]);
// The pushed-out day lost its date to the new one and sits at the end without one.
expect(dayRows(trip.id).at(-1)).toEqual({ id: d3.id, day_number: 4, date: null });
removal.remove(trip.id, d3.id, { userId: user.id });
expect(dayRows(trip.id)).toEqual([
{ id: d1.id, day_number: 1, date: '2026-01-01' },
{ id: d2.id, day_number: 2, date: '2026-01-02' },
{ id: appended.id, day_number: 3, date: '2026-01-03' },
]);
expect(range(trip.id)).toEqual({ start_date: '2026-01-01', end_date: '2026-01-03' });
expect(reservationRow(lunch)?.reservation_time).toBe('2026-01-02T12:00');
expect(reservationRow(tour)?.reservation_time).toBe('2026-01-03T09:00');
});
it('DAY-DEL-012 a failure halfway rolls the whole delete back', () => {
const { user, trip, rows } = datedTrip('2026-01-01', '2026-01-03');
const night = bookedNight(trip.id, rows[1].id, rows[2].id);
vi.spyOn(days, 'restampReservationDates').mockImplementation(() => { throw new Error('disk full'); });
expect(() => removal.remove(trip.id, rows[1].id, { userId: user.id })).toThrow('disk full');
expect(dayRows(trip.id)).toEqual(rows);
expect(testDb.prepare('SELECT id FROM day_accommodations WHERE id = ?').get(night.stayId)).toBeDefined();
expect(reservationRow(night.reservationId)).toBeDefined();
expect(testDb.prepare('SELECT id FROM budget_items WHERE id = ?').get(night.budgetId)).toBeDefined();
});
it('DAY-DEL-013 the journey catches up after the commit, and a failure there does not undo the delete', () => {
const { user, trip, rows } = datedTrip('2026-01-01', '2026-01-02');
const reconcile = vi.spyOn(journey, 'reconcileTripSkeletons').mockImplementation(() => { throw new Error('journey down'); });
const result = removal.remove(trip.id, rows[0].id, { userId: user.id, socketId: 'sock-1' });
expect(reconcile).toHaveBeenCalledWith(trip.id, 'sock-1');
expect(result.orderedIds).toEqual([rows[1].id]);
expect(dayRows(trip.id)).toHaveLength(1);
});
it('DAY-DEL-015 a day of another trip is refused, and nothing is written', () => {
const { user, trip, rows } = datedTrip('2026-01-01', '2026-01-02');
const other = datedTrip('2026-02-01', '2026-02-02');
expect(() => removal.remove(trip.id, other.rows[0].id, { userId: user.id })).toThrow(new DayDeleteError('Day not found'));
expect(dayRows(trip.id)).toEqual(rows);
expect(dayRows(other.trip.id)).toEqual(other.rows);
});
it('DAY-DEL-016 dated days on a trip without a range move their dates but leave the trip alone', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const a = createDay(testDb, trip.id, { date: '2026-04-01' });
const b = createDay(testDb, trip.id, { date: '2026-04-02' });
const result = removal.remove(trip.id, a.id, { userId: user.id });
expect(dayRows(trip.id)).toEqual([{ id: b.id, day_number: 1, date: '2026-04-01' }]);
expect(range(trip.id)).toEqual({ start_date: null, end_date: null });
expect(result.endDate).toBeNull();
});
it('DAY-DEL-018 a cancelled stay does not announce the deleted day: neither its stop there nor its roads', () => {
const { user, trip, rows } = datedTrip('2026-01-01', '2026-01-03');
const place = createPlace(testDb, trip.id);
createDayAccommodation(testDb, trip.id, place.id, rows[1].id, rows[1].id);
const stub = {
deleteAccommodation: vi.fn(() => ({
linkedReservationId: null, deletedBudgetItemId: null, linkedReservationIds: [], deletedBudgetItemIds: [],
mirror: {
created: null, moved: null, updated: [], stamped: null,
removed: [{ id: 1, dayId: rows[1].id }, { id: 2, dayId: rows[0].id }],
vias: [{ dayId: rows[1].id, vias: [] }, { dayId: rows[0].id, vias: [] }],
},
})),
} as unknown as AccommodationsService;
const result = new DayRemovalService(dbs, days, stub, assignments).remove(trip.id, rows[1].id, { userId: user.id });
expect(result.mirrors).toEqual([{
created: null, moved: null, updated: [], stamped: null,
removed: [{ id: 2, dayId: rows[0].id }],
vias: [{ dayId: rows[0].id, vias: [] }],
}]);
});
});
describe('DayRemovalService.announce', () => {
function recorder() {
const sent: [string, string, unknown][] = [];
const all: MirrorSender = (event, payload) => { sent.push(['all', event, payload]); };
const others: MirrorSender = (event, payload) => { sent.push(['others', event, payload]); };
return { sent, all, others };
}
it('DAY-DEL-014 fans out in one order: the day, the new order, the stays, their rows, the boundaries, the trip', () => {
const { user, trip, rows } = datedTrip('2026-01-01', '2026-01-03');
const night = bookedNight(trip.id, rows[0].id, rows[1].id);
const place = createPlace(testDb, trip.id);
boundary(trip.id, 3, createDayAssignment(testDb, rows[2].id, place.id).id);
const result = removal.remove(trip.id, rows[1].id, { userId: user.id, socketId: 'sock' });
const mirror = vi.spyOn(accommodations, 'announceMirror');
const { sent, all, others } = recorder();
removal.announce(trip.id, result, { all, others, socketId: 'sock' });
expect(sent.map(([to, event]) => `${to} ${event}`)).toEqual([
'others day:deleted',
'others day:reordered',
'all assignment:deleted',
'all assignment:reordered',
'all reservation:deleted',
'all budget:deleted',
'others accommodation:deleted',
'all roadtripBoundary:changed',
'others trip:updated',
]);
expect(sent[0][2]).toEqual({ dayId: rows[1].id });
expect(sent[1][2]).toEqual({ orderedIds: [rows[0].id, rows[2].id] });
expect(sent[2][2]).toEqual({ assignmentId: night.stop.id, dayId: rows[0].id });
expect(sent[4][2]).toEqual({ reservationId: night.reservationId });
expect(sent[5][2]).toEqual({ itemId: night.budgetId });
expect(sent[6][2]).toEqual({ accommodationId: night.stayId });
expect(sent[7][2]).toEqual({ boundaries: [expect.objectContaining({ day_number: 2 })] });
expect(sent[8][2]).toEqual({ trip: expect.objectContaining({ id: trip.id, end_date: '2026-01-02' }) });
expect(mirror).toHaveBeenCalledWith(trip.id, result.mirrors[0], all, 'sock');
});
it('DAY-DEL-017 a plain delete announces the day and the new order, nothing else', () => {
const removed: DayRemoval = {
dayId: 4, orderedIds: [3, 5], stayIds: [], reservationIds: [], budgetItemIds: [], mirrors: [],
boundaries: null, endDate: null, trip: null,
};
const { sent, all, others } = recorder();
removal.announce(9, removed, { all, others });
expect(sent).toEqual([
['others', 'day:deleted', { dayId: 4 }],
['others', 'day:reordered', { orderedIds: [3, 5] }],
]);
});
});
+60 -14
View File
@@ -4,6 +4,7 @@ import { DaysController } from '../../../src/nest/days/days.controller';
import { DayNotesController } from '../../../src/nest/day-notes/day-notes.controller';
import { DayNoteCreateDto, DayNoteUpdateDto } from '../../../src/nest/day-notes/day-notes.dto';
import { DayReorderError } from '../../../src/nest/days/days.service';
import { DayDeleteError, type DayRemoval, type DayRemovalService } from '../../../src/nest/days/day-removal.service';
import type { DayReorderDto } from '../../../src/nest/days/days.dto';
import type { DaysService } from '../../../src/nest/days/days.service';
import type { DayNotesService } from '../../../src/nest/day-notes/day-notes.service';
@@ -24,6 +25,9 @@ function thrown(fn: () => unknown): { status: number; body: unknown } {
function daysSvc(o: Partial<DaysService> = {}): DaysService {
return { verifyTripAccess: vi.fn().mockReturnValue(trip), canEdit: vi.fn().mockReturnValue(true), broadcast: vi.fn(), ...o } as unknown as DaysService;
}
function removalSvc(o: Partial<DayRemovalService> = {}): DayRemovalService {
return { remove: vi.fn(), announce: vi.fn(), ...o } as unknown as DayRemovalService;
}
function notesSvc(o: Partial<DayNotesService> = {}): DayNotesService {
return { verifyTripAccess: vi.fn().mockReturnValue(trip), canEdit: vi.fn().mockReturnValue(true), broadcast: vi.fn(), ...o } as unknown as DayNotesService;
}
@@ -44,12 +48,12 @@ describe('DaysController (parity with the legacy /api/trips/:tripId/days route)'
it('GET / returns the list service result verbatim (the { days } envelope)', () => {
const svc = daysSvc({ list: vi.fn().mockReturnValue({ days: [{ id: 1 }] }) } as Partial<DaysService>);
expect(new DaysController(svc).list(user, '5')).toEqual({ days: [{ id: 1 }] });
expect(new DaysController(svc, removalSvc()).list(user, '5')).toEqual({ days: [{ id: 1 }] });
});
it('POST / creates + broadcasts', () => {
const create = vi.fn().mockReturnValue({ id: 9 }); const broadcast = vi.fn();
expect(new DaysController(daysSvc({ create, broadcast } as Partial<DaysService>)).create(user, '5', { date: '2026-07-01' }, 'sock')).toEqual({ day: { id: 9 } });
expect(new DaysController(daysSvc({ create, broadcast } as Partial<DaysService>), removalSvc()).create(user, '5', { date: '2026-07-01' }, 'sock')).toEqual({ day: { id: 9 } });
expect(create).toHaveBeenCalledWith('5', '2026-07-01', undefined);
expect(broadcast).toHaveBeenCalledWith('5', 'day:created', { day: { id: 9 } }, 'sock');
});
@@ -58,7 +62,7 @@ describe('DaysController (parity with the legacy /api/trips/:tripId/days route)'
it('POST / with a position inserts + broadcasts day:reordered', () => {
const insert = vi.fn().mockReturnValue({ id: 12 }); const create = vi.fn(); const broadcast = vi.fn();
const svc = daysSvc({ insert, create, broadcast } as Partial<DaysService>);
expect(new DaysController(svc).create(user, '5', { position: 0 }, 'sock')).toEqual({ day: { id: 12 } });
expect(new DaysController(svc, removalSvc()).create(user, '5', { position: 0 }, 'sock')).toEqual({ day: { id: 12 } });
expect(insert).toHaveBeenCalledWith('5', 0);
expect(create).not.toHaveBeenCalled();
expect(broadcast).toHaveBeenCalledWith('5', 'day:reordered', { day: { id: 12 } }, 'sock');
@@ -68,17 +72,17 @@ describe('DaysController (parity with the legacy /api/trips/:tripId/days route)'
it('400 when orderedIds is missing', () => {
expect(thrown(() => new DaysController(daysSvc()).reorder(user, '5', rawReorderBody({})))).toEqual({ status: 400, body: { error: 'orderedIds must be an array' } });
expect(thrown(() => new DaysController(daysSvc(), removalSvc()).reorder(user, '5', rawReorderBody({})))).toEqual({ status: 400, body: { error: 'orderedIds must be an array' } });
});
it('400 when orderedIds is not an array', () => {
expect(thrown(() => new DaysController(daysSvc()).reorder(user, '5', rawReorderBody({ orderedIds: 'nope' })))).toEqual({ status: 400, body: { error: 'orderedIds must be an array' } });
expect(thrown(() => new DaysController(daysSvc(), removalSvc()).reorder(user, '5', rawReorderBody({ orderedIds: 'nope' })))).toEqual({ status: 400, body: { error: 'orderedIds must be an array' } });
});
it('maps a DayReorderError to 400 with its message', () => {
const reorder = vi.fn(() => { throw new DayReorderError('orderedIds must be a permutation of the trip day ids.'); });
const svc = daysSvc({ reorder } as Partial<DaysService>);
expect(thrown(() => new DaysController(svc).reorder(user, '5', { orderedIds: [9] }))).toEqual({
expect(thrown(() => new DaysController(svc, removalSvc()).reorder(user, '5', { orderedIds: [9] }))).toEqual({
status: 400, body: { error: 'orderedIds must be a permutation of the trip day ids.' },
});
});
@@ -87,29 +91,71 @@ describe('DaysController (parity with the legacy /api/trips/:tripId/days route)'
const boom = new Error('db is down');
const reorder = vi.fn(() => { throw boom; });
const svc = daysSvc({ reorder } as Partial<DaysService>);
expect(() => new DaysController(svc).reorder(user, '5', { orderedIds: [1, 2] })).toThrow(boom);
expect(() => new DaysController(svc, removalSvc()).reorder(user, '5', { orderedIds: [1, 2] })).toThrow(boom);
});
it('reorders and broadcasts day:reordered', () => {
const reorder = vi.fn(); const broadcast = vi.fn();
const svc = daysSvc({ reorder, broadcast } as Partial<DaysService>);
expect(new DaysController(svc).reorder(user, '5', { orderedIds: [2, 1] }, 'sock')).toEqual({ success: true });
expect(new DaysController(svc, removalSvc()).reorder(user, '5', { orderedIds: [2, 1] }, 'sock')).toEqual({ success: true });
expect(reorder).toHaveBeenCalledWith('5', [2, 1]);
expect(broadcast).toHaveBeenCalledWith('5', 'day:reordered', { orderedIds: [2, 1] }, 'sock');
});
});
it('PUT /:id 404 when the day is missing, else updates', () => {
expect(thrown(() => new DaysController(daysSvc({ getDay: vi.fn().mockReturnValue(undefined) } as Partial<DaysService>)).update(user, '5', '9', {}))).toEqual({ status: 404, body: { error: 'Day not found' } });
expect(thrown(() => new DaysController(daysSvc({ getDay: vi.fn().mockReturnValue(undefined) } as Partial<DaysService>), removalSvc()).update(user, '5', '9', {}))).toEqual({ status: 404, body: { error: 'Day not found' } });
const update = vi.fn().mockReturnValue({ id: 9, title: 'T' });
const svc = daysSvc({ getDay: vi.fn().mockReturnValue({ id: 9 }), update } as Partial<DaysService>);
expect(new DaysController(svc).update(user, '5', '9', { title: 'T' })).toEqual({ day: { id: 9, title: 'T' } });
expect(new DaysController(svc, removalSvc()).update(user, '5', '9', { title: 'T' })).toEqual({ day: { id: 9, title: 'T' } });
});
it('DELETE /:id 404 when missing, else success', () => {
expect(thrown(() => new DaysController(daysSvc({ getDay: vi.fn().mockReturnValue(undefined) } as Partial<DaysService>)).remove(user, '5', '9'))).toEqual({ status: 404, body: { error: 'Day not found' } });
const svc = daysSvc({ getDay: vi.fn().mockReturnValue({ id: 9 }), remove: vi.fn() } as Partial<DaysService>);
expect(new DaysController(svc).remove(user, '5', '9')).toEqual({ success: true });
describe('DELETE /:id', () => {
const removed: DayRemoval = {
dayId: 9, orderedIds: [8, 10], stayIds: [], reservationIds: [], budgetItemIds: [], mirrors: [],
boundaries: null, endDate: '2026-07-02', trip: { id: 5, end_date: '2026-07-02' },
};
it('404 when the day is not on the trip, before anything is removed', () => {
const removal = removalSvc();
expect(thrown(() => new DaysController(daysSvc({ getDay: vi.fn().mockReturnValue(undefined) } as Partial<DaysService>), removal).remove(user, '5', '9')))
.toEqual({ status: 404, body: { error: 'Day not found' } });
expect(removal.remove).not.toHaveBeenCalled();
});
it('400 with the service sentence for the last day, and nothing is announced', () => {
const removal = removalSvc({ remove: vi.fn(() => { throw new DayDeleteError('A trip needs at least one day.'); }) });
const svc = daysSvc({ getDay: vi.fn().mockReturnValue({ id: 9 }) } as Partial<DaysService>);
expect(thrown(() => new DaysController(svc, removal).remove(user, '5', '9', 'sock')))
.toEqual({ status: 400, body: { error: 'A trip needs at least one day.' } });
expect(removal.announce).not.toHaveBeenCalled();
});
it('rethrows anything else unchanged', () => {
const boom = new Error('db is down');
const removal = removalSvc({ remove: vi.fn(() => { throw boom; }) });
const svc = daysSvc({ getDay: vi.fn().mockReturnValue({ id: 9 }) } as Partial<DaysService>);
expect(() => new DaysController(svc, removal).remove(user, '5', '9')).toThrow(boom);
});
it('removes as the caller, announces with and without the socket, and answers with the trip', () => {
const broadcast = vi.fn();
const removal = removalSvc({ remove: vi.fn().mockReturnValue(removed) });
const svc = daysSvc({ getDay: vi.fn().mockReturnValue({ id: 9 }), broadcast } as Partial<DaysService>);
expect(new DaysController(svc, removal).remove(user, '5', '9', 'sock'))
.toEqual({ success: true, trip: { id: 5, end_date: '2026-07-02' } });
expect(removal.remove).toHaveBeenCalledWith('5', '9', { userId: 1, socketId: 'sock' });
const [tripId, sentRemoval, senders] = vi.mocked(removal.announce).mock.calls[0];
expect([tripId, sentRemoval, senders.socketId]).toEqual(['5', removed, 'sock']);
senders.all('budget:deleted', { itemId: 3 });
senders.others('day:deleted', { dayId: 9 });
expect(broadcast.mock.calls).toEqual([
['5', 'budget:deleted', { itemId: 3 }, undefined],
['5', 'day:deleted', { dayId: 9 }, 'sock'],
]);
});
});
});
+15 -6
View File
@@ -247,13 +247,22 @@ describe('update', () => {
});
});
describe('remove', () => {
it('DAY-SVC-015 — deletes the day', () => {
// DAY-SVC-015 pinned the bare delete this class used to own. Deleting a day moved
// to DayRemovalService with the renumbering and the stay cancellation around it;
// its cases are DAY-DEL-001 to DAY-DEL-018 in day-removal.service.test.ts.
describe('getTripForViewer', () => {
it('DAY-SVC-098: reads the trip in list shape, ownership from the viewer, without the feed token', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const day = createDay(testDb, trip.id) as any;
svc.remove(day.id);
expect(svc.getDay(day.id, trip.id)).toBeUndefined();
const { user: member } = createUser(testDb);
const trip = createTrip(testDb, user.id, { start_date: '2026-06-01', end_date: '2026-06-02' });
testDb.prepare('UPDATE trips SET feed_token = ? WHERE id = ?').run('secret-feed', trip.id);
expect(svc.getTripForViewer(trip.id, user.id)).toMatchObject({
id: trip.id, end_date: '2026-06-02', day_count: 2, place_count: 0, is_owner: 1, feed_token: null,
});
expect(svc.getTripForViewer(trip.id, member.id)).toMatchObject({ id: trip.id, is_owner: 0 });
expect(svc.getTripForViewer(99999, user.id)).toBeUndefined();
});
});
@@ -249,7 +249,7 @@ describe('every schema-validated method rejects a payload its schema refuses', (
const anything = new Proxy({}, { get: () => vi.fn(() => ({ id: 1, trip: { id: 1 }, updatedTrip: { id: 1 }, reservation: { id: 1 } })) }) as never;
const registry = createTestPluginRegistry([
new PlacesRpc(anything, anything, realtime(), guards),
new DaysRpc(anything, realtime(), guards),
new DaysRpc(anything, realtime(), guards, anything),
new PackingRpc(anything, realtime(), guards),
new TripsRpc(anything, anything, anything, anything, db, realtime(), guards, anything, anything),
new AccommodationsRpc(anything, realtime(), guards),