// node --test const test = require('node:test'); const assert = require('node:assert/strict'); const core = require('./sas-bonus-watch.user.js'); // Shapes captured from sas.se on 2026-10-12 const ROUTES_ARN_HND_1209 = [ { flightId: 'SK1415-20261209-ARN-CPH', arrivalDate: '2026-12-10', arrivalTime: '07:55', departureDate: '2026-12-09', departureTime: '06:00', availability: { AG: 0, AP: 1, AB: 0 }, flyTime: 830, totalTime: 1075, noOfFlights: 2, haulType: 'LH' }, { flightId: 'SK1417-20261209-ARN-CPH', arrivalDate: '2026-12-10', arrivalTime: '07:55', departureDate: '2026-12-09', departureTime: '07:00', availability: { AG: 0, AP: 1, AB: 0 }, flyTime: 830, totalTime: 1015, noOfFlights: 2, haulType: 'LH' }, ]; const day = (date, seats) => ({ date, key: +date.replace(/-/g, '').slice(2), availableSeatsTotal: Object.values(seats).reduce((a, b) => a + b, 0), ...seats }); const calendarPayload = (outbound, inbound) => [{ airportCode: 'HND', cityName: 'Tokyo', flightClasses: ['AG', 'AP'], availability: { outbound: outbound.map(([d, s]) => day(d, s)), inbound: inbound.map(([d, s]) => day(d, s)) } }]; class Blocked extends Error {} function setup(watches, responses, { prefilter = true, now } = {}) { const cfg = core.validateConfig({ settings: { prefilter }, notify: [], watches }); const calls = []; const sent = []; const answer = (key) => { calls.push(key); const r = responses[key]; if (r instanceof Error) throw r; return r || []; }; const api = { calendar: async (o, d, direct, pax) => core.parseCalendar(answer(`cal ${o} ${d}`), d), flights: async (o, d, dayIso) => answer(`routes ${o} ${d} ${dayIso}`).map(core.parseFlight), }; const state = {}; const w = core.createWatcher({ cfg, api, state, save() {}, log() {}, ...(now && { now }), notify: async (title, body) => { sent.push({ title, body }); return true; } }); return { w, calls, sent, state }; } // one-way style by default so single-leg behaviour is easy to assert const watch = (extra = {}) => ({ name: 'tokyo', origins: ['ARN'], destinations: ['HND'], from: '2026-12-01', to: '2026-12-20', cabins: ['AP'], require_both_legs: false, ...extra }); const baseResponses = () => ({ 'cal ARN HND': calendarPayload([['2026-12-09', { AP: 1 }], ['2026-12-08', { AG: 5 }]], [['2027-01-05', { AG: 4 }]]), 'routes ARN HND 2026-12-09': ROUTES_ARN_HND_1209, }); test('new seats notify once, unchanged calendar uses cache', async () => { const { w, calls, sent } = setup([watch()], baseResponses()); assert.equal(await w.runCycle(), 2); assert.equal(sent.length, 1); assert.match(sent[0].body, /SAS Plus ×1/); assert.match(sent[0].body, /OUT ARN→HND Wed 09 Dec 06:00→Thu 10 Dec 07:55, 1 stop/); assert.deepEqual(calls, ['cal ARN HND', 'routes ARN HND 2026-12-09']); // 12-08 has only AG -> never fetched calls.length = 0; assert.equal(await w.runCycle(), 0); assert.equal(sent.length, 1); assert.deepEqual(calls, ['cal ARN HND']); }); test('disappear then reappear notifies again', async () => { const r = baseResponses(); const { w, sent } = setup([watch()], r); await w.runCycle(); const full = r['cal ARN HND']; r['cal ARN HND'] = calendarPayload([], []); await w.runCycle(); r['cal ARN HND'] = full; await w.runCycle(); assert.equal(sent.length, 2); }); test('min_seats, cabins and max_stops filter', async () => { assert.equal(await setup([watch({ min_seats: 2 })], baseResponses()).w.runCycle(), 0); assert.equal(await setup([watch({ cabins: ['AB'] })], baseResponses()).w.runCycle(), 0); assert.equal(await setup([watch({ max_stops: 0 })], baseResponses()).w.runCycle(), 0); }); test('require_both_legs (the default) and max_nights within one travel window', async () => { const trip = { from: '2026-12-01', to: '2027-01-10', require_both_legs: undefined }; const r = baseResponses(); assert.equal(await setup([watch(trip)], r).w.runCycle(), 0); // no AP seats home r['cal ARN HND'] = calendarPayload([['2026-12-09', { AP: 1 }]], [['2027-01-05', { AP: 2 }]]); r['routes HND ARN 2027-01-05'] = [{ ...ROUTES_ARN_HND_1209[0], flightId: 'SK984-20270105-HND-CPH', departureDate: '2027-01-05', arrivalDate: '2027-01-05', availability: { AP: 2 } }]; const both = setup([watch(trip)], r); assert.equal(await both.w.runCycle(), 3); assert.match(both.sent[0].body, /RET HND→ARN/); assert.equal(await setup([watch({ ...trip, max_nights: 20 })], r).w.runCycle(), 0); // 27 nights }); test('blocked request aborts without committing calendar or notifying', async () => { const r = baseResponses(); r['routes ARN HND 2026-12-09'] = new Blocked('HTTP 403'); const { w, sent, state } = setup([watch()], r); await assert.rejects(w.runCycle(), Blocked); assert.deepEqual(state.calendar, {}); assert.equal(sent.length, 0); }); test('prefilter off fetches every day in both directions', async () => { const { w, calls } = setup([watch({ from: '2026-12-08', to: '2026-12-09' })], baseResponses(), { prefilter: false }); assert.equal(await w.runCycle(), 2); assert.deepEqual(calls, ['routes ARN HND 2026-12-08', 'routes ARN HND 2026-12-09', 'routes HND ARN 2026-12-08', 'routes HND ARN 2026-12-09']); }); test('inbound flights in the window are found too', async () => { const r = baseResponses(); r['cal ARN HND'] = calendarPayload([], [['2026-12-12', { AP: 2 }]]); r['routes HND ARN 2026-12-12'] = [{ ...ROUTES_ARN_HND_1209[0], flightId: 'SK984-20261212-HND-CPH', departureDate: '2026-12-12', arrivalDate: '2026-12-12', availability: { AP: 2 } }]; const { w, sent } = setup([watch()], r); assert.equal(await w.runCycle(), 1); assert.match(sent[0].body, /RET HND→ARN Sat 12 Dec/); }); test('one calendar request serves both directions', async () => { const a = watch({ name: 'a' }); const b = watch({ name: 'b', origins: ['HND'], destinations: ['ARN'], from: '2027-01-01', to: '2027-01-10', cabins: ['AG'] }); const { w, calls } = setup([a, b], baseResponses()); await w.runCycle(); assert.equal(calls.filter((c) => c.startsWith('cal')).length, 1); }); test('config validation', () => { assert.ok(core.validateConfig(core.DEFAULT_CONFIG)); assert.throws(() => core.validateConfig({ watches: [{ origins: 'ARNX', destinations: ['HND'], from: '2026-12-01', to: '2026-11-01' }] }), /"to" is before "from"[\s\S]*not a 3-letter airport code/); assert.throws(() => core.validateConfig({ settings: { min_request_interval: 5 }, watches: [watch()] }), /at least 20 seconds/); assert.throws(() => core.validateConfig({ watches: [watch({ min_nights: 9, max_nights: 5 })] }), /max nights is below min nights/); // table input: comma-separated airports, empty optional numbers, disabled rows dropped const cfg = core.validateConfig({ watches: [ watch({ origins: 'arn, cph', min_nights: '', max_stops: '' }), watch({ name: 'off', enabled: false }), ] }); assert.deepEqual(cfg.watches.map((w) => w.name), ['tokyo']); assert.deepEqual(cfg.watches[0].origins, ['ARN', 'CPH']); assert.equal(cfg.watches[0].min_nights, null); assert.equal(cfg.watches[0].max_stops, null); }); test('old outbound/inbound configs are migrated to one window', () => { const cfg = core.validateConfig({ watches: [{ name: 'old', origins: ['ARN'], destinations: ['HND'], outbound: { from: '2026-12-15', to: '2026-12-23' }, inbound: { from: '2027-01-02', to: '2027-01-10' } }] }); assert.deepEqual(cfg.watches[0].window, { from: '2026-12-15', to: '2027-01-10' }); assert.equal(cfg.watches[0].require_both_legs, true); }); const withSeats = (n) => ROUTES_ARN_HND_1209.map((f) => ({ ...f, availability: { AP: n } })); test('seat drops are tracked against the peak, gone matches stay listed in red', async () => { const r = baseResponses(); r['cal ARN HND'] = calendarPayload([['2026-12-09', { AP: 20 }]], []); r['routes ARN HND 2026-12-09'] = withSeats(10); const { w, state, sent } = setup([watch()], r); await w.runCycle(); r['cal ARN HND'] = calendarPayload([['2026-12-09', { AP: 16 }]], []); // calendar changed -> details refetched r['routes ARN HND 2026-12-09'] = withSeats(8); await w.runCycle(); const e = state.matches.tokyo['outbound|SK1415-20261209-ARN-CPH|AP']; assert.deepEqual([e.status, e.seats, e.peak], ['active', 8, 10]); assert.equal(state.events[0].kind, 'down'); assert.match(state.events[0].line, /SAS Plus ×10 → ×8/); assert.equal(sent.length, 1); // a drop is shown, not alerted r['cal ARN HND'] = calendarPayload([], []); await w.runCycle(); const gone = state.matches.tokyo['outbound|SK1415-20261209-ARN-CPH|AP']; assert.deepEqual([gone.status, gone.seats, gone.peak], ['gone', 8, 10]); assert.equal(state.events[0].kind, 'gone'); r['cal ARN HND'] = calendarPayload([['2026-12-09', { AP: 4 }]], []); r['routes ARN HND 2026-12-09'] = withSeats(2); await w.runCycle(); assert.equal(state.matches.tokyo['outbound|SK1415-20261209-ARN-CPH|AP'].status, 'active'); assert.equal(state.events[0].kind, 'back'); assert.equal(sent.length, 2); // back again -> alerted again }); test('lost matches are forgotten after two weeks', async () => { let t = Date.parse('2026-10-12T12:00:00Z'); const r = baseResponses(); const { w, state } = setup([watch()], r, { now: () => t }); await w.runCycle(); r['cal ARN HND'] = calendarPayload([], []); await w.runCycle(); assert.equal(Object.values(state.matches.tokyo).every((e) => e.status === 'gone'), true); t += 15 * 86400000; await w.runCycle(); assert.deepEqual(state.matches.tokyo, {}); }); test('a leg whose return disappeared is marked unpaired, not gone', async () => { const r = baseResponses(); r['cal ARN HND'] = calendarPayload([['2026-12-09', { AP: 1 }]], [['2026-12-20', { AP: 1 }]]); r['routes HND ARN 2026-12-20'] = [{ ...ROUTES_ARN_HND_1209[0], flightId: 'SK984-20261220-HND-CPH', departureDate: '2026-12-20', arrivalDate: '2026-12-20', availability: { AP: 1 } }]; const { w, state } = setup([watch({ require_both_legs: true })], r); assert.equal(await w.runCycle(), 3); r['cal ARN HND'] = calendarPayload([['2026-12-09', { AP: 1 }]], []); await w.runCycle(); const st = Object.fromEntries(Object.entries(state.matches.tokyo).map(([k, e]) => [k.split('|')[1], e.status])); assert.deepEqual(st, { 'SK1415-20261209-ARN-CPH': 'unpaired', 'SK1417-20261209-ARN-CPH': 'unpaired', 'SK984-20261220-HND-CPH': 'gone' }); });