Files
sas-bonus-watch/sas-bonus-watch.test.cjs
T
Måns SivertssonandClaude Opus 5.5 f1acd53a9c SAS bonus watch userscript
Tampermonkey/Violentmonkey userscript that watches SAS EuroBonus award
availability from a sas.se tab and notifies via ntfy, Discord, Slack,
webhook or desktop when matching seats appear. Moved here from
sas-bonus-finder; update URLs point at this public repo.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 16:06:40 +02:00

212 lines
10 KiB
JavaScript
Raw 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, /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' });
});