- A fresh install generates two example watches from ARN: a themed, funny name (Christmas, New Year, getaway weekend, sun escape, ski week, Valentine's, Easter, Midsummer, bucket list), a fitting SAS award destination and dates at the theme's next occurrence; saved on first run. "New random example" regenerates the watches and keeps targets. - Notifications: ntfy and its priority column removed (ntfy entries get a clear validation error); new zulip type for Zulip's Slack-compatible webhook, with one topic per watch - Wider name field Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
273 lines
14 KiB
JavaScript
273 lines
14 KiB
JavaScript
// 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, /Premium ×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, /Premium ×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' });
|
||
});
|
||
|
||
test('cabins are configured by name; old codes and SAS names still work', () => {
|
||
const cabinsOf = (cabins) => core.validateConfig({ watches: [watch({ cabins })] }).watches[0].cabins;
|
||
assert.deepEqual(cabinsOf(['business', 'Economy']), ['AG', 'AB']);
|
||
assert.deepEqual(cabinsOf(['AP', 'go', 'Plus']), ['AG', 'AP']);
|
||
assert.deepEqual(cabinsOf(undefined), ['AG', 'AP', 'AB']);
|
||
assert.throws(() => cabinsOf(['first']), /unknown cabin "first", use economy, premium or business/);
|
||
assert.deepEqual(core.CABINS, { AG: 'Economy', AP: 'Premium', AB: 'Business' });
|
||
});
|
||
|
||
// every award destination SAS listed from ARN on 2026-10-13 (award-finder destinations/v1)
|
||
const SAS_FROM_ARN = new Set(('AGA ALC ALF AMS AYT ATH ATL BCN BRI BGO BER BIO BLL BHX BOO BLQ BOD BOS BRU BUD CTA CHQ ORD DUB DUS EDI FAO ' +
|
||
'FLR FRA FUE FAE GZP GDN GVA GOT HAD HAM HAJ EVE HAU HEL INN KLR KKN KRN KRK KRS KSU CPH LCA LPA LIS LHR LYR LAX LLA LUX LYS FNC ' +
|
||
'MAD AGP MMX MLA MAN RAK MIA MXP LIN BOM MUC NAP EWR JFK NCE OLB OSL PLQ PMO PMI CDG PSA OPO POZ PRG PUY KEF RHO FCO RNB RVN SZG ' +
|
||
'SFO SEA ICN SVQ SFT SPU SVG STR SDL SCR TLL TLV TFS SKG HND YYZ TOS TRD UME VAA VCE VNO VBY WAW IAD VIE WRO ZRH AGH TKU AAL AES ' +
|
||
'AAR OSD').split(' '));
|
||
|
||
const seeded = (seed) => () => ((seed = (seed * 16807) % 2147483647) - 1) / 2147483646;
|
||
|
||
test('example destinations are all SAS award destinations from ARN', () => {
|
||
for (const t of core.THEMES) for (const code of t.places) assert.ok(SAS_FROM_ARN.has(code) && core.PLACES[code], code);
|
||
});
|
||
|
||
test('date rules for yearly themes', () => {
|
||
assert.equal(core.easterSunday(2027), '2027-03-28');
|
||
assert.equal(core.easterSunday(2028), '2028-04-16');
|
||
const win = (i, y) => core.THEMES[i].window(y, '2026-10-13', () => 0);
|
||
assert.deepEqual(win(0, 2026), { from: '2026-12-20', to: '2026-12-30' }); // Christmas
|
||
assert.deepEqual(win(4, 2027), { from: '2027-02-27', to: '2027-03-07' }); // sportlov, ISO week 9
|
||
assert.deepEqual(win(6, 2027), { from: '2027-03-25', to: '2027-03-29' }); // Maundy Thursday -> Easter Monday
|
||
assert.deepEqual(win(7, 2027), { from: '2027-06-24', to: '2027-06-27' }); // around Midsummer Eve, Fri 25 June
|
||
const weekend = core.THEMES[2].window(2026, '2026-10-13', () => 0);
|
||
assert.deepEqual(weekend, { from: '2026-11-06', to: '2026-11-09' }); // first Friday ≥ 3 weeks out, through Monday
|
||
});
|
||
|
||
test('example configs are valid, near-future, themed and different each time', () => {
|
||
const today = '2026-10-13';
|
||
const names = new Set();
|
||
for (let seed = 1; seed <= 200; seed++) {
|
||
const cfg = core.exampleConfig(today, seeded(seed));
|
||
const valid = core.validateConfig(cfg);
|
||
assert.equal(valid.watches.length, 2);
|
||
assert.deepEqual(cfg.notify, []);
|
||
for (const w of cfg.watches) {
|
||
assert.deepEqual(w.origins, ['ARN']);
|
||
assert.ok(w.from >= '2026-10-27' && w.from <= '2027-05-11', `${w.name} ${w.from}`);
|
||
assert.ok(w.to > w.from);
|
||
assert.ok(w.name.includes(core.PLACES[w.destinations[0]]), w.name);
|
||
names.add(w.name);
|
||
}
|
||
assert.notEqual(cfg.watches[0].name, cfg.watches[1].name);
|
||
}
|
||
assert.ok(names.size > 40, `only ${names.size} distinct names`);
|
||
});
|
||
|
||
test('ntfy targets are rejected with a clear message; zulip is accepted', () => {
|
||
assert.throws(() => core.validateConfig({ notify: [{ type: 'ntfy', url: 'https://ntfy.sh/x' }], watches: [watch()] }),
|
||
/ntfy is no longer supported/);
|
||
assert.equal(core.validateConfig({ notify: [{ type: 'zulip', url: 'https://x.zulipchat.com/api/v1/external/slack_incoming?api_key=k&stream=s' }],
|
||
watches: [watch()] }).notify[0].type, 'zulip');
|
||
});
|