mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-01 08:12:43 +00:00
151 lines
8.3 KiB
TypeScript
151 lines
8.3 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
|
import { normalizeItems } from '../normalize';
|
|
import { needsAirportZones, resolveCards } from '../resolve';
|
|
import { loadAirportZones } from '../time-zones';
|
|
import type { StructuredCard, TransportBooking } from '../types';
|
|
import { validateCard } from '../validate';
|
|
|
|
const ctx = 'http://schema.org';
|
|
const MAIL = new Date('2026-10-30T10:00:00Z');
|
|
const one = (item: unknown) => normalizeItems([{ item, source: 'json-ld' }])[0];
|
|
const problems = (item: unknown, mailDate = MAIL) => validateCard(one(item), mailDate).problems;
|
|
|
|
function flight(dep: [string, string, string], arr: [string, string, string], extra: Record<string, unknown> = {}) {
|
|
return {
|
|
'@context': ctx, '@type': 'FlightReservation', reservationNumber: 'T1', underName: 'Erika Mustermann',
|
|
reservationFor: {
|
|
'@type': 'Flight', flightNumber: '100', airline: { name: 'Test Air', iataCode: 'TA' },
|
|
departureAirport: { name: dep[0], iataCode: dep[1] }, departureTime: dep[2],
|
|
arrivalAirport: { name: arr[0], iataCode: arr[1] }, arrivalTime: arr[2],
|
|
},
|
|
...extra,
|
|
};
|
|
}
|
|
|
|
const sfoJfk = flight(['San Francisco', 'SFO', '2026-12-04T20:15:00-08:00'], ['New York', 'JFK', '2026-12-05T06:30:00-05:00']);
|
|
|
|
describe('validateCard', () => {
|
|
it('accepts a complete flight near the mail date', () => {
|
|
expect(problems(sfoJfk)).toEqual([]);
|
|
});
|
|
|
|
it('rejects flights with missing times or places', () => {
|
|
const noArrival = flight(['San Francisco', 'SFO', '2026-12-04T20:15:00-08:00'], ['New York', 'JFK', '']);
|
|
const bareDate = flight(['San Francisco', 'SFO', '2026-12-04'], ['New York', 'JFK', '2026-12-05T06:30:00-05:00']);
|
|
const noPlace = flight(['', '', '2026-12-04T20:15:00-08:00'], ['New York', 'JFK', '2026-12-05T06:30:00-05:00']);
|
|
expect(problems(noArrival)).toContain('arrival time missing');
|
|
expect(problems(bareDate)).toContain('departure time missing');
|
|
expect(problems(noPlace)).toContain('departure place missing');
|
|
});
|
|
|
|
it('rejects bookings years away from the mail', () => {
|
|
expect(problems(sfoJfk, new Date('2024-11-12T00:00:00Z'))).toContain('date too far from the mail');
|
|
expect(problems(sfoJfk, new Date('2028-01-01T00:00:00Z'))).toContain('date too far from the mail');
|
|
});
|
|
|
|
it('rejects copies of Google documentation examples', () => {
|
|
expect(problems({ ...sfoJfk, reservationNumber: 'RXJ34P' })).toContain('documentation example');
|
|
expect(problems({ '@context': ctx, '@type': 'ParcelDelivery', trackingNumber: '3453291231', carrier: 'FedEx' })).toContain('documentation example');
|
|
});
|
|
|
|
it('rejects stays that end before they start', () => {
|
|
const stay = (checkin: string, checkout: string) => ({
|
|
'@context': ctx, '@type': 'LodgingReservation', reservationNumber: 'H', reservationFor: { '@type': 'Hotel', name: 'Hotel' },
|
|
checkinDate: checkin, checkoutDate: checkout,
|
|
});
|
|
expect(problems(stay('2026-12-04', '2026-12-06'))).toEqual([]);
|
|
expect(problems(stay('2026-12-06', '2026-12-04'))).toContain('check-out not after check-in');
|
|
expect(problems(stay('2026-12-04', '2026-12-04'))).toContain('check-out not after check-in');
|
|
});
|
|
|
|
it('rejects orders with nothing to identify them, like Uber\'s empty Order', () => {
|
|
expect(problems({ '@context': ctx, '@type': 'Order', merchant: { name: 'UberEATS' } })).toContain('neither order number nor tracking');
|
|
expect(problems({ '@context': ctx, '@type': 'Order', merchant: 'K4G', orderNumber: 'A1' })).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe('resolveCards', () => {
|
|
const resolved = async (item: unknown): Promise<StructuredCard[]> => {
|
|
const cards = [one(item)];
|
|
return needsAirportZones(cards) ? resolveCards(cards, await loadAirportZones()) : resolveCards(cards);
|
|
};
|
|
|
|
it('gives flight ends their airport zones and keeps the instants', async () => {
|
|
const [card] = (await resolved(sfoJfk)) as TransportBooking[];
|
|
expect(card.legs[0].departure.at).toMatchObject({ zone: 'America/Los_Angeles', utc: '2026-12-05T04:15:00Z' });
|
|
expect(card.legs[0].arrival?.at).toMatchObject({ zone: 'America/New_York', utc: '2026-12-05T11:30:00Z' });
|
|
});
|
|
|
|
it('accepts eastbound date-line flights that land earlier on the wall clock', async () => {
|
|
const nrtHnl = flight(['Tokyo Narita', 'NRT', '2026-12-04T21:00:00+09:00'], ['Honolulu', 'HNL', '2026-12-04T09:15:00-10:00']);
|
|
const sydLax = flight(['Sydney', 'SYD', '2026-12-04T09:50:00+11:00'], ['Los Angeles', 'LAX', '2026-12-04T06:20:00-08:00']);
|
|
expect(await resolved(nrtHnl)).toHaveLength(1);
|
|
expect(await resolved(sydLax)).toHaveLength(1);
|
|
});
|
|
|
|
it('rejects arrival before departure and flights over 20 hours', async () => {
|
|
const backwards = flight(['Frankfurt', 'FRA', '2026-12-01T10:15:00+01:00'], ['New York', 'JFK', '2026-12-01T03:05:00-05:00']);
|
|
const tooLong = flight(['Frankfurt', 'FRA', '2026-12-01T10:15:00+01:00'], ['Sydney', 'SYD', '2026-12-02T23:00:00+11:00']);
|
|
expect(await resolved(backwards)).toEqual([]);
|
|
expect(await resolved(tooLong)).toEqual([]);
|
|
});
|
|
|
|
it('drops a flight whose offset contradicts the airport zone', async () => {
|
|
const wrongOffset = flight(['Frankfurt', 'FRA', '2026-12-01T10:15:00+00:00'], ['New York', 'JFK', '2026-12-01T13:05:00-05:00']);
|
|
expect(await resolved(wrongOffset)).toEqual([]);
|
|
});
|
|
|
|
it('reads a flight time without offset in the airport zone, and drops it when the airport is unknown', async () => {
|
|
const floating = flight(['Frankfurt', 'FRA', '2026-12-01T10:15:00'], ['New York', 'JFK', '2026-12-01T13:05:00']);
|
|
const [card] = (await resolved(floating)) as TransportBooking[];
|
|
expect(card.legs[0].departure.at).toMatchObject({ zone: 'Europe/Berlin', utc: '2026-12-01T09:15:00Z' });
|
|
const unknown = flight(['Nowhere', 'QQQ', '2026-12-01T10:15:00'], ['New York', 'JFK', '2026-12-01T13:05:00']);
|
|
expect(await resolved(unknown)).toEqual([]);
|
|
});
|
|
|
|
it('never shows a flight before the airport table is there', () => {
|
|
expect(resolveCards([one(sfoJfk)])).toEqual([]);
|
|
});
|
|
|
|
it('uses a fixed-offset zone where no airport code is known', async () => {
|
|
const noCode = flight(['San Francisco', '', '2026-12-04T20:15:00-08:00'], ['New York', 'JFK', '2026-12-05T06:30:00-05:00']);
|
|
const [card] = (await resolved(noCode)) as TransportBooking[];
|
|
expect(card.legs[0].departure.at.zone).toBe('Etc/GMT+8');
|
|
});
|
|
|
|
it('treats hotel times without offset or known country as dates only', async () => {
|
|
const [card] = await resolved({
|
|
'@context': ctx, '@type': 'LodgingReservation', reservationNumber: 'H', reservationFor: { '@type': 'Hotel', name: 'Hotel', address: { addressCountry: 'US' } },
|
|
checkinDate: '2026-12-04T15:00:00', checkoutDate: '2026-12-06T11:00:00',
|
|
});
|
|
expect(card).toMatchObject({ checkin: { date: '2026-12-04' }, checkout: { date: '2026-12-06' } });
|
|
expect((card as { checkin: { time?: string } }).checkin.time).toBeUndefined();
|
|
});
|
|
|
|
it('ends a date-only event end at midnight local time even across a DST change', async () => {
|
|
const event = (country?: string) => ({
|
|
'@context': ctx, '@type': 'EventReservation', reservationNumber: 'EV1',
|
|
reservationFor: {
|
|
'@type': 'Event', name: 'Herbstmesse', startDate: '2026-10-23T18:00:00+02:00', endDate: '2026-10-26',
|
|
location: { '@type': 'Place', name: 'Messe', ...(country ? { address: { addressCountry: country } } : {}) },
|
|
},
|
|
});
|
|
const [withVenue] = await resolved(event('DE'));
|
|
expect(withVenue).toMatchObject({ end: { date: '2026-10-26', time: '23:59:59', zone: 'Europe/Berlin', utc: '2026-10-26T22:59:59Z' } });
|
|
const [noVenueCountry] = await resolved(event());
|
|
expect(noVenueCountry).toMatchObject({ end: { date: '2026-10-26', time: '23:59:59', zone: 'Etc/GMT-2', utc: '2026-10-26T21:59:59Z' } });
|
|
});
|
|
|
|
it('reads bus times in the single zone of the stop\'s country', async () => {
|
|
const [card] = (await resolved({
|
|
'@context': ctx, '@type': 'BusReservation', reservationNumber: 'B',
|
|
reservationFor: {
|
|
'@type': 'BusTrip', busNumber: 'FLX30',
|
|
departureBusStop: { name: 'Hannover', address: { addressCountry: 'DE' } }, departureTime: '2026-06-04T11:44:00+0200',
|
|
arrivalBusStop: { name: 'Berlin', address: { addressCountry: 'DE' } }, arrivalTime: '2026-06-04T13:44:00+0200',
|
|
},
|
|
})) as TransportBooking[];
|
|
expect(card.legs[0].departure.at.zone).toBe('Europe/Berlin');
|
|
});
|
|
});
|