import { describe, it, expect } from 'vitest' import { AxiosError, AxiosHeaders } from 'axios' import { coverSrc, dayHasEntries, formatDateRange, formatDurationMinutes, groupInOrder, isHttpUrl, isSharedTripPayload, legFacts, linkHost, sharedTripLoadError, spanLabelKey, stopNumbers, transportFacts, unplannedPlaces, } from './sharedTripModel' describe('sharedTripModel (#2320)', () => { it('accepts http and https and nothing else', () => { expect(isHttpUrl('https://a.example/x')).toBe(true) expect(isHttpUrl(' http://a.example ')).toBe(true) expect(isHttpUrl('javascript:alert(1)')).toBe(false) expect(isHttpUrl('data:text/html,hi')).toBe(false) expect(isHttpUrl('ftp://a.example')).toBe(false) expect(isHttpUrl('not a url')).toBe(false) expect(isHttpUrl('')).toBe(false) expect(isHttpUrl(null)).toBe(false) expect(isHttpUrl(42)).toBe(false) }) it('writes a planned stay as minutes, hours, or both', () => { expect(formatDurationMinutes(45)).toBe('45 min') expect(formatDurationMinutes(60)).toBe('1 h') expect(formatDurationMinutes(150)).toBe('2 h 30 min') expect(formatDurationMinutes(89.6)).toBe('1 h 30 min') }) it('writes nothing for a missing, zero or nonsense figure', () => { expect(formatDurationMinutes(null)).toBeNull() expect(formatDurationMinutes(undefined)).toBeNull() expect(formatDurationMinutes(0)).toBeNull() expect(formatDurationMinutes(-5)).toBeNull() expect(formatDurationMinutes(Number.NaN)).toBeNull() }) it('labels a link by its host, without the www', () => { expect(linkHost('https://www.bahn.example/booking/abc')).toBe('bahn.example') expect(linkHost('https://booking.example')).toBe('booking.example') expect(linkHost('nonsense')).toBe('nonsense') }) }) describe('sharedTripLoadError (#2505)', () => { // What the share endpoint sends for an unknown, expired or revoked token. const ENDPOINT_404 = { error: 'Invalid or expired link' } const answered = (status: number, data: unknown = ENDPOINT_404) => new AxiosError('Request failed', 'ERR_BAD_RESPONSE', undefined, undefined, { status, statusText: '', data, headers: {}, config: { headers: new AxiosHeaders() }, }) it('calls only the endpoint 404 an expired link', () => { expect(sharedTripLoadError(answered(404))).toBe('expired') }) it('treats every other answer as a failed load', () => { for (const status of [400, 401, 403, 408, 429, 500, 502, 503, 504]) { expect(sharedTripLoadError(answered(status))).toBe('unavailable') } }) it('does not take a 404 without the TREK error body for an expired link', () => { // Traefik with the container stopped or still starting, nginx, an empty // answer: a 404 from something in front of TREK says nothing about the token. expect(sharedTripLoadError(answered(404, '404 page not found\n'))).toBe('unavailable') expect(sharedTripLoadError(answered(404, '

404 Not Found

'))).toBe('unavailable') expect(sharedTripLoadError(answered(404, ''))).toBe('unavailable') expect(sharedTripLoadError(answered(404, null))).toBe('unavailable') expect(sharedTripLoadError(answered(404, {}))).toBe('unavailable') expect(sharedTripLoadError(answered(404, [{ error: 'x' }]))).toBe('unavailable') expect(sharedTripLoadError(answered(404, { error: 404 }))).toBe('unavailable') }) it('treats a request without any answer as a failed load', () => { expect(sharedTripLoadError(new AxiosError('Network Error', AxiosError.ERR_NETWORK))).toBe('unavailable') expect(sharedTripLoadError(new AxiosError('timeout of 8000ms exceeded', AxiosError.ECONNABORTED))).toBe('unavailable') expect(sharedTripLoadError(new TypeError('Failed to fetch'))).toBe('unavailable') expect(sharedTripLoadError(undefined)).toBe('unavailable') expect(sharedTripLoadError(null)).toBe('unavailable') }) }) describe('isSharedTripPayload (#2505)', () => { it('accepts what the share endpoint sends', () => { expect(isSharedTripPayload({ trip: { id: 1, title: 'Lisbon' }, days: [], permissions: {} })).toBe(true) }) it('rejects a 200 that is not the share payload', () => { // An auth wall or a captive portal answering the API call with its own page. expect(isSharedTripPayload('Sign in')).toBe(false) expect(isSharedTripPayload('')).toBe(false) expect(isSharedTripPayload(null)).toBe(false) expect(isSharedTripPayload(undefined)).toBe(false) expect(isSharedTripPayload([])).toBe(false) expect(isSharedTripPayload({})).toBe(false) expect(isSharedTripPayload({ trip: null })).toBe(false) expect(isSharedTripPayload({ trip: 'Lisbon' })).toBe(false) }) }) describe('sharedTripModel, the redesigned page', () => { it('reads a cover in each of the three shapes the column holds', () => { expect(coverSrc(null)).toBeNull() expect(coverSrc('https://cdn.example/a.jpg')).toBe('https://cdn.example/a.jpg') expect(coverSrc('/uploads/covers/b.jpg')).toBe('/uploads/covers/b.jpg') expect(coverSrc('c.jpg')).toBe('/uploads/c.jpg') }) it('prints a range with an arrow, a single date alone, and nothing without dates', () => { expect(formatDateRange('2026-07-07', '2026-07-25', 'en-US')).toBe('Jul 7, 2026 → Jul 25, 2026') expect(formatDateRange('2026-07-07', null, 'en-US')).toBe('Jul 7, 2026') expect(formatDateRange(null, undefined, 'en-US')).toBeNull() }) it('numbers stops by their order, counts a stop without a place, and gives a revisited place both numbers', () => { const { byAssignment, byPlace } = stopNumbers([ { id: 3, order_index: 2, place: { id: 10 } }, { id: 1, order_index: 0, place: { id: 10 } }, { id: 2, order_index: 1, place: null }, { id: 4, order_index: 3, place: { id: 11 } }, ]) expect(byAssignment).toEqual({ 1: 1, 2: 2, 3: 3, 4: 4 }) expect(byPlace).toEqual({ 10: [1, 3], 11: [4] }) }) it('groups in the order the groups first appear', () => { expect(groupInOrder(['b1', 'a1', 'b2'], v => v[0])).toEqual([['b', ['b1', 'b2']], ['a', ['a1']]]) }) it('names each end of a span the way the planner does, and a single day not at all', () => { expect(spanLabelKey('flight', 'single')).toBeNull() expect(spanLabelKey('flight', 'start')).toBe('reservations.span.departure') expect(spanLabelKey('flight', 'middle')).toBe('reservations.span.inTransit') expect(spanLabelKey('car', 'end')).toBe('reservations.span.return') expect(spanLabelKey('parking', 'start')).toBe('reservations.span.dropOff') expect(spanLabelKey('parking', 'end')).toBe('reservations.span.pickup') expect(spanLabelKey('hotel', 'middle')).toBe('reservations.span.ongoing') }) it('states the facts of a transport row, those of the leg itself for a leg, and none for other types', () => { expect(transportFacts({ type: 'flight', metadata: { airline: 'LH', flight_number: '190', departure_airport: 'FRA', arrival_airport: 'BER' } }, 'Platform')) .toEqual(['LH', '190', 'FRA → BER']) expect(transportFacts({ type: 'flight', metadata: JSON.stringify({ airline: 'KLM', departure_airport: 'AMS' }) }, 'Platform')).toEqual(['KLM']) expect(transportFacts({ type: 'flight', metadata: {}, __leg: { index: 1, airline: 'EK', flight_number: '46', from: 'FRA', to: null } }, 'Platform')) .toEqual(['EK', '46', 'FRA']) expect(transportFacts({ type: 'train', metadata: '{"train_number":"ICE 5","platform":"7"}' }, 'Platform')).toEqual(['ICE 5', 'Platform 7']) expect(transportFacts({ type: 'train', metadata: {}, __leg: { index: 0, train_number: 'EC 51', from: 'Basel', to: 'Milano' } }, 'Gleis')) .toEqual(['EC 51', 'Basel → Milano']) expect(transportFacts({ type: 'train', metadata: 'not json' }, 'Platform')).toEqual([]) expect(transportFacts({ type: 'bus', metadata: { airline: 'Flix' } }, 'Platform')).toEqual([]) }) it('lists a leg with its carrier, number, platform and route', () => { expect(legFacts({ train_number: 'IC 8', platform: '12', from: 'Bern', to: 'Zurich' }, 'Platform')).toEqual(['IC 8', 'Platform 12', 'Bern → Zurich']) expect(legFacts({ airline: 'Emirates', flight_number: 'EK350' }, 'Platform')).toEqual(['Emirates', 'EK350']) }) }) describe('unplanned places and day entries (#1758, #1712)', () => { it('keeps the pool places no day has picked up, in pool order', () => { const places = [{ id: 3 }, { id: 1 }, { id: 2 }] const assignments = { 10: [{ place: { id: 1 } }, { place: null }], 11: [] } expect(unplannedPlaces(places, assignments)).toEqual([{ id: 3 }, { id: 2 }]) expect(unplannedPlaces([], assignments)).toEqual([]) }) it('counts a day with an entry or a night booked', () => { expect(dayHasEntries(0, 0)).toBe(false) expect(dayHasEntries(1, 0)).toBe(true) expect(dayHasEntries(0, 1)).toBe(true) }) })