From f470ae029d032d7e93d514f5014dabeb51e39a8b Mon Sep 17 00:00:00 2001 From: Maurice Date: Mon, 12 Oct 2026 21:13:57 +0000 Subject: [PATCH] refactor(trips): the trip span check and the feed token strip move into trips helpers --- server/src/nest/trips/trips.helpers.ts | 35 ++++++++++++++++++++ server/src/nest/trips/trips.service.ts | 34 +++---------------- server/tests/unit/nest/trips.helpers.test.ts | 34 +++++++++++++++++++ 3 files changed, 73 insertions(+), 30 deletions(-) create mode 100644 server/src/nest/trips/trips.helpers.ts create mode 100644 server/tests/unit/nest/trips.helpers.test.ts diff --git a/server/src/nest/trips/trips.helpers.ts b/server/src/nest/trips/trips.helpers.ts new file mode 100644 index 000000000..2744b98bd --- /dev/null +++ b/server/src/nest/trips/trips.helpers.ts @@ -0,0 +1,35 @@ +import { ValidationError } from '../common/domain-errors'; +import { MAX_TRIP_DAYS, tripSpanDays } from '@trek/shared'; + +/** + * Pure helpers of the trips domain, moved out of trips.service.ts. Nothing + * here touches the DB, so they stay plain exports rather than methods on + * TripsService. + */ + +/** + * The date range is refused, not cut short: generateDays used to clip the day + * rows at the limit while the trip kept its full end date, so everything past + * the cut-off had dates but no day to go on (#2403). An inverted range is + * refused here as well, because a start date moved past the stored end date + * arrives on its own and would otherwise empty the trip. + */ +export function assertTripSpan(startDate: string, endDate: string) { + const span = tripSpanDays(startDate, endDate); + if (span < 1) throw new ValidationError('End date must be after start date'); + if (span > MAX_TRIP_DAYS) throw new ValidationError(`A trip can span at most ${MAX_TRIP_DAYS} days`); +} + +/** + * Strips `feed_token` from a trip row on its way out. + * + * The column is the sole credential for the anonymous /api/feed/trip/:token.ics + * route, and `SELECT t.*` hands it to every reader of the trip. Gating the + * token endpoint on `share_manage` means nothing while any member can read the + * same value out of the trip payload, so the two go together. No TREK client + * reads the field (it is absent from client/ and shared/ entirely). + */ +export function withoutFeedToken(row: T): T { + if (row && typeof row === 'object') delete (row as { feed_token?: unknown }).feed_token; + return row; +} diff --git a/server/src/nest/trips/trips.service.ts b/server/src/nest/trips/trips.service.ts index 1cc21c896..26ecb14f6 100644 --- a/server/src/nest/trips/trips.service.ts +++ b/server/src/nest/trips/trips.service.ts @@ -27,11 +27,9 @@ import { Tours } from '../../db/entities/Tours.entity'; import { TourWaypoints } from '../../db/entities/TourWaypoints.entity'; import type { TourWaypointRow } from '../../db/repositories/TourWaypoints.repository'; import { - MAX_TRIP_DAYS, addIsoDays, planDayGrid, resolveDayGridRange, - tripSpanDays, type ActiveTrip, type DayGridPlan, type DayGridRemoval, @@ -53,34 +51,7 @@ import { NotFoundError, ValidationError } from '../common/domain-errors'; import { UnitOfWork } from '../database/unit-of-work'; import { legacyBoundIntegerText } from '../common/row-id'; import { appClock } from '../common/timezoneService'; - -/** - * The date range is refused, not cut short: generateDays used to clip the day - * rows at the limit while the trip kept its full end date, so everything past - * the cut-off had dates but no day to go on (#2403). An inverted range is - * refused here as well, because a start date moved past the stored end date - * arrives on its own and would otherwise empty the trip. - */ -function assertTripSpan(startDate: string, endDate: string) { - const span = tripSpanDays(startDate, endDate); - if (span < 1) throw new ValidationError('End date must be after start date'); - if (span > MAX_TRIP_DAYS) throw new ValidationError(`A trip can span at most ${MAX_TRIP_DAYS} days`); -} - -/** - * Strips `feed_token` from a trip row on its way out. - * - * The column is the sole credential for the anonymous /api/feed/trip/:token.ics - * route, and `SELECT t.*` hands it to every reader of the trip. Gating the - * token endpoint on `share_manage` means nothing while any member can read the - * same value out of the trip payload, so the two go together. No TREK client - * reads the field (it is absent from client/ and shared/ entirely). - */ -export function withoutFeedToken(row: T): T { - if (row && typeof row === 'object') delete (row as Record).feed_token; - return row; -} - +import { assertTripSpan } from './trips.helpers'; interface CreateTripData { title: string; @@ -1003,3 +974,6 @@ export class TripsService { // trip aggregate; re-exported here because nine files already import them from // this module. export { NotFoundError, ValidationError }; + +// Lives in trips.helpers.ts; re-exported for the importers that read it from here. +export { withoutFeedToken } from './trips.helpers'; diff --git a/server/tests/unit/nest/trips.helpers.test.ts b/server/tests/unit/nest/trips.helpers.test.ts new file mode 100644 index 000000000..b54cd4310 --- /dev/null +++ b/server/tests/unit/nest/trips.helpers.test.ts @@ -0,0 +1,34 @@ +/** + * The pure helpers of the trips domain: the date range a trip may span and the + * feed token a trip row loses on its way out. TRIP-HELPERS-001 to 003. + */ +import { assertTripSpan, withoutFeedToken } from '../../../src/nest/trips/trips.helpers'; +import { MAX_TRIP_DAYS, addIsoDays } from '@trek/shared'; + +import { describe, expect, it } from 'vitest'; + +describe('assertTripSpan', () => { + it('TRIP-HELPERS-001: takes a range from a single day up to the limit', () => { + const lastAllowed = addIsoDays('2025-07-01', MAX_TRIP_DAYS - 1); + expect(() => assertTripSpan('2025-07-01', '2025-07-01')).not.toThrow(); + expect(() => assertTripSpan('2025-07-01', lastAllowed)).not.toThrow(); + }); + + it('TRIP-HELPERS-002: refuses an inverted range and one past the limit', () => { + const tooLate = addIsoDays('2025-07-01', MAX_TRIP_DAYS); + expect(() => assertTripSpan('2025-07-02', '2025-07-01')).toThrow('End date must be after start date'); + expect(() => assertTripSpan('2025-07-01', tooLate)).toThrow(`A trip can span at most ${MAX_TRIP_DAYS} days`); + }); +}); + +describe('withoutFeedToken', () => { + it('TRIP-HELPERS-003: drops feed_token from the row it was given and passes anything else through', () => { + const row = { id: 1, title: 'Japan', feed_token: 'feed-1' }; + expect(withoutFeedToken(row)).toBe(row); + expect(row).toEqual({ id: 1, title: 'Japan' }); + expect(row).not.toHaveProperty('feed_token'); + expect(withoutFeedToken(undefined)).toBeUndefined(); + expect(withoutFeedToken(null)).toBeNull(); + expect(withoutFeedToken(42)).toBe(42); + }); +});