mirror of
https://github.com/aculix/ticket-monitoring.git
synced 2026-10-27 07:15:51 +00:00
feat: watch BookMyShow alongside District in one process
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.
This commit is contained in:
1 parent
00e6852863
commit
608221fe18
17 files changed
+1188
-539
No files matched your search
Vendored
+1
@@ -0,0 +1 @@
|
||||
{"data":{"header":{"title":{"styleId":"header-title-v2","text":"Avengers Endgame: Encore"},"subtitle":{"styleId":"screen-subtitle","text":"Movie runtime: 3h 5m"},"startActions":[{"iconUrl":"https://assets-in.bmscdn.com/moviesmaster/movies-showtimes/v4/common/left-chevron.png","cta":{"type":"closeScreen","analytics":{"event_action":"back","event_name":"showtime_actions","event_type":"click"}}}],"endActions":[{"iconUrl":"https://assets-in.bmscdn.com/moviesmaster/movies-showtimes/v4/common/search.png","cta":{"type":"searchView","analytics":{"event_action":"search","event_name":"showtime_actions","event_type":"click"}}}],"backgroundColor":"#FFFFFF","searchBackgroundColor":"#FFFFFF","searchForegroundColor":"#000000","additionalData":{"genre":"Action|Adventure|Fantasy"}},"additionalData":{},"addOnWidgets":{}},"metadata":{"error":{"title":"Uh-oh! We couldn't find anything.","message":"There are no shows available in this region.","errorCode":"1a.1b.2001","imageUrl":"https://assets-in.bmscdn.com/moviesmaster/movies-showtimes/v4/errors/no-showtimes-found-eventcode.png","action":{"label":"Change Location","backgroundColor":"#EB4E62","textColor":"#FFFFFF","cta":{"type":"changeLocation"}}}}}
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
{"data":{"meta":{"version":23},"header":{"title":{"styleId":"header-title-v2","text":"Toxic: A Fairy Tale for Grown-ups"},"subtitle":{"styleId":"screen-subtitle","text":"Movie runtime: 3h 14m"},"startActions":[{"iconUrl":"https://assets-in.bmscdn.com/moviesmaster/movies-showtimes/v4/common/left-chevron.png","cta":{"type":"closeScreen","analytics":{"event_action":"back","event_name":"showtime_actions","event_type":"click"}}}],"endActions":[{"iconUrl":"https://assets-in.bmscdn.com/moviesmaster/movies-showtimes/v4/common/search.png","cta":{"type":"searchView","analytics":{"event_action":"search","event_name":"showtime_actions","event_type":"click"}}},{"iconUrl":"https://assets-in.bmscdn.com/moviesmaster/movies-showtimes/v4/common/filter.png","cta":{"type":"bottomsheet","id":"filter","analytics":{"event_action":"filter_icon","event_name":"showtime_actions","event_type":"click"}}}],"backgroundColor":"#FFFFFF","searchBackgroundColor":"#FFFFFF","searchForegroundColor":"#000000","additionalData":{"eventCensor":"A","genre":"Action|Crime|Period|Thriller"}},"bottomSheetData":{"filter":{"type":"filter","widgets":[{"type":"vertical-text-list","data":[{"text":"Filters","styleId":"bottomsheet-title"}]},{"type":"chip-list","text":"Price Range","data":[{"type":"chip","title":"₹0 - ₹500","cta":{"type":"selectFilter","analytics":{"category":"price","event_action":"bottom_filter","event_name":"showtime_actions","event_type":"click","label":"0-500"},"additionalData":{"filterID":"pf5","filterLevel":"both","categoryId":"price"}}},{"type":"chip","title":"₹501 - ₹600","cta":{"type":"selectFilter","analytics":{"category":"price","event_action":"bottom_filter","event_name":"showtime_actions","event_type":"click","label":"501-600"},"additionalData":{"filterID":"pf6","filterLevel":"both","categoryId":"price"}}},{"type":"chip","title":"₹601 - ₹700","cta":{"type":"selectFilter","analytics":{"category":"price","event_action":"bottom_filter","event_name":"showtime_actions","event_type":"click","label":"601-700"},"additionalData":{"filterID":"pf7","filterLevel":"both","categoryId":"price"}}},{"type":"chip","title":"₹701 - ₹800","cta":{"type":"selectFilter","analytics":{"category":"price","event_action":"bottom_filter","event_name":"showtime_actions","event_type":"click","label":"701-800"},"additionalData":{"filterID":"pf8","filterLevel":"both","categoryId":"price"}}},{"type":"chip","title":"₹801 - ₹900","cta":{"type":"selectFilter","analytics":{"category":"price","event_action":"bottom_filter","event_name":"showtime_actions","event_type":"click","label":"801-900"},"additionalData":{"filterID":"pf9","filterLevel":"both","categoryId":"price"}}},{"type":"chip","title":"₹901 - ₹1000","cta":{"type":"selectFilter","analytics":{"category":"price","event_action":"bottom_filter","event_name":"showtime_actions","event_type":"click","label":"901-1000"},"additionalData":{"filterID":"pf91","filterLevel":"both","categoryId":"price"}}},{"type":"chip","title":"₹1001 - ₹1200","cta":{"type":"selectFilter","analytics":{"category":"price","event_action":"bottom_filter","event_name":"showtime_actions","event_type":"click","label":"1001-1200"},"additionalData":{"filterID":"pf93","filterLevel":"both","categoryId":"price"}}}]},{"type":"chip-list","text":"Special Formats","secondaryText":"No special formats available in your region"},{"type":"chip-list","text":"Other Filters","data":[{"type":"chip","title":"Cancellable","cta":{"type":"selectFilter","analytics":{"category":"cancel","event_action":"bottom_filter","event_name":"showtime_actions","event_type":"click","label":"Cancellable"},"additionalData":{"filterID":"cancellation","filterLevel":"parent","categoryId":"cancellation"}}}]},{"type":"multi-selector","text":"Preferred Time","data":[{"title":{"styleId":"filter-title","text":"Morning"},"subtitle":{"styleId":"filter-subtitle","text":"12:00 AM - 11:59 AM"},"iconUrl":"https://assets-in.bmscdn.com/moviesmaster/movies-showtimes/v4/common/morning.png","cta":{"type":"selectFilter","analytics":{"category":"time","event_action":"bottom_filter","event_name":"showtime_actions","event_type":"click","label":"morning"},"additionalData":{"filterID":"tf1","filterLevel":"both","categoryId":"time"}}},{"title":{"styleId":"filter-title","text":"Afternoon"},"subtitle":{"styleId":"filter-subtitle","text":"12:00 PM - 3:59 PM"},"iconUrl":"https://assets-in.bmscdn.com/moviesmaster/movies-showtimes/v4/common/afternoon.png","cta":{"type":"selectFilter","analytics":{"category":"time","event_action":"bottom_filter","event_name":"showtime_actions","event_type":"click","label":"afternoon"},"additionalData":{"filterID":"tf2","filterLevel":"both","categoryId":"time"}}},{"title":{"styleId":"filter-title-disabled","text":"Evening"},"subtitle":{"styleId":"filter-subtitle-disabled","text":"4:00 PM - 6:59 PM"},"iconUrl":"https://assets-in.bmscdn.com/moviesmaster/movies-showtimes/v4/common/evening-disabled.png"},{"title":{"styleId":"filter-title","text":"Night"},"subtiLine truncated
|
||||
+101
-123
@@ -1,165 +1,129 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { guestToken, toLocalTime, extractSessions, decide, defaultState } from "../src/monitor.js";
|
||||
import {
|
||||
decideProvider, decideGlobal, defaultProviderState, defaultState, providerState, heartbeatDue,
|
||||
} from "../src/monitor.js";
|
||||
|
||||
const fixture = JSON.parse(readFileSync(
|
||||
fileURLToPath(new URL("./fixtures/response-open-date.json", import.meta.url)), "utf8"));
|
||||
|
||||
// Matches the sample in .env.example: IMAX at one specific cinema, IST.
|
||||
const CFG = {
|
||||
targetDate: "2026-08-10",
|
||||
expiryUtc: "2026-08-10T18:30:00Z",
|
||||
targetDate: "2026-09-25",
|
||||
expiryUtc: "2026-09-25T18:30:00Z",
|
||||
bookingUrl: "https://example.com/book",
|
||||
failureThreshold: 6,
|
||||
tzOffsetMinutes: 330,
|
||||
heartbeatHour: 9,
|
||||
formatTag: "imax_2d",
|
||||
formatMatch: "IMAX",
|
||||
formatLabel: "IMAX",
|
||||
cinemaId: "1032362",
|
||||
currency: "₹",
|
||||
};
|
||||
const P = { key: "district", label: "District", bookingUrl: "https://example.com/d" };
|
||||
const P2 = { key: "bookmyshow", label: "BookMyShow", bookingUrl: "https://example.com/b" };
|
||||
|
||||
test("guestToken matches District's client-generated shape", () => {
|
||||
const t = guestToken();
|
||||
assert.match(t, /^\d{13}_\d{18}_[a-z0-9]{11}$/);
|
||||
assert.notEqual(guestToken(), t, "fresh token per call");
|
||||
});
|
||||
|
||||
test("toLocalTime converts API UTC showTime to local clock time", () => {
|
||||
assert.equal(toLocalTime("2026-08-05T03:30", 330), "9:00 AM"); // verified against the site UI
|
||||
assert.equal(toLocalTime("2026-08-05T06:30", 330), "12:00 PM"); // noon boundary
|
||||
assert.equal(toLocalTime("2026-08-05T12:00", 330), "5:30 PM");
|
||||
assert.equal(toLocalTime("2026-08-05T18:45", 330), "12:15 AM"); // past midnight, clock time only
|
||||
assert.equal(toLocalTime("2026-08-05T03:30:00", 330), "9:00 AM"); // seconds already present
|
||||
assert.equal(toLocalTime("2026-08-05T03:30", 0), "3:30 AM"); // other timezones
|
||||
});
|
||||
|
||||
test("extractSessions finds the target cinema's sessions in a real capture", () => {
|
||||
const ex = extractSessions(fixture, CFG);
|
||||
assert.equal(ex.cinemaCount, 5);
|
||||
assert.ok(ex.sessionCount > ex.matched.length);
|
||||
assert.equal(ex.matched.length, 4);
|
||||
assert.equal(ex.other.length, 0);
|
||||
const s = ex.matched[0];
|
||||
assert.equal(s.sid, "36874");
|
||||
assert.equal(s.cinema, "PVR Palladium Mall, Thaltej, Ahmedabad");
|
||||
assert.equal(s.time, "9:00 AM");
|
||||
assert.equal(s.audi, "SCREEN 9");
|
||||
assert.ok(s.tiers.includes("CLASSIC ₹260"));
|
||||
assert.ok(s.tiers.includes("RECLINER ₹640"));
|
||||
assert.equal(ex.showDates.at(-1), "2026-08-09");
|
||||
});
|
||||
|
||||
test("extractSessions: no cinemaId watches every cinema; no format filter matches all", () => {
|
||||
const anyCinema = extractSessions(fixture, { ...CFG, cinemaId: "" });
|
||||
assert.equal(anyCinema.matched.length, 4, "still only IMAX sessions");
|
||||
assert.equal(anyCinema.other.length, 0, "nothing is 'other' when no cinema is targeted");
|
||||
|
||||
const anyFormat = extractSessions(fixture, { ...CFG, cinemaId: "", formatTag: "", formatMatch: "" });
|
||||
assert.equal(anyFormat.matched.length, anyFormat.sessionCount, "every session matches");
|
||||
|
||||
const otherCinema = extractSessions(fixture, { ...CFG, cinemaId: "125" });
|
||||
assert.equal(otherCinema.matched.length, 0);
|
||||
assert.equal(otherCinema.other.length, 4, "IMAX elsewhere becomes a secondary line");
|
||||
});
|
||||
|
||||
test("extractSessions tolerates empty/malformed payloads", () => {
|
||||
assert.deepEqual(extractSessions({}, CFG),
|
||||
{ matched: [], other: [], cinemaCount: 0, sessionCount: 0, showDates: [] });
|
||||
});
|
||||
|
||||
const T0 = new Date("2026-08-06T02:00:00Z"); // before the 03:30 UTC (09:00 IST) heartbeat window
|
||||
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", avail: 100, total: 300, tiers: "CLASSIC ₹260 (66/66)" }],
|
||||
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(state, decision) {
|
||||
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(defaultState(), decide(defaultState(), { kind: "closed", now: T0 }, CFG));
|
||||
const d2 = decide(s1, { kind: "closed", now: new Date(T0.getTime() + 60000) }, CFG);
|
||||
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 = decide(defaultState(), openInput(EX_MATCH), CFG);
|
||||
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(defaultState(), d1);
|
||||
assert.equal(decide(s1, openInput(EX_MATCH), CFG).alerts.length, 0, "deduped");
|
||||
|
||||
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 = decide(s1, openInput(withNew), CFG);
|
||||
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 at other cinemas ride along as secondary lines, untracked", () => {
|
||||
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: "", avail: 50, total: 100, tiers: "CLASSIC ₹300 (50/100)" }],
|
||||
other: [{ sid: "999", cinema: "Other Cinema", time: "6:00 PM", audi: "", format: "IMAX", avail: null, total: null, tiers: "" }],
|
||||
};
|
||||
const d = decide(defaultState(), openInput(withOther), CFG);
|
||||
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 DATE_OPEN once; a later match still fires", () => {
|
||||
const d1 = decide(defaultState(), openInput(EX_NO_MATCH), CFG);
|
||||
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(defaultState(), d1);
|
||||
assert.equal(decide(s1, openInput(EX_NO_MATCH), CFG).alerts.length, 0);
|
||||
const d3 = decide(s1, openInput(EX_MATCH), CFG);
|
||||
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 still reachable", () => {
|
||||
const d1 = decide(defaultState(), openInput(EX_MATCH), CFG);
|
||||
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 = decide(s1, openInput(EX_NO_MATCH), CFG);
|
||||
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 = decide(defaultState(), openInput(EX_EMPTY), CFG);
|
||||
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; RECOVERED on the next success", () => {
|
||||
let s = defaultState();
|
||||
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 = decide(s, { kind: "error", reason: "HTTP 403", now: T0 }, CFG);
|
||||
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 = decide(s, { kind: "error", reason: "HTTP 403", now: T0 }, CFG);
|
||||
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(decide(s, { kind: "error", reason: "HTTP 403", now: T0 }, CFG).alerts.length, 0, "not repeated");
|
||||
const d8 = decide(s, { kind: "closed", now: T0 }, CFG);
|
||||
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);
|
||||
@@ -167,48 +131,62 @@ test("BROKEN fires at the threshold, once; RECOVERED on the next success", () =>
|
||||
assert.equal(s.brokenAlerted, false);
|
||||
});
|
||||
|
||||
test("heartbeat fires once per local day, never on error ticks, and can be disabled", () => {
|
||||
const hbTime = new Date("2026-08-06T03:31:00Z"); // 09:01 IST
|
||||
const early = new Date("2026-08-06T03:29:00Z");
|
||||
assert.equal(decide(defaultState(), { kind: "closed", now: early }, CFG).alerts.length, 0);
|
||||
|
||||
const d1 = decide(defaultState(), { kind: "closed", now: hbTime }, CFG);
|
||||
assert.equal(d1.alerts.length, 1);
|
||||
assert.equal(d1.alerts[0].priority, "1");
|
||||
assert.equal(d1.alerts[0].statePatch.hbDate, "2026-08-06");
|
||||
|
||||
const s1 = apply(defaultState(), d1);
|
||||
assert.equal(decide(s1, { kind: "closed", now: new Date("2026-08-06T09:00:00Z") }, CFG).alerts.length, 0, "once per day");
|
||||
assert.equal(decide(defaultState(), { kind: "error", reason: "x", now: hbTime }, CFG).alerts.length, 0, "not on errors");
|
||||
assert.equal(decide(defaultState(), { kind: "closed", now: hbTime }, { ...CFG, heartbeatHour: -1 }).alerts.length, 0, "disabled");
|
||||
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("heartbeat reports the probe-refreshed showDates maximum", () => {
|
||||
const hbTime = new Date("2026-08-06T03:31:00Z");
|
||||
const d = decide(defaultState(), { kind: "closed", now: hbTime, probeMax: "2026-08-11" }, CFG);
|
||||
assert.equal(d.baseState.lastGood.showDatesMax, "2026-08-11", "probe result rides baseState");
|
||||
assert.ok(d.alerts[0].body.includes("2026-08-11"));
|
||||
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());
|
||||
});
|
||||
|
||||
test("recovery, match and heartbeat can co-fire with disjoint patches", () => {
|
||||
const hbTime = new Date("2026-08-06T03:31:00Z");
|
||||
const s0 = { ...defaultState(), brokenAlerted: true, failures: 6 };
|
||||
const d = decide(s0, openInput(EX_MATCH, hbTime), CFG);
|
||||
assert.deepEqual(d.alerts.map((a) => a.priority), ["3", "5", "1"]);
|
||||
assert.deepEqual(d.alerts.map((a) => Object.keys(a.statePatch)),
|
||||
[["brokenAlerted"], ["sidsAlerted", "dateOpenAlerted"], ["hbDate"]]);
|
||||
const merged = apply(s0, d);
|
||||
assert.equal(merged.brokenAlerted, false);
|
||||
assert.equal(merged.failures, 0);
|
||||
assert.ok(merged.sidsAlerted["111"]);
|
||||
assert.equal(merged.hbDate, "2026-08-06");
|
||||
// ---------------------------------------------------------- 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-08-10T18:31:00Z");
|
||||
const d1 = decide(defaultState(), { kind: "closed", now: past }, CFG);
|
||||
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 = apply(defaultState(), d1);
|
||||
assert.equal(decide(s1, { kind: "closed", now: past }, CFG).alerts.length, 0);
|
||||
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"));
|
||||
});
|
||||
@@ -0,0 +1,106 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { toLocalTime, localDate } from "../src/lib.js";
|
||||
import * as district from "../src/providers/district.js";
|
||||
import * as bms from "../src/providers/bookmyshow.js";
|
||||
|
||||
const load = (f) => JSON.parse(readFileSync(fileURLToPath(new URL(`./fixtures/${f}`, import.meta.url)), "utf8"));
|
||||
const CFG = { tzOffsetMinutes: 330, currency: "₹" };
|
||||
|
||||
test("toLocalTime converts API UTC timestamps to local clock time", () => {
|
||||
assert.equal(toLocalTime("2026-08-05T03:30", 330), "9:00 AM"); // verified against the site UI
|
||||
assert.equal(toLocalTime("2026-08-05T06:30", 330), "12:00 PM"); // noon boundary
|
||||
assert.equal(toLocalTime("2026-08-05T12:00", 330), "5:30 PM");
|
||||
assert.equal(toLocalTime("2026-08-05T18:45", 330), "12:15 AM"); // past midnight, clock time only
|
||||
assert.equal(toLocalTime("2026-08-05T03:30:00", 330), "9:00 AM"); // seconds already present
|
||||
assert.equal(toLocalTime("2026-08-05T03:30", 0), "3:30 AM"); // other timezones
|
||||
});
|
||||
|
||||
test("localDate rolls over at local midnight, not UTC midnight", () => {
|
||||
assert.equal(localDate(new Date("2026-08-05T19:00:00Z"), 330), "2026-08-06");
|
||||
assert.equal(localDate(new Date("2026-08-05T17:00:00Z"), 330), "2026-08-05");
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------- District
|
||||
|
||||
const D_PARAMS = { venueId: "1032362", formatTag: "imax_2d", formatMatch: "IMAX" };
|
||||
|
||||
test("district: extracts the target cinema's sessions from a real capture", () => {
|
||||
const ex = district.extract(load("response-open-date.json"), CFG, D_PARAMS);
|
||||
assert.equal(ex.cinemaCount, 5);
|
||||
assert.ok(ex.sessionCount > ex.matched.length);
|
||||
assert.equal(ex.matched.length, 4);
|
||||
assert.equal(ex.other.length, 0);
|
||||
const s = ex.matched[0];
|
||||
assert.equal(s.sid, "36874");
|
||||
assert.equal(s.cinema, "PVR Palladium Mall, Thaltej, Ahmedabad");
|
||||
assert.equal(s.time, "9:00 AM");
|
||||
assert.equal(s.audi, "SCREEN 9");
|
||||
assert.equal(s.avail, 103); // District reports exact seat counts
|
||||
assert.ok(s.tiers.includes("CLASSIC ₹260"));
|
||||
assert.ok(s.tiers.includes("RECLINER ₹640"));
|
||||
assert.equal(ex.showDates.at(-1), "2026-08-09");
|
||||
});
|
||||
|
||||
test("district: no venue watches every cinema, no format filter matches everything", () => {
|
||||
const data = load("response-open-date.json");
|
||||
const anyVenue = district.extract(data, CFG, { ...D_PARAMS, venueId: "" });
|
||||
assert.equal(anyVenue.matched.length, 4, "still only IMAX");
|
||||
assert.equal(anyVenue.other.length, 0);
|
||||
|
||||
const anyFormat = district.extract(data, CFG, { venueId: "", formatTag: "", formatMatch: "" });
|
||||
assert.equal(anyFormat.matched.length, anyFormat.sessionCount);
|
||||
|
||||
const elsewhere = district.extract(data, CFG, { ...D_PARAMS, venueId: "125" });
|
||||
assert.equal(elsewhere.matched.length, 0);
|
||||
assert.equal(elsewhere.other.length, 4, "IMAX at another venue becomes a secondary line");
|
||||
});
|
||||
|
||||
test("district: tolerates empty payloads", () => {
|
||||
assert.deepEqual(district.extract({}, CFG, D_PARAMS),
|
||||
{ matched: [], other: [], cinemaCount: 0, sessionCount: 0, showDates: [] });
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------- BookMyShow
|
||||
|
||||
const B_PARAMS = { venueId: "PPAM", formatMatch: "IMAX" };
|
||||
|
||||
test("bookmyshow: date codes drop the dashes", () => {
|
||||
assert.equal(bms.toDateCode("2026-09-25"), "20260925");
|
||||
});
|
||||
|
||||
test("bookmyshow: extracts venue sessions and per-tier availability from a real capture", () => {
|
||||
const ex = bms.extract(load("bms-open-date.json"), CFG, B_PARAMS);
|
||||
assert.equal(ex.cinemaCount, 1);
|
||||
assert.equal(ex.sessionCount, 5);
|
||||
assert.equal(ex.matched.length, 5);
|
||||
const s = ex.matched[0];
|
||||
assert.equal(s.sid, "37929");
|
||||
assert.equal(s.cinema, "PVR: Palladium Mall, Ahmedabad");
|
||||
assert.equal(s.time, "08:00 AM"); // already local, so it is passed through untouched
|
||||
assert.equal(s.format, "IMAX");
|
||||
assert.equal(s.avail, null, "BMS never exposes exact seat counts");
|
||||
assert.ok(s.tiers.includes("RECLINER ₹960 (ALMOST FULL)"));
|
||||
assert.ok(s.tiers.includes("CLASSIC ₹470 (AVAILABLE)"));
|
||||
});
|
||||
|
||||
test("bookmyshow: a different venue code moves sessions to the secondary bucket", () => {
|
||||
const ex = bms.extract(load("bms-open-date.json"), CFG, { ...B_PARAMS, venueId: "XXXX" });
|
||||
assert.equal(ex.matched.length, 0);
|
||||
assert.equal(ex.other.length, 5);
|
||||
});
|
||||
|
||||
test("bookmyshow: a non-matching format filter yields nothing", () => {
|
||||
const ex = bms.extract(load("bms-open-date.json"), CFG, { ...B_PARAMS, formatMatch: "4DX" });
|
||||
assert.equal(ex.matched.length, 0);
|
||||
assert.equal(ex.sessionCount, 5, "sessions are still counted, just not matched");
|
||||
});
|
||||
|
||||
test("bookmyshow: a not-yet-on-sale response has no widgets and extracts to nothing", () => {
|
||||
const closed = load("bms-closed-date.json");
|
||||
assert.equal(closed.data.showtimeWidgets, undefined, "this is how 'not open yet' looks");
|
||||
assert.deepEqual(bms.extract(closed, CFG, B_PARAMS),
|
||||
{ matched: [], other: [], cinemaCount: 0, sessionCount: 0, showDates: [] });
|
||||
});
|
||||
Reference in new issue
Block a user