fix(trips): let a trip run past 365 days and refuse a longer range instead of clipping it

A trip longer than a year stopped at day 365: generateDays capped the day
rows at MAX_TRIP_DAYS while the trip kept its full end date, so the last
days had a date but no day to plan on, in the day list, the transport and
booking dialogs and the phone's date pickers (#2403).

The limit is MAX_TRIP_DAYS in @trek/shared now, 999 days, shared by the
server, both trip forms and the MCP tools, and a range past it is refused
rather than cut short: 400 on REST, an error result on MCP, bad params on
the plugin RPC, and a message in the forms before anything is sent. The
check runs before the trip row and the budget are touched, also catches a
start date moved past the stored end on its own, and holds a day_count
rebuild to the same bound. A trip that already carries a longer range can
still be renamed. The MCP-only 90-day ceiling on create_trip goes with it;
it clipped dated trips the same way.

Closes #2403
This commit is contained in:
Maurice
2026-09-20 12:08:50 +02:00
parent 44c92052a3
commit c8d4ed896e
42 changed files with 409 additions and 70 deletions
+33 -7
View File
@@ -43,6 +43,7 @@ import { runMigrations } from '../../../src/db/migrations';
import { resetTestDb } from '../../helpers/test-db';
import { createUser, createTrip, createDay, createPlace, addTripMember, createBudgetItem, createPackingItem, createReservation, createDayNote, createCollabNote, createDayAssignment, createDayAccommodation } from '../../helpers/factories';
import { createMcpHarness, parseToolResult, parseResourceResult, type McpHarness } from '../../helpers/mcp-harness';
import { MAX_TRIP_DAYS } from '@trek/shared';
beforeAll(() => {
createTables(testDb);
@@ -99,16 +100,29 @@ describe('Tool: create_trip', () => {
});
});
it('caps days at 90 for very long trips', async () => {
it('generates every day of a trip longer than a year (#2403)', async () => {
const { user } = createUser(testDb);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'create_trip',
arguments: { title: 'Long Trip', start_date: '2026-01-01', end_date: '2027-12-31' },
arguments: { title: 'Long Trip', start_date: '2025-01-26', end_date: '2026-01-28' },
});
const data = parseToolResult(result) as any;
const days = testDb.prepare('SELECT COUNT(*) as c FROM days WHERE trip_id = ?').get(data.trip.id) as { c: number };
expect(days.c).toBe(90);
expect(days.c).toBe(368);
});
});
it('refuses a date range longer than MAX_TRIP_DAYS instead of cutting the days short', async () => {
const { user } = createUser(testDb);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'create_trip',
arguments: { title: 'Decade', start_date: '2026-01-01', end_date: '2036-01-01' },
});
expect(result.isError).toBe(true);
expect(JSON.stringify(result.content)).toContain(`at most ${MAX_TRIP_DAYS} days`);
expect(testDb.prepare('SELECT COUNT(*) as c FROM trips').get()).toEqual({ c: 0 });
});
});
@@ -153,11 +167,11 @@ describe('Tool: create_trip', () => {
});
});
it('refuses a day_count outside 1..365', async () => {
it('refuses a day_count outside 1..MAX_TRIP_DAYS', async () => {
const { user } = createUser(testDb);
await withHarness(user.id, async (h) => {
expect((await h.client.callTool({ name: 'create_trip', arguments: { title: 'Zero', day_count: 0 } })).isError).toBe(true);
expect((await h.client.callTool({ name: 'create_trip', arguments: { title: 'Huge', day_count: 400 } })).isError).toBe(true);
expect((await h.client.callTool({ name: 'create_trip', arguments: { title: 'Huge', day_count: MAX_TRIP_DAYS + 1 } })).isError).toBe(true);
expect(testDb.prepare('SELECT COUNT(*) as c FROM trips').get()).toEqual({ c: 0 });
});
});
@@ -378,16 +392,28 @@ describe('Tool: update_trip', () => {
});
});
it('refuses a day_count outside 1..365', async () => {
it('refuses a day_count outside 1..MAX_TRIP_DAYS', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Untouched' });
await withHarness(user.id, async (h) => {
expect((await h.client.callTool({ name: 'update_trip', arguments: { tripId: trip.id, day_count: 0 } })).isError).toBe(true);
expect((await h.client.callTool({ name: 'update_trip', arguments: { tripId: trip.id, day_count: 400 } })).isError).toBe(true);
expect((await h.client.callTool({ name: 'update_trip', arguments: { tripId: trip.id, day_count: MAX_TRIP_DAYS + 1 } })).isError).toBe(true);
expect(testDb.prepare('SELECT COUNT(*) as c FROM days WHERE trip_id = ?').get(trip.id)).toEqual({ c: 0 });
});
});
it('refuses a date range longer than MAX_TRIP_DAYS and leaves the trip untouched', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Untouched', start_date: '2026-07-01', end_date: '2026-07-07' });
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'update_trip', arguments: { tripId: trip.id, end_date: '2036-07-01' } });
expect(result.isError).toBe(true);
expect(JSON.stringify(result.content)).toContain(`at most ${MAX_TRIP_DAYS} days`);
const row = testDb.prepare('SELECT end_date FROM trips WHERE id = ?').get(trip.id) as { end_date: string };
expect(row.end_date).toBe('2026-07-07');
});
});
it('changes reminder_days and can turn the reminder off', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
@@ -23,7 +23,7 @@ import type { CalendarService } from '../../../src/nest/calendar/calendar.servic
import type { TripReadModelService } from '../../../src/nest/trip-read-model/trip-read-model.service';
import { NotFoundError, ValidationError } from '../../../src/nest/trips/trips.service';
import type { User } from '../../../src/types';
import { activeTripResponseSchema, tripCreateRequestSchema, tripTransferOwnershipRequestSchema } from '@trek/shared';
import { MAX_TRIP_DAYS, activeTripResponseSchema, tripCreateRequestSchema, tripTransferOwnershipRequestSchema } from '@trek/shared';
const user = { id: 1, role: 'user', email: 'u@example.test' } as User;
const req = { headers: {} } as Request;
@@ -180,6 +180,24 @@ describe('TripsController (parity with the legacy /api/trips route)', () => {
expect(create).toHaveBeenCalledWith(1, expect.objectContaining({ day_count: 7 }));
});
it('clamps day_count to MAX_TRIP_DAYS', () => {
const create = vi.fn().mockReturnValue({ trip: { id: 9 }, tripId: 9, reminderDays: 0 });
tc(svc({ create } as Partial<TripsService>)).create(user, { title: 'T', day_count: MAX_TRIP_DAYS + 1 }, req);
expect(create).toHaveBeenCalledWith(1, expect.objectContaining({ day_count: MAX_TRIP_DAYS }));
});
it('maps a ValidationError from create to 400 (range past MAX_TRIP_DAYS)', () => {
const create = vi.fn().mockImplementation(() => { throw new ValidationError(`A trip can span at most ${MAX_TRIP_DAYS} days`); });
expect(thrown(() => tc(svc({ create } as Partial<TripsService>)).create(user, { title: 'T', start_date: '2026-01-01', end_date: '2036-01-01' }, req))).toEqual({
status: 400, body: { error: `A trip can span at most ${MAX_TRIP_DAYS} days` },
});
});
it('re-throws an unknown error from create', () => {
const create = vi.fn().mockImplementation(() => { throw new Error('boom'); });
expect(() => tc(svc({ create } as Partial<TripsService>)).create(user, { title: 'T' }, req)).toThrow('boom');
});
it('logs the reminder when reminderDays is set', () => {
const create = vi.fn().mockReturnValue({ trip: { id: 9 }, tripId: 9, reminderDays: 3 });
expect(tc(svc({ create } as Partial<TripsService>)).create(user, { title: 'T' }, req)).toEqual({ trip: { id: 9 } });
+88 -2
View File
@@ -54,6 +54,7 @@ import { createTables } from '../../../src/db/schema';
import { runMigrations } from '../../../src/db/migrations';
import { resetTestDb } from '../../helpers/test-db';
import { createUser, createTrip, createReservation, createPlace, createDay, createDayAssignment, createDayNote, addTripMember } from '../../helpers/factories';
import { MAX_TRIP_DAYS } from '@trek/shared';
import { DatabaseService } from '../../../src/nest/database/database.service';
import { DaysService } from '../../../src/nest/days/days.service';
import { PermissionsService } from '../../../src/nest/permissions/permissions.service';
@@ -169,6 +170,10 @@ function getNotes(dayId: number) {
return testDb.prepare('SELECT * FROM day_notes WHERE day_id = ?').all(dayId) as { id: number; day_id: number }[];
}
function addDaysIso(date: string, n: number) {
return new Date(Date.parse(date + 'T00:00:00Z') + n * 86400000).toISOString().slice(0, 10);
}
// ── Tests ─────────────────────────────────────────────────────────────────────
describe('generateDays', () => {
@@ -260,6 +265,24 @@ describe('generateDays', () => {
expect(getAssignments(daysAfter[4].id)).toHaveLength(0);
});
it('TRIP-SVC-062: a range longer than a year gets every one of its days (#2403)', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { start_date: '2025-01-26', end_date: '2025-01-28' });
// The reporter's range: 368 days, and the days used to stop at 365.
svc.generateDays(trip.id, '2025-01-26', '2026-01-28');
const days = getDays(trip.id);
expect(days).toHaveLength(368);
expect(days[364].date).toBe('2026-01-25');
expect(days[367]).toMatchObject({ day_number: 368, date: '2026-01-28' });
});
it('TRIP-SVC-063: a dateless day_count is clamped to MAX_TRIP_DAYS', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
svc.generateDays(trip.id, null, null, MAX_TRIP_DAYS + 50);
expect(getDays(trip.id)).toHaveLength(MAX_TRIP_DAYS);
});
it('TRIP-SVC-013: clearing dates converts all days to dateless without destroying assignments', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { start_date: '2025-09-01', end_date: '2025-09-04' });
@@ -722,6 +745,17 @@ describe('folded trip CRUD', () => {
expect(getDays(tripId)).toHaveLength(3);
});
it('TRIP-SVC-064: create refuses a range past MAX_TRIP_DAYS and writes nothing', () => {
const { user } = createUser(testDb);
const before = (testDb.prepare('SELECT COUNT(*) AS n FROM trips').get() as { n: number }).n;
expect(() => svc.create(user.id, { title: 'Decade', start_date: '2026-01-01', end_date: '2036-01-01' }))
.toThrow(`A trip can span at most ${MAX_TRIP_DAYS} days`);
expect((testDb.prepare('SELECT COUNT(*) AS n FROM trips').get() as { n: number }).n).toBe(before);
// The longest allowed range goes through in full.
const { tripId } = svc.create(user.id, { title: 'Longest', start_date: '2026-01-01', end_date: addDaysIso('2026-01-01', MAX_TRIP_DAYS - 1) });
expect(getDays(tripId)).toHaveLength(MAX_TRIP_DAYS);
});
it('TRIP-SVC-045: remove deletes the trip, cleans skeleton journey entries and detaches filled ones', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
@@ -930,13 +964,15 @@ describe('folded trip CRUD', () => {
describe('TripsService wrapper helpers', () => {
it('re-anchors the budget before the trip row leaves its old currency (#1543)', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const order: string[] = [];
const rebaseSpy = vi.spyOn(budgetSvc, 'rebaseTripCurrency').mockImplementation(async () => { order.push('rebase'); });
const updateSpy = vi.spyOn(svc, 'updateTrip').mockImplementation(() => { order.push('update'); return {} as never; });
try {
await svc.update('9', 1, { currency: 'RUB' } as never, 'user');
await svc.update(trip.id, user.id, { currency: 'RUB' } as never, 'user');
// The rebase reads the outgoing currency off the trip row, so it has to run first.
expect(rebaseSpy).toHaveBeenCalledWith('9', 'RUB');
expect(rebaseSpy).toHaveBeenCalledWith(trip.id, 'RUB');
expect(order).toEqual(['rebase', 'update']);
} finally {
rebaseSpy.mockRestore();
@@ -944,6 +980,22 @@ describe('TripsService wrapper helpers', () => {
}
});
it('TRIP-SVC-068: update refuses a bad range before the budget is rebased (#2403)', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { start_date: '2026-07-01', end_date: '2026-07-07' });
const rebaseSpy = vi.spyOn(budgetSvc, 'rebaseTripCurrency').mockResolvedValue();
try {
await expect(svc.update(trip.id, user.id, { currency: 'USD', end_date: '2036-07-01' }, 'user'))
.rejects.toThrow(`A trip can span at most ${MAX_TRIP_DAYS} days`);
await expect(svc.update(trip.id, user.id, { currency: 'USD', start_date: '2026-07-10' }, 'user'))
.rejects.toThrow('End date must be after start date');
expect(rebaseSpy).not.toHaveBeenCalled();
await expect(svc.update(99999, user.id, { currency: 'USD' }, 'user')).rejects.toThrow('Trip not found');
} finally {
rebaseSpy.mockRestore();
}
});
it('canAccessTrip delegates to the db helper; can() delegates to checkPermission; broadcast forwards', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
@@ -1006,6 +1058,40 @@ describe('folded quirk branches', () => {
expect(() => svc.updateTrip(trip.id, owner.id, { start_date: '2025-06-10', end_date: '2025-06-01' }, 'user')).toThrow('End date must be after start date');
});
it('TRIP-SVC-065: updateTrip refuses a range past MAX_TRIP_DAYS before touching the row', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Week', start_date: '2026-07-01', end_date: '2026-07-07' });
expect(() => svc.updateTrip(trip.id, user.id, { title: 'Decade', end_date: '2036-07-01' }, 'user'))
.toThrow(`A trip can span at most ${MAX_TRIP_DAYS} days`);
expect(testDb.prepare('SELECT title, end_date FROM trips WHERE id = ?').get(trip.id)).toEqual({ title: 'Week', end_date: '2026-07-07' });
expect(getDays(trip.id)).toHaveLength(7);
// Moving only the start keeps the stored end and is measured against it.
expect(() => svc.updateTrip(trip.id, user.id, { start_date: '2020-01-01' }, 'user'))
.toThrow(`A trip can span at most ${MAX_TRIP_DAYS} days`);
});
it('TRIP-SVC-066: a trip whose stored range already exceeds the limit can still be renamed', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Legacy' });
testDb.prepare("UPDATE trips SET start_date = '2020-01-01', end_date = '2030-01-01' WHERE id = ?").run(trip.id);
const result = svc.updateTrip(trip.id, user.id, { title: 'Renamed' }, 'user');
expect(result.newTitle).toBe('Renamed');
expect(result.changes).toEqual({ title: 'Renamed' });
// A day_count would rebuild the grid over the whole stored range, so it is held to the limit too.
expect(() => svc.updateTrip(trip.id, user.id, { day_count: 5 }, 'user'))
.toThrow(`A trip can span at most ${MAX_TRIP_DAYS} days`);
expect(getDays(trip.id)).toHaveLength(0);
});
it('TRIP-SVC-067: a start date moved past the stored end is refused instead of emptying the trip', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Week', start_date: '2026-07-01', end_date: '2026-07-07' });
expect(() => svc.updateTrip(trip.id, user.id, { start_date: '2026-07-10' }, 'user'))
.toThrow('End date must be after start date');
expect(testDb.prepare('SELECT start_date FROM trips WHERE id = ?').get(trip.id)).toEqual({ start_date: '2026-07-01' });
expect(getDays(trip.id)).toHaveLength(7);
});
it('TRIP-SVC-049: addMember inserts the membership and reports the trip title; removeMember deletes it', () => {
const { user: owner } = createUser(testDb);
const { user: invitee } = createUser(testDb);
+12 -2
View File
@@ -15,6 +15,7 @@ import { PluginGuards } from '../../../src/nest/plugins/host/plugin-guards.servi
import { TripsRpc } from '../../../src/nest/trips/trips.rpc';
import { TripsModule } from '../../../src/nest/trips/trips.module';
import { NotFoundError, ValidationError } from '../../../src/nest/trips/trips.service';
import { MAX_TRIP_DAYS } from '@trek/shared';
import type { TripsService } from '../../../src/nest/trips/trips.service';
import type { ReservationsService } from '../../../src/nest/reservations/reservations.service';
import type { DaysService } from '../../../src/nest/days/days.service';
@@ -33,14 +34,17 @@ const req = (method: string, params: Record<string, unknown> = {}): RpcRequest =
const ALL_TRIP_GRANTS = ['db:read:trips', 'db:write:trips', 'db:create:trips', 'db:write:members'];
/** Trip 1 belongs to user 42. `allow` decides each individual permission check. */
export function build(opts: { allow?: (action: string) => boolean; updateThrows?: Error } = {}) {
export function build(opts: { allow?: (action: string) => boolean; updateThrows?: Error; createThrows?: Error } = {}) {
const trips = {
list: vi.fn(() => [{ id: 1, title: 'Japan' }]),
updateTrip: vi.fn(() => {
if (opts.updateThrows) throw opts.updateThrows;
return { updatedTrip: { id: 1, title: 'Japan' } };
}),
create: vi.fn((userId: number) => ({ trip: { id: 99, user_id: userId } })),
create: vi.fn((userId: number) => {
if (opts.createThrows) throw opts.createThrows;
return { trip: { id: 99, user_id: userId } };
}),
removeMember: vi.fn(),
} as unknown as TripsService & Record<string, ReturnType<typeof vi.fn>>;
const reservations = { list: vi.fn(() => [{ id: 5 }]) } as unknown as ReservationsService & Record<string, ReturnType<typeof vi.fn>>;
@@ -169,6 +173,12 @@ describe('TripsRpc writes', () => {
expect(res.error.message).toBe('no permission to create trips');
expect(f.trips.create).not.toHaveBeenCalled();
});
it('TRIPS-RPC-017 a range the service refuses comes back as BAD_PARAMS (#2403)', async () => {
const f = build({ createThrows: new ValidationError(`A trip can span at most ${MAX_TRIP_DAYS} days`) });
const res = (await f.host().dispatch(req('trips.create', { input: { title: 'Decade', start_date: '2026-01-01', end_date: '2036-01-01' } }), 42)) as RpcError;
expect(res.error).toMatchObject({ code: 'BAD_PARAMS', message: `A trip can span at most ${MAX_TRIP_DAYS} days` });
});
});
describe('TripsRpc membership', () => {