Files
sas-bonus-watch/sas-bonus-watch.test.cjs
Måns SivertssonandClaude Opus 5.5 9caff094ac Export, Import and Reset; JSON editor removed
- Export downloads one file with the saved config, all match data and
  history, the flight cache and view settings
- Import restores such a file after a confirmation; older config-only
  .json files are accepted and keep existing match data
- Reset (with a warning) deletes everything and starts over with new
  random example watches; replaces "Reset state"
- The JSON config section is gone
- Old ntfy targets no longer stop the script: they are ignored at
  runtime and on import, shown as "no longer supported" in the editor

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 09:08:47 +02:00

293 lines
15 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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');
});
test('import reads full exports and older config-only backups, and rejects junk', () => {
const config = { notify: [], watches: [watch()] };
const state = { matches: { tokyo: { a: {}, b: {} } }, events: [] };
const full = core.parseBackup(JSON.stringify({ app: 'sas-bonus-watch', format: 1, exported_at: '2026-10-13T08:00:00Z',
data: { config, state, collapsed: { tokyo: true }, matchSort: 'newest' } }));
assert.equal(full.full, true);
assert.equal(full.tracked, 2);
assert.deepEqual(full.state, state);
assert.deepEqual(full.prefs, { collapsed: { tokyo: true }, matchSort: 'newest' });
const old = core.parseBackup(JSON.stringify(config));
assert.deepEqual([old.full, old.config], [false, config]);
assert.throws(() => core.parseBackup('{oops'), /not valid JSON/);
assert.throws(() => core.parseBackup('{"hello": 1}'), /not a SAS bonus watch export/);
assert.throws(() => core.parseBackup(JSON.stringify({ app: 'sas-bonus-watch', data: { state } })), /no config/);
assert.throws(() => core.parseBackup(JSON.stringify({ app: 'sas-bonus-watch', data: { config: { watches: [{ origins: 'X' }] } } })),
/not a 3-letter airport code/);
});