mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
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:
@@ -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 () => {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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()),
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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] }],
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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'],
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user