mirror of
https://github.com/aculix/ticket-monitoring.git
synced 2026-10-27 07:15:51 +00:00
BookMyShow sits behind Cloudflare bot management that rejects curl and Node fetch with 403 on the TLS fingerprint alone, so no header or cookie copying helps and a proxy makes no difference. impit performs the request with a real Chrome fingerprint; it ships musl and arm64 binaries, so the Alpine multi-arch image is unaffected. BMS has no 204 either: a date that is not on sale returns 200 with no showtimeWidgets key, so 'closed' is read from the payload. Sites now live in src/providers/, each exposing check() and extract() and returning one normalized session shape. State is namespaced per site, so each keeps its own dedupe and failure tracking and neither can silence the other. Alert titles name the site. Sessions without exact seat counts render without them, which BMS needs. Old flat District variable names still work. CI now starts the built image and runs a real check through both providers before publishing, since a broken native binary only fails at runtime.
193 lines
9.5 KiB
JavaScript
193 lines
9.5 KiB
JavaScript
import { test } from "node:test";
|
|
import assert from "node:assert/strict";
|
|
import {
|
|
decideProvider, decideGlobal, defaultProviderState, defaultState, providerState, heartbeatDue,
|
|
} from "../src/monitor.js";
|
|
|
|
const CFG = {
|
|
targetDate: "2026-09-25",
|
|
expiryUtc: "2026-09-25T18:30:00Z",
|
|
bookingUrl: "https://example.com/book",
|
|
failureThreshold: 6,
|
|
tzOffsetMinutes: 330,
|
|
heartbeatHour: 9,
|
|
formatLabel: "IMAX",
|
|
currency: "₹",
|
|
};
|
|
const P = { key: "district", label: "District", bookingUrl: "https://example.com/d" };
|
|
const P2 = { key: "bookmyshow", label: "BookMyShow", bookingUrl: "https://example.com/b" };
|
|
|
|
const T0 = new Date("2026-09-20T02:00:00Z"); // before the 03:30 UTC (09:00 IST) heartbeat window
|
|
const HB = new Date("2026-09-20T03:31:00Z"); // inside it
|
|
const openInput = (extract, now = T0) => ({ kind: "open", extract, now });
|
|
|
|
const EX_MATCH = {
|
|
matched: [{ sid: "111", cinema: "Test Cinema", time: "9:00 AM", audi: "SCREEN 9", format: "IMAX", avail: 100, total: 300, tiers: "CLASSIC ₹260 (66/66)" }],
|
|
other: [], cinemaCount: 3, sessionCount: 7, showDates: [],
|
|
};
|
|
const EX_NO_MATCH = { matched: [], other: [], cinemaCount: 3, sessionCount: 7, showDates: [] };
|
|
const EX_EMPTY = { matched: [], other: [], cinemaCount: 0, sessionCount: 0, showDates: [] };
|
|
|
|
/** Mirrors the runner: merge each alert's patch only after its push succeeds. */
|
|
function apply(pstate, decision) {
|
|
let merged = decision.baseState;
|
|
for (const a of decision.alerts) merged = { ...merged, ...a.statePatch };
|
|
return merged;
|
|
}
|
|
|
|
test("closed steady state produces no alerts and no state change", () => {
|
|
const s1 = apply(defaultProviderState(), decideProvider(defaultProviderState(), { kind: "closed", now: T0 }, CFG, P));
|
|
const d2 = decideProvider(s1, { kind: "closed", now: new Date(T0.getTime() + 60000) }, CFG, P);
|
|
assert.equal(d2.alerts.length, 0);
|
|
assert.equal(JSON.stringify(apply(s1, d2)), JSON.stringify(s1), "no state write when nothing changed");
|
|
});
|
|
|
|
test("a match fires a max-priority alert once, re-firing only for new session ids", () => {
|
|
const d1 = decideProvider(defaultProviderState(), openInput(EX_MATCH), CFG, P);
|
|
assert.equal(d1.alerts.length, 1);
|
|
assert.equal(d1.alerts[0].priority, "5");
|
|
assert.equal(d1.alerts[0].provider, "district", "alerts carry their provider");
|
|
assert.ok(d1.alerts[0].title.includes("District"), "the site is named in the title");
|
|
assert.ok(d1.alerts[0].statePatch.dateOpenAlerted, "first open response sets dateOpenAlerted");
|
|
assert.ok(d1.alerts[0].statePatch.sidsAlerted["111"]);
|
|
|
|
const s1 = apply(defaultProviderState(), d1);
|
|
assert.equal(decideProvider(s1, openInput(EX_MATCH), CFG, P).alerts.length, 0, "deduped");
|
|
|
|
const withNew = { ...EX_MATCH, matched: [...EX_MATCH.matched, { ...EX_MATCH.matched[0], sid: "222", time: "1:15 PM" }] };
|
|
const d3 = decideProvider(s1, openInput(withNew), CFG, P);
|
|
assert.equal(d3.alerts.length, 1, "new session id re-alerts");
|
|
assert.ok(d3.alerts[0].body.includes("1:15 PM"));
|
|
assert.ok(!d3.alerts[0].body.includes("9:00 AM"), "already-alerted session not repeated");
|
|
});
|
|
|
|
test("sessions without seat counts still render (BookMyShow shape)", () => {
|
|
const bmsLike = {
|
|
matched: [{ sid: "37929", cinema: "PVR: Palladium", time: "08:00 AM", audi: "", format: "IMAX", avail: null, total: null, tiers: "CLASSIC ₹470 (AVAILABLE)" }],
|
|
other: [], cinemaCount: 1, sessionCount: 5, showDates: [],
|
|
};
|
|
const d = decideProvider(defaultProviderState(), openInput(bmsLike), CFG, P2);
|
|
const body = d.alerts[0].body;
|
|
assert.ok(body.includes("08:00 AM | PVR: Palladium | IMAX"), body);
|
|
assert.ok(!body.includes("seats"), "no seat counts when the provider has none");
|
|
assert.ok(d.alerts[0].title.includes("BookMyShow"));
|
|
});
|
|
|
|
test("sessions at other venues ride along as secondary lines, untracked", () => {
|
|
const withOther = {
|
|
...EX_MATCH,
|
|
other: [{ sid: "999", cinema: "Other Cinema", time: "6:00 PM", audi: "", format: "IMAX", avail: null, total: null, tiers: "" }],
|
|
};
|
|
const d = decideProvider(defaultProviderState(), openInput(withOther), CFG, P);
|
|
assert.equal(d.alerts.length, 1, "no separate push for other venues");
|
|
assert.ok(d.alerts[0].body.includes("Also at Other Cinema: 6:00 PM"));
|
|
assert.ok(!d.alerts[0].statePatch.sidsAlerted["999"], "other-venue ids are not dedupe-tracked");
|
|
});
|
|
|
|
test("date open without a match fires once; a later match still fires", () => {
|
|
const d1 = decideProvider(defaultProviderState(), openInput(EX_NO_MATCH), CFG, P);
|
|
assert.equal(d1.alerts.length, 1);
|
|
assert.equal(d1.alerts[0].priority, "4");
|
|
const s1 = apply(defaultProviderState(), d1);
|
|
assert.equal(decideProvider(s1, openInput(EX_NO_MATCH), CFG, P).alerts.length, 0);
|
|
const d3 = decideProvider(s1, openInput(EX_MATCH), CFG, P);
|
|
assert.equal(d3.alerts.length, 1);
|
|
assert.equal(d3.alerts[0].priority, "5");
|
|
});
|
|
|
|
test("failed match push then sessions vanish -> date-open alert still reachable", () => {
|
|
const d1 = decideProvider(defaultProviderState(), openInput(EX_MATCH), CFG, P);
|
|
const s1 = d1.baseState; // push failed: statePatch not merged
|
|
const d2 = decideProvider(s1, openInput(EX_NO_MATCH), CFG, P);
|
|
assert.equal(d2.alerts.length, 1);
|
|
assert.equal(d2.alerts[0].priority, "4", "user still learns the date opened");
|
|
});
|
|
|
|
test("open-but-empty counts as closed", () => {
|
|
const d = decideProvider(defaultProviderState(), openInput(EX_EMPTY), CFG, P);
|
|
assert.equal(d.alerts.length, 0);
|
|
assert.equal(d.baseState.lastGood.kind, "closed");
|
|
});
|
|
|
|
test("BROKEN fires at the threshold, once, and names the site; RECOVERED on next success", () => {
|
|
let s = defaultProviderState();
|
|
for (let i = 1; i <= 5; i++) {
|
|
const d = decideProvider(s, { kind: "error", reason: "HTTP 403", now: T0 }, CFG, P2);
|
|
assert.equal(d.alerts.length, 0, `no alert at failure ${i}`);
|
|
s = apply(s, d);
|
|
assert.equal(s.failures, i);
|
|
}
|
|
const d6 = decideProvider(s, { kind: "error", reason: "HTTP 403", now: T0 }, CFG, P2);
|
|
assert.equal(d6.alerts.length, 1);
|
|
assert.ok(d6.alerts[0].title.includes("BookMyShow"));
|
|
assert.ok(d6.alerts[0].title.includes("BROKEN"));
|
|
s = apply(s, d6);
|
|
assert.equal(decideProvider(s, { kind: "error", reason: "HTTP 403", now: T0 }, CFG, P2).alerts.length, 0, "not repeated");
|
|
const d8 = decideProvider(s, { kind: "closed", now: T0 }, CFG, P2);
|
|
assert.equal(d8.alerts.length, 1);
|
|
assert.ok(d8.alerts[0].title.includes("recovered"));
|
|
s = apply(s, d8);
|
|
assert.equal(s.failures, 0);
|
|
assert.equal(s.brokenAlerted, false);
|
|
});
|
|
|
|
test("providers keep independent dedupe state", () => {
|
|
const state = defaultState();
|
|
const dA = decideProvider(providerState(state, "district"), openInput(EX_MATCH), CFG, P);
|
|
state.providers.district = apply(providerState(state, "district"), dA);
|
|
// BookMyShow has never alerted, so the same session id must still notify there.
|
|
const dB = decideProvider(providerState(state, "bookmyshow"), openInput(EX_MATCH), CFG, P2);
|
|
assert.equal(dB.alerts.length, 1, "one site alerting must not silence the other");
|
|
assert.ok(dB.alerts[0].title.includes("BookMyShow"));
|
|
assert.equal(decideProvider(providerState(state, "district"), openInput(EX_MATCH), CFG, P).alerts.length, 0);
|
|
});
|
|
|
|
test("providerState defaults unknown providers and preserves stored ones", () => {
|
|
const state = { ...defaultState(), providers: { district: { failures: 3 } } };
|
|
assert.equal(providerState(state, "district").failures, 3);
|
|
assert.equal(providerState(state, "district").brokenAlerted, false, "missing fields are defaulted");
|
|
assert.deepEqual(providerState(state, "bookmyshow"), defaultProviderState());
|
|
});
|
|
|
|
// ---------------------------------------------------------- global alerts
|
|
|
|
test("heartbeat fires once per local day, summarises every provider, and can be disabled", () => {
|
|
const summaries = [
|
|
{ label: "District", kind: "closed", showDatesMax: "2026-09-20" },
|
|
{ label: "BookMyShow", kind: "closed" },
|
|
];
|
|
assert.equal(decideGlobal(defaultState(), CFG, new Date("2026-09-20T03:29:00Z"), summaries).alerts.length, 0, "before the window");
|
|
|
|
const d = decideGlobal(defaultState(), CFG, HB, summaries);
|
|
assert.equal(d.alerts.length, 1);
|
|
assert.equal(d.alerts[0].priority, "1");
|
|
assert.ok(d.alerts[0].body.includes("District: closed (open through 2026-09-20)"));
|
|
assert.ok(d.alerts[0].body.includes("BookMyShow: closed"));
|
|
assert.equal(d.alerts[0].statePatch.hbDate, "2026-09-20");
|
|
|
|
const after = { ...defaultState(), hbDate: "2026-09-20" };
|
|
assert.equal(decideGlobal(after, CFG, new Date("2026-09-20T09:00:00Z"), summaries).alerts.length, 0, "once per day");
|
|
assert.equal(decideGlobal(defaultState(), { ...CFG, heartbeatHour: -1 }, HB, summaries).alerts.length, 0, "disabled");
|
|
});
|
|
|
|
test("heartbeatDue tracks the local day, so it is not fooled by UTC rollover", () => {
|
|
assert.equal(heartbeatDue({ hbDate: "" }, HB, CFG), true);
|
|
assert.equal(heartbeatDue({ hbDate: "2026-09-20" }, HB, CFG), false);
|
|
});
|
|
|
|
test("retirement: one alert at expiry, then a permanent no-op", () => {
|
|
const past = new Date("2026-09-25T18:31:00Z");
|
|
const d1 = decideGlobal(defaultState(), CFG, past, []);
|
|
assert.equal(d1.alerts.length, 1);
|
|
assert.ok(d1.alerts[0].statePatch.retired);
|
|
const s1 = { ...defaultState(), ...d1.alerts[0].statePatch };
|
|
assert.equal(decideGlobal(s1, CFG, past, []).alerts.length, 0);
|
|
});
|
|
|
|
test("retirement takes precedence over the heartbeat", () => {
|
|
const past = new Date("2026-09-26T03:31:00Z"); // after expiry and inside the heartbeat window
|
|
const d = decideGlobal(defaultState(), CFG, past, []);
|
|
assert.equal(d.alerts.length, 1);
|
|
assert.ok(d.alerts[0].title.includes("retired"));
|
|
});
|