mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
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:
@@ -11,6 +11,7 @@ import { resetAllStores, seedStore } from '../../../tests/helpers/store';
|
||||
import { buildUser, buildTrip } from '../../../tests/helpers/factories';
|
||||
import { server } from '../../../tests/helpers/msw/server';
|
||||
import type { Trip } from '../../types';
|
||||
import { MAX_TRIP_DAYS } from '@trek/shared';
|
||||
import TripFormModal from './TripFormModal';
|
||||
|
||||
const defaultProps = {
|
||||
@@ -321,7 +322,7 @@ describe('TripFormModal', () => {
|
||||
|
||||
it('FE-COMP-TRIPFORM-029: clearing the day count leaves the field empty (no snap to 1)', () => {
|
||||
render(<TripFormModal {...defaultProps} trip={null} />);
|
||||
const dayInput = document.querySelector('input[max="365"]') as HTMLInputElement;
|
||||
const dayInput = document.querySelector(`input[max="${MAX_TRIP_DAYS}"]`) as HTMLInputElement;
|
||||
expect(dayInput).toBeInTheDocument();
|
||||
expect(dayInput.value).toBe('7');
|
||||
fireEvent.change(dayInput, { target: { value: '' } });
|
||||
@@ -333,7 +334,7 @@ describe('TripFormModal', () => {
|
||||
const onSave = vi.fn();
|
||||
render(<TripFormModal {...defaultProps} trip={null} onSave={onSave} />);
|
||||
await user.type(screen.getByPlaceholderText(/Summer in Japan/i), 'No-date Trip');
|
||||
const dayInput = document.querySelector('input[max="365"]') as HTMLInputElement;
|
||||
const dayInput = document.querySelector(`input[max="${MAX_TRIP_DAYS}"]`) as HTMLInputElement;
|
||||
fireEvent.change(dayInput, { target: { value: '' } });
|
||||
const submitBtn = screen.getAllByText('Create New Trip').find(el => el.closest('button'))!;
|
||||
await user.click(submitBtn.closest('button')!);
|
||||
@@ -534,7 +535,7 @@ describe('TripFormModal', () => {
|
||||
expect(screen.getByPlaceholderText(/Summer in Japan/i)).toHaveValue('');
|
||||
expect(screen.getByText(/^EUR/)).toBeInTheDocument();
|
||||
// No dates -> the day-count field appears and falls back to 7.
|
||||
expect(document.querySelector('input[max="365"]')).toHaveValue('7');
|
||||
expect(document.querySelector(`input[max="${MAX_TRIP_DAYS}"]`)).toHaveValue('7');
|
||||
});
|
||||
|
||||
// ── Create follow-ups: members and cover ──────────────────────────────────
|
||||
@@ -921,7 +922,7 @@ describe('TripFormModal', () => {
|
||||
fireEvent.keyDown(input, { key: 'Enter' });
|
||||
|
||||
// The day-count field disappears once the trip is dated.
|
||||
await waitFor(() => expect(document.querySelector('input[max="365"]')).toBeNull());
|
||||
await waitFor(() => expect(document.querySelector(`input[max="${MAX_TRIP_DAYS}"]`)).toBeNull());
|
||||
await submitNewTrip(user);
|
||||
await waitFor(() => expect(onSave).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ start_date: '2026-04-10', end_date: '2026-04-10' })
|
||||
@@ -951,18 +952,18 @@ describe('TripFormModal', () => {
|
||||
));
|
||||
});
|
||||
|
||||
it('FE-COMP-TRIPFORM-067: the day count is clamped to the 1..365 range', async () => {
|
||||
it('FE-COMP-TRIPFORM-067: the day count is clamped to the 1..MAX_TRIP_DAYS range', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSave = vi.fn().mockResolvedValue({});
|
||||
render(<TripFormModal {...defaultProps} trip={null} onSave={onSave} />);
|
||||
|
||||
const dayInput = document.querySelector('input[max="365"]') as HTMLInputElement;
|
||||
fireEvent.change(dayInput, { target: { value: '400' } });
|
||||
expect(dayInput.value).toBe('365');
|
||||
const dayInput = document.querySelector(`input[max="${MAX_TRIP_DAYS}"]`) as HTMLInputElement;
|
||||
fireEvent.change(dayInput, { target: { value: String(MAX_TRIP_DAYS + 1) } });
|
||||
expect(dayInput.value).toBe(String(MAX_TRIP_DAYS));
|
||||
|
||||
await user.type(screen.getByPlaceholderText(/Summer in Japan/i), 'Long Trip');
|
||||
await submitNewTrip(user);
|
||||
await waitFor(() => expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ day_count: 365 })));
|
||||
await waitFor(() => expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ day_count: MAX_TRIP_DAYS })));
|
||||
});
|
||||
|
||||
it('FE-COMP-TRIPFORM-068: a currency without a known symbol is labelled with its code', () => {
|
||||
@@ -1249,4 +1250,44 @@ describe('TripFormModal', () => {
|
||||
await waitFor(() => expect(revokeObjectURL).toHaveBeenCalledWith('blob:first'));
|
||||
Object.defineProperty(URL, 'revokeObjectURL', { writable: true, configurable: true, value: original });
|
||||
});
|
||||
|
||||
it('FE-COMP-TRIPFORM-085: a trip longer than a year is saved with its full range (#2403)', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSave = vi.fn().mockResolvedValue({});
|
||||
const trip = buildTrip({ id: 1, title: 'Gap year', start_date: '2025-01-26', end_date: '2026-01-28' });
|
||||
render(<TripFormModal {...defaultProps} trip={trip} onSave={onSave} />);
|
||||
await user.click(screen.getByRole('button', { name: /Update/i }));
|
||||
await waitFor(() => expect(onSave).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ start_date: '2025-01-26', end_date: '2026-01-28' })
|
||||
));
|
||||
});
|
||||
|
||||
it('FE-COMP-TRIPFORM-086: a range past MAX_TRIP_DAYS is refused before anything is sent', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSave = vi.fn();
|
||||
const trip = buildTrip({ id: 1, title: 'Week', start_date: '2026-01-01', end_date: '2026-01-07' });
|
||||
render(<TripFormModal {...defaultProps} trip={trip} onSave={onSave} />);
|
||||
|
||||
await user.click(screen.getAllByRole('button', { name: 'Enter date manually' })[1]);
|
||||
const endInput = screen.getByPlaceholderText('DD.MM.YYYY');
|
||||
fireEvent.change(endInput, { target: { value: '2036-01-01' } });
|
||||
fireEvent.keyDown(endInput, { key: 'Enter' });
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /Update/i }));
|
||||
await screen.findByText(`A trip can span at most ${MAX_TRIP_DAYS} days`);
|
||||
expect(onSave).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FE-COMP-TRIPFORM-087: a trip stored with a longer range can still be renamed', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSave = vi.fn().mockResolvedValue({});
|
||||
const trip = buildTrip({ id: 1, title: 'Legacy', start_date: '2020-01-01', end_date: '2030-01-01' });
|
||||
render(<TripFormModal {...defaultProps} trip={trip} onSave={onSave} />);
|
||||
await user.clear(screen.getByPlaceholderText(/Summer in Japan/i));
|
||||
await user.type(screen.getByPlaceholderText(/Summer in Japan/i), 'Renamed');
|
||||
await user.click(screen.getByRole('button', { name: /Update/i }));
|
||||
await waitFor(() => expect(onSave).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ title: 'Renamed', start_date: '2020-01-01', end_date: '2030-01-01' })
|
||||
));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,7 +11,7 @@ import { useTranslation } from '../../i18n'
|
||||
import { CustomDatePicker } from '../shared/CustomDateTimePicker'
|
||||
import { normalizeImageFile } from '../../utils/convertHeic'
|
||||
import { getApiErrorMessage, type Trip } from '../../types'
|
||||
import type { TripCreateRequest } from '@trek/shared'
|
||||
import { MAX_TRIP_DAYS, tripSpanDays, type TripCreateRequest } from '@trek/shared'
|
||||
import { NumericInput } from '../shared/NumericInput'
|
||||
import { currenciesWith, SYMBOLS } from '../Budget/BudgetPanel.constants'
|
||||
|
||||
@@ -148,12 +148,17 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
if (!formData.title.trim()) { setError(t('dashboard.titleRequired')); return }
|
||||
if (formData.start_date && formData.end_date && new Date(formData.end_date) < new Date(formData.start_date)) {
|
||||
setError(t('dashboard.endDateError')); return
|
||||
if (formData.start_date && formData.end_date) {
|
||||
const span = tripSpanDays(formData.start_date, formData.end_date)
|
||||
if (span < 1) { setError(t('dashboard.endDateError')); return }
|
||||
// Only a range being set is held to the limit, as on the server: a trip that
|
||||
// already carries a longer one can still be renamed.
|
||||
const datesTouched = !trip || formData.start_date !== (trip.start_date || '') || formData.end_date !== (trip.end_date || '')
|
||||
if (datesTouched && span > MAX_TRIP_DAYS) { setError(t('dashboard.tripTooLong', { days: MAX_TRIP_DAYS })); return }
|
||||
}
|
||||
if (!formData.start_date && !formData.end_date) {
|
||||
const dc = Number(formData.day_count)
|
||||
if (formData.day_count === '' || !Number.isInteger(dc) || dc < 1 || dc > 365) {
|
||||
if (formData.day_count === '' || !Number.isInteger(dc) || dc < 1 || dc > MAX_TRIP_DAYS) {
|
||||
setError(t('dashboard.dayCountRequired')); return
|
||||
}
|
||||
}
|
||||
@@ -557,11 +562,11 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp
|
||||
<label className={labelCls}>
|
||||
{t('dashboard.dayCount')}
|
||||
</label>
|
||||
<NumericInput min={1} max={365} value={formData.day_count}
|
||||
<NumericInput min={1} max={MAX_TRIP_DAYS} value={formData.day_count}
|
||||
onValueChange={raw => {
|
||||
if (raw === '') { update('day_count', ''); return }
|
||||
const n = Math.floor(Number(raw))
|
||||
if (Number.isFinite(n)) update('day_count', Math.min(365, Math.max(1, n)))
|
||||
if (Number.isFinite(n)) update('day_count', Math.min(MAX_TRIP_DAYS, Math.max(1, n)))
|
||||
}}
|
||||
className={inputCls} />
|
||||
<p className="text-caption text-content-faint mt-1.5">{t('dashboard.dayCountHint')}</p>
|
||||
|
||||
@@ -11,7 +11,7 @@ import { CustomDatePicker } from '../../../components/shared/CustomDateTimePicke
|
||||
import CustomSelect from '../../../components/shared/CustomSelect'
|
||||
import { currenciesWith, SYMBOLS } from '../../../components/Budget/BudgetPanel.constants'
|
||||
import type { DashboardTrip } from '../../../pages/dashboard/dashboardModel'
|
||||
import type { Trip, TripCreateRequest } from '@trek/shared'
|
||||
import { MAX_TRIP_DAYS, tripSpanDays, type Trip, type TripCreateRequest } from '@trek/shared'
|
||||
import MSheet from '../../components/MSheet'
|
||||
import MIconBtn from '../../components/MIconBtn'
|
||||
import MListRow from '../../components/MListRow'
|
||||
@@ -112,8 +112,11 @@ export default function MNewTripSheet({ open, trip, onClose, onSave, onCoverUpda
|
||||
const handleSave = async () => {
|
||||
setError('')
|
||||
if (!title.trim()) { setError(t('dashboard.titleRequired')); return }
|
||||
if (startDate && endDate && new Date(endDate) < new Date(startDate)) {
|
||||
setError(t('dashboard.endDateError')); return
|
||||
if (startDate && endDate) {
|
||||
const span = tripSpanDays(startDate, endDate)
|
||||
if (span < 1) { setError(t('dashboard.endDateError')); return }
|
||||
const datesTouched = !trip || startDate !== (trip.start_date || '') || endDate !== (trip.end_date || '')
|
||||
if (datesTouched && span > MAX_TRIP_DAYS) { setError(t('dashboard.tripTooLong', { days: MAX_TRIP_DAYS })); return }
|
||||
}
|
||||
setIsSaving(true)
|
||||
try {
|
||||
|
||||
@@ -8,7 +8,7 @@ import { usePermissionsStore } from '../../../../src/store/permissionsStore';
|
||||
import { useSettingsStore } from '../../../../src/store/settingsStore';
|
||||
import { buildUser } from '../../../helpers/factories';
|
||||
import type { DashboardTrip } from '../../../../src/pages/dashboard/dashboardModel';
|
||||
import type { Trip, TripCreateRequest } from '@trek/shared';
|
||||
import { MAX_TRIP_DAYS, type Trip, type TripCreateRequest } from '@trek/shared';
|
||||
|
||||
interface CoverSearchPhoto {
|
||||
id: string
|
||||
@@ -136,6 +136,20 @@ describe('MNewTripSheet', () => {
|
||||
expect(onSave).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FE-MOB-NTSH-036: a range past MAX_TRIP_DAYS blocks the save (#2403)', () => {
|
||||
const onSave = vi.fn(async () => undefined);
|
||||
render(<MNewTripSheet open trip={null} onClose={() => {}} onSave={onSave} />);
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText('e.g. Summer in Japan'), { target: { value: 'Decade' } });
|
||||
fireEvent.change(screen.getByLabelText('Start Date'), { target: { value: '2026-01-01' } });
|
||||
fireEvent.change(screen.getByLabelText('End Date'), { target: { value: '2036-01-01' } });
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Create New Trip' }));
|
||||
|
||||
expect(screen.getByText(`A trip can span at most ${MAX_TRIP_DAYS} days`)).toBeInTheDocument();
|
||||
expect(onSave).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FE-MOB-NTSH-005: a dateless create asks for the 7 default days and closes', async () => {
|
||||
const onSave = vi.fn(async (_data: TripCreateRequest) => undefined);
|
||||
const onClose = vi.fn();
|
||||
|
||||
Reference in New Issue
Block a user