mirror of
https://github.com/aculix/ticket-monitoring.git
synced 2026-10-27 07:15:51 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a82c2b29af | ||
|
|
474415bb55 | ||
|
|
ef23f25ee5 | ||
|
|
6607a469a2 | ||
|
|
67e97f5a6a |
No files matched your search
+5
-3
@@ -18,11 +18,13 @@ NTFY_URL=https://ntfy.sh
|
||||
# NTFY_TOKEN=tk_... # only if your server (or topic) requires auth
|
||||
|
||||
# ── District (district.in) ────────────────────────────────────────────────────
|
||||
# Leave DISTRICT_MOVIE_CODE unset to skip District entirely.
|
||||
# Leave DISTRICT_CONTENT_ID unset to skip District entirely.
|
||||
# Read these off the movie's page URL, e.g.
|
||||
# district.in/movies/the-odyssey-movie-tickets-in-ahmedabad-MV187151?frmtid=y7EZNLXN5Y
|
||||
DISTRICT_MOVIE_CODE=y7EZNLXN5Y # ?frmtid= — identifies movie + language
|
||||
DISTRICT_CONTENT_ID=187151 # digits after MV
|
||||
DISTRICT_CONTENT_ID=187151 # digits after MV; this is what enables District
|
||||
DISTRICT_MOVIE_CODE=y7EZNLXN5Y # optional ?frmtid=, pins one language/format variant.
|
||||
# Leave empty for a film with no showtimes yet, since
|
||||
# District only publishes the code once shows exist.
|
||||
DISTRICT_CITY_KEY=ahmedabad # city slug in the path
|
||||
DISTRICT_LAT=23.033938956712916 # any coordinates in that city; the API needs them
|
||||
DISTRICT_LNG=72.57696301365426
|
||||
|
||||
@@ -28,12 +28,14 @@ daily heartbeat. A monitor that died at 3am and one that's quietly working shoul
|
||||
same from the outside.
|
||||
|
||||
Alerts are deduplicated by session id, so you hear about each show once. Shows added later still
|
||||
get through.
|
||||
get through, and so do shows that were pulled and re-listed: a show missing for three checks in a
|
||||
row is forgotten, so its return alerts you again.
|
||||
|
||||
| Notification | Priority | What it means |
|
||||
|---|---|---|
|
||||
| `<FORMAT> <DATE> OPEN on <SITE> - BOOK NOW` | max | The one you're waiting for. Tap it to book. |
|
||||
| `<DATE> is OPEN on <SITE> (no <FORMAT> yet)` | high | Date opened, your format hasn't shown up yet. |
|
||||
| `<DATE> shows withdrawn on <SITE>` | default | Shows you were alerted about were pulled. Re-armed for their return. |
|
||||
| `<SITE> checks BROKEN - check manually` | high | That site's checks are failing. Don't trust the silence. |
|
||||
| `<SITE> checks recovered` | default | Back to normal. |
|
||||
| `Still watching <DATE>` | min | Daily heartbeat, with a line per site. |
|
||||
@@ -106,7 +108,7 @@ support.
|
||||
## Finding your IDs
|
||||
|
||||
Configure either site, or both. Skip a site by leaving its main ID unset: no
|
||||
`DISTRICT_MOVIE_CODE` means District isn't watched, no `BMS_EVENT_CODE` means BookMyShow isn't.
|
||||
`DISTRICT_CONTENT_ID` means District isn't watched, no `BMS_EVENT_CODE` means BookMyShow isn't.
|
||||
|
||||
### District
|
||||
|
||||
@@ -118,7 +120,9 @@ https://www.district.in/movies/the-odyssey-movie-tickets-in-ahmedabad-MV187151?f
|
||||
```
|
||||
|
||||
- `DISTRICT_CONTENT_ID` is the number after `MV`
|
||||
- `DISTRICT_MOVIE_CODE` is the `frmtid` parameter, which pins down the movie *and* the language
|
||||
- `DISTRICT_MOVIE_CODE` is the `frmtid` parameter, which pins down the movie *and* the language.
|
||||
It's optional, and a film that hasn't been given showtimes yet doesn't have one, so leave it
|
||||
empty when you're waiting on an unreleased title.
|
||||
- `DISTRICT_CITY_KEY` is the city slug in the path
|
||||
- `DISTRICT_LAT` and `DISTRICT_LNG` can be any coordinates inside that city. The API refuses to
|
||||
answer without them, though results are city-wide either way.
|
||||
@@ -159,7 +163,8 @@ with comments in [`.env.example`](.env.example).
|
||||
|---|---|---|
|
||||
| `TARGET_DATE` | *required* | The date you're waiting for, `YYYY-MM-DD` |
|
||||
| `NTFY_TOPIC` | *required* | The topic you subscribe to. Treat it like a password. |
|
||||
| `DISTRICT_MOVIE_CODE` | | Set to watch District. Plus `DISTRICT_CONTENT_ID`, `DISTRICT_CITY_KEY`, `DISTRICT_LAT`, `DISTRICT_LNG` |
|
||||
| `DISTRICT_CONTENT_ID` | | Set to watch District. Plus `DISTRICT_CITY_KEY`, `DISTRICT_LAT`, `DISTRICT_LNG` |
|
||||
| `DISTRICT_MOVIE_CODE` | *(any variant)* | Optional `frmtid` to pin one language/format |
|
||||
| `DISTRICT_CINEMA_ID` | *(any)* | Restrict District to one cinema |
|
||||
| `DISTRICT_FORMAT_TAG` | `imax_2d` | Session tag that counts as a match |
|
||||
| `DISTRICT_FORMAT_MATCH` | `IMAX` | Screen-format substring that counts as a match |
|
||||
@@ -184,7 +189,8 @@ with comments in [`.env.example`](.env.example).
|
||||
| `PROXY_URL` | | SOCKS5 proxy for both sites' checks, see below |
|
||||
|
||||
v1 used flat names (`MOVIE_CODE`, `CINEMA_ID` and friends) for District. Those still work, so an
|
||||
existing `.env` keeps running unchanged.
|
||||
existing `.env` keeps running unchanged. They're read per field, so a `DISTRICT_*` value you set
|
||||
deliberately always wins over a leftover flat one, and the log warns when any flat name is in use.
|
||||
|
||||
## Status endpoint
|
||||
|
||||
@@ -249,7 +255,7 @@ Run `npm run check` for a single noisy check. Configuration problems are reporte
|
||||
## Development
|
||||
|
||||
```bash
|
||||
npm test # 24 tests, no network needed
|
||||
npm test # 35 tests, no network needed
|
||||
```
|
||||
|
||||
The tests run the alert state machine and both site parsers against real captured API responses
|
||||
|
||||
@@ -75,6 +75,14 @@ edge case testable without network or clock mocking, and it's where the interest
|
||||
alert's push succeeds. A failed notification retries on the next tick instead of being silently
|
||||
recorded as delivered.
|
||||
- **Dedupe by session id, not a boolean.** Shows added after the first alert still notify you.
|
||||
- **Dedupe does not outlive the listing.** Cinemas publish early, pull the shows and re-list
|
||||
them, often under the same session ids, and this really happened with the Endgame release. A
|
||||
show missing for three consecutive successful checks is forgotten, so its return alerts again.
|
||||
Three rather than one, so a single flaky response can't cause a duplicate max-priority push,
|
||||
and failed checks don't count, since an outage says nothing about whether a show exists. If
|
||||
the whole date goes dark after opening, a separate notice says the shows were withdrawn. The
|
||||
re-arm itself lives in the base state, not in that notice's patch, so it happens even if the
|
||||
notice fails to deliver.
|
||||
- **`dateOpenAlerted` rides an alert patch, never the base state.** Otherwise a failed push
|
||||
followed by sessions disappearing could swallow the only notification you'd ever get.
|
||||
- **Open-but-empty counts as closed**, defending against a `200` with no sessions.
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ticket-monitoring",
|
||||
"version": "1.1.0",
|
||||
"version": "1.2.0",
|
||||
"description": "Watches district.in and BookMyShow showtimes and sends a phone notification the moment tickets for a specific movie, date, format and cinema go on sale.",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
|
||||
+45
-27
@@ -22,36 +22,50 @@ const num = (name, fallback) => {
|
||||
return n;
|
||||
};
|
||||
|
||||
/** Old single-provider variable names, still honoured so v1 .env files keep working. */
|
||||
function legacyDistrict() {
|
||||
if (!process.env.MOVIE_CODE || process.env.DISTRICT_MOVIE_CODE) return null;
|
||||
return {
|
||||
movieCode: env("MOVIE_CODE"),
|
||||
contentId: env("CONTENT_ID"),
|
||||
cityKey: env("CITY_KEY"),
|
||||
lat: env("LAT"),
|
||||
lng: env("LNG"),
|
||||
venueId: env("CINEMA_ID"),
|
||||
formatTag: process.env.FORMAT_TAG ?? "imax_2d",
|
||||
formatMatch: process.env.FORMAT_MATCH ?? "IMAX",
|
||||
bookingUrl: env("BOOKING_URL"),
|
||||
};
|
||||
/**
|
||||
* District settings. v1 used flat names (MOVIE_CODE, CINEMA_ID and so on) and those are
|
||||
* still honoured, but per field, so a stale leftover cannot override a DISTRICT_* value
|
||||
* that was set deliberately. Enabled by the content id, since the movie code is optional.
|
||||
*/
|
||||
const LEGACY = {
|
||||
DISTRICT_MOVIE_CODE: "MOVIE_CODE",
|
||||
DISTRICT_CONTENT_ID: "CONTENT_ID",
|
||||
DISTRICT_CITY_KEY: "CITY_KEY",
|
||||
DISTRICT_LAT: "LAT",
|
||||
DISTRICT_LNG: "LNG",
|
||||
DISTRICT_CINEMA_ID: "CINEMA_ID",
|
||||
DISTRICT_FORMAT_TAG: "FORMAT_TAG",
|
||||
DISTRICT_FORMAT_MATCH: "FORMAT_MATCH",
|
||||
DISTRICT_BOOKING_URL: "BOOKING_URL",
|
||||
};
|
||||
|
||||
const pick = (name) => process.env[name] ?? process.env[LEGACY[name]];
|
||||
|
||||
export function legacyNamesInUse() {
|
||||
return Object.entries(LEGACY)
|
||||
.filter(([nu, old]) => process.env[nu] === undefined && process.env[old] !== undefined)
|
||||
.map(([, old]) => old);
|
||||
}
|
||||
|
||||
function districtConfig() {
|
||||
const legacy = legacyDistrict();
|
||||
if (legacy) return legacy;
|
||||
if (!process.env.DISTRICT_MOVIE_CODE) return null;
|
||||
const contentId = pick("DISTRICT_CONTENT_ID");
|
||||
if (!contentId) return null; // District not configured
|
||||
const need = (name) => {
|
||||
const v = pick(name);
|
||||
if (!v) throw new Error(`${name} is required when District is configured (see .env.example)`);
|
||||
return v;
|
||||
};
|
||||
return {
|
||||
movieCode: required("DISTRICT_MOVIE_CODE"),
|
||||
contentId: required("DISTRICT_CONTENT_ID"),
|
||||
cityKey: required("DISTRICT_CITY_KEY"),
|
||||
lat: required("DISTRICT_LAT"),
|
||||
lng: required("DISTRICT_LNG"),
|
||||
venueId: env("DISTRICT_CINEMA_ID"),
|
||||
formatTag: process.env.DISTRICT_FORMAT_TAG ?? "imax_2d",
|
||||
formatMatch: process.env.DISTRICT_FORMAT_MATCH ?? "IMAX",
|
||||
bookingUrl: env("DISTRICT_BOOKING_URL"),
|
||||
// Optional: District answers without it, and an unreleased film has no format code yet.
|
||||
movieCode: pick("DISTRICT_MOVIE_CODE") ?? "",
|
||||
contentId,
|
||||
cityKey: need("DISTRICT_CITY_KEY"),
|
||||
lat: need("DISTRICT_LAT"),
|
||||
lng: need("DISTRICT_LNG"),
|
||||
venueId: pick("DISTRICT_CINEMA_ID") ?? "",
|
||||
formatTag: pick("DISTRICT_FORMAT_TAG") ?? "imax_2d",
|
||||
formatMatch: pick("DISTRICT_FORMAT_MATCH") ?? "IMAX",
|
||||
bookingUrl: pick("DISTRICT_BOOKING_URL") ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -83,6 +97,10 @@ export function loadConfig() {
|
||||
new Date(Date.parse(`${targetDate}T00:00:00Z`) + 86400000 - tzOffsetMinutes * 60000).toISOString();
|
||||
|
||||
const built = { district: districtConfig(), bookmyshow: bookmyshowConfig() };
|
||||
const stale = legacyNamesInUse();
|
||||
if (stale.length) {
|
||||
console.warn(`warning: using v1 variable names ${stale.join(", ")}. Rename them to DISTRICT_* to avoid confusion.`);
|
||||
}
|
||||
// PROVIDERS is an explicit allow-list; without it, every configured provider runs.
|
||||
const wanted = env("PROVIDERS").split(",").map((s) => s.trim()).filter(Boolean);
|
||||
for (const w of wanted) {
|
||||
@@ -90,7 +108,7 @@ export function loadConfig() {
|
||||
}
|
||||
const keys = (wanted.length ? wanted : Object.keys(built)).filter((k) => built[k]);
|
||||
if (!keys.length) {
|
||||
throw new Error("No provider configured. Set DISTRICT_MOVIE_CODE and/or BMS_EVENT_CODE (see .env.example)");
|
||||
throw new Error("No provider configured. Set DISTRICT_CONTENT_ID and/or BMS_EVENT_CODE (see .env.example)");
|
||||
}
|
||||
for (const w of wanted) {
|
||||
if (!built[w]) throw new Error(`PROVIDERS lists "${w}" but its settings are missing (see .env.example)`);
|
||||
|
||||
+12
-4
@@ -76,15 +76,15 @@ async function tickProvider(cfg, p, state, now) {
|
||||
const res = await p.module.check(cfg, p, cfg.targetDate);
|
||||
input = res.kind === "open"
|
||||
? { kind: "open", extract: p.module.extract(res.data, cfg, p), now, probeMax }
|
||||
: { kind: "closed", now, probeMax };
|
||||
: { kind: "closed", now, probeMax, note: res.note ?? null };
|
||||
} catch (err) {
|
||||
input = { kind: "error", reason: String(err.message ?? err), now, probeMax };
|
||||
}
|
||||
|
||||
const { baseState, alerts } = decideProvider(pstate, input, cfg, p);
|
||||
lastTick[p.key] = { at: ts(), kind: input.kind, reason: input.reason ?? null };
|
||||
lastTick[p.key] = { at: ts(), kind: input.kind, reason: input.reason ?? input.note ?? null };
|
||||
const matched = input.extract?.matched?.length ?? 0;
|
||||
console.log(ts(), `[${p.key}] ${input.kind}${input.reason ? ` (${input.reason})` : ""}`,
|
||||
console.log(ts(), `[${p.key}] ${input.kind}${input.reason || input.note ? ` (${input.reason ?? input.note})` : ""}`,
|
||||
`| matched: ${matched} | failures: ${baseState.failures}`);
|
||||
return { pstate: baseState, alerts, input };
|
||||
}
|
||||
@@ -123,6 +123,7 @@ export async function tick(cfg) {
|
||||
label: p.label,
|
||||
kind: input.kind === "error" ? "checks failing" : merged.providers[p.key].lastGood?.kind,
|
||||
showDatesMax: merged.providers[p.key].lastGood?.showDatesMax,
|
||||
note: merged.providers[p.key].lastGood?.note,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -157,7 +158,14 @@ function startStatusServer(cfg) {
|
||||
const out = {
|
||||
now: ts(),
|
||||
targetDate: cfg.targetDate,
|
||||
providers: cfg.providers.map((p) => p.key),
|
||||
// What each site is actually watching. Without this, a misconfigured id or an
|
||||
// unexpectedly narrow filter is indistinguishable from "nothing on sale yet".
|
||||
watching: Object.fromEntries(cfg.providers.map((p) => [p.key, {
|
||||
movie: p.contentId ?? p.eventCode,
|
||||
variant: p.movieCode || p.language || "(any language/format variant)",
|
||||
venue: p.venueId || "(any venue)",
|
||||
format: [p.formatTag, p.formatMatch].filter(Boolean).join(" or ") || "(any format)",
|
||||
}])),
|
||||
proxied: Boolean(cfg.dispatcher),
|
||||
retired: state.retired,
|
||||
lastTick,
|
||||
|
||||
+60
-7
@@ -2,8 +2,18 @@
|
||||
// Providers supply normalized sessions; nothing here knows which site they came from.
|
||||
import { localDate, localMinutes } from "./lib.js";
|
||||
|
||||
/**
|
||||
* Consecutive successful checks a previously announced show must be absent before it is
|
||||
* forgotten. Counting checks, rather than reacting to one miss, stops a single flaky
|
||||
* response from turning into a duplicate max-priority push.
|
||||
*/
|
||||
export const REARM_AFTER = 3;
|
||||
|
||||
export function defaultProviderState() {
|
||||
return { dateOpenAlerted: false, sidsAlerted: {}, failures: 0, brokenAlerted: false, lastGood: null };
|
||||
return {
|
||||
dateOpenAlerted: false, sidsAlerted: {}, missing: {}, closedTicks: 0,
|
||||
failures: 0, brokenAlerted: false, lastGood: null,
|
||||
};
|
||||
}
|
||||
|
||||
export function defaultState() {
|
||||
@@ -73,16 +83,58 @@ export function decideProvider(pstate, input, cfg, p) {
|
||||
|
||||
const ex = input.kind === "open" ? input.extract : null;
|
||||
const effectiveKind = ex && ex.sessionCount > 0 ? "open" : "closed"; // open-but-empty = closed
|
||||
if (base.lastGood?.kind !== effectiveKind) {
|
||||
base.lastGood = { ...(base.lastGood ?? {}), kind: effectiveKind, at: now.toISOString() };
|
||||
const note = input.note ?? null;
|
||||
if (base.lastGood?.kind !== effectiveKind || base.lastGood?.note !== note) {
|
||||
base.lastGood = { ...(base.lastGood ?? {}), kind: effectiveKind, note, at: now.toISOString() };
|
||||
}
|
||||
|
||||
// Cinemas publish early, pull the shows and re-list them, often under the same session
|
||||
// ids. Dedupe must not outlive the listing, or the re-listing is silent. So a show that
|
||||
// stays missing for REARM_AFTER checks is forgotten, and its return alerts again. This
|
||||
// lives in the base state rather than an alert patch: re-arming must not depend on a
|
||||
// notification getting through.
|
||||
const present = new Set(effectiveKind === "open" ? ex.matched.map((s) => s.sid) : []);
|
||||
const alerted = { ...(pstate.sidsAlerted ?? {}) };
|
||||
const missing = { ...(pstate.missing ?? {}) };
|
||||
for (const sid of Object.keys(alerted)) {
|
||||
if (present.has(sid)) {
|
||||
delete missing[sid];
|
||||
} else if ((missing[sid] = (missing[sid] ?? 0) + 1) >= REARM_AFTER) {
|
||||
delete alerted[sid];
|
||||
delete missing[sid];
|
||||
}
|
||||
}
|
||||
for (const sid of Object.keys(missing)) if (!(sid in alerted)) delete missing[sid];
|
||||
base.sidsAlerted = alerted;
|
||||
base.missing = missing;
|
||||
|
||||
// The whole date going dark after it opened is worth saying out loud: the user may be
|
||||
// mid-booking and wondering where the shows went. Counted the same way, then re-armed.
|
||||
if (effectiveKind === "closed" && pstate.dateOpenAlerted) {
|
||||
base.closedTicks = (pstate.closedTicks ?? 0) + 1;
|
||||
if (base.closedTicks >= REARM_AFTER) {
|
||||
base.dateOpenAlerted = false;
|
||||
base.closedTicks = 0;
|
||||
alerts.push(tag({
|
||||
title: `${cfg.targetDate} shows withdrawn on ${p.label}`,
|
||||
priority: "3",
|
||||
tags: "arrows_counterclockwise",
|
||||
click: p.bookingUrl,
|
||||
body: `${p.label} is no longer listing shows for ${cfg.targetDate}. The monitor has re-armed, so you'll get a max-priority push again the moment they come back.`,
|
||||
statePatch: {},
|
||||
}));
|
||||
}
|
||||
} else if (pstate.closedTicks) {
|
||||
base.closedTicks = 0;
|
||||
}
|
||||
|
||||
if (effectiveKind === "open") {
|
||||
const fresh = ex.matched.filter((s) => !pstate.sidsAlerted?.[s.sid]);
|
||||
const fresh = ex.matched.filter((s) => !alerted[s.sid]);
|
||||
if (fresh.length > 0) {
|
||||
const lines = fresh.map(sessionLine);
|
||||
for (const s of ex.other) lines.push(`Also at ${s.cinema}: ${s.time}`);
|
||||
const sidsAlerted = { ...pstate.sidsAlerted };
|
||||
// Built from the post-forget map, so the patch can't resurrect a forgotten id.
|
||||
const sidsAlerted = { ...alerted };
|
||||
for (const s of fresh) sidsAlerted[s.sid] = true;
|
||||
alerts.push(tag({
|
||||
title: `${cfg.formatLabel} ${cfg.targetDate} OPEN on ${p.label} - BOOK NOW`,
|
||||
@@ -92,7 +144,7 @@ export function decideProvider(pstate, input, cfg, p) {
|
||||
body: lines.join("\n\n"),
|
||||
statePatch: { sidsAlerted, dateOpenAlerted: true },
|
||||
}));
|
||||
} else if (!pstate.dateOpenAlerted && ex.matched.length === 0) {
|
||||
} else if (!base.dateOpenAlerted && ex.matched.length === 0) {
|
||||
alerts.push(tag({
|
||||
title: `${cfg.targetDate} is OPEN on ${p.label} (no ${cfg.formatLabel} yet)`,
|
||||
priority: "4",
|
||||
@@ -131,7 +183,8 @@ export function decideGlobal(state, cfg, now, summaries) {
|
||||
|
||||
if (heartbeatDue(state, now, cfg)) {
|
||||
const lines = summaries.map((s) =>
|
||||
`${s.label}: ${s.kind ?? "unknown"}${s.showDatesMax ? ` (open through ${s.showDatesMax})` : ""}`);
|
||||
`${s.label}: ${s.kind ?? "unknown"}${s.note ? `, ${s.note}` : ""}` +
|
||||
`${s.showDatesMax ? ` (open through ${s.showDatesMax})` : ""}`);
|
||||
alerts.push({
|
||||
title: `Still watching ${cfg.targetDate}`,
|
||||
priority: "1",
|
||||
|
||||
@@ -56,6 +56,22 @@ export async function check(cfg, p, date) {
|
||||
throw new CheckError(`unparseable 200 body: ${err.message}`);
|
||||
}
|
||||
}
|
||||
// A film announced but not yet given showtimes answers 400 "Content not found"
|
||||
// (code 32). That is a waiting state, not a fault, so it must not raise the failure
|
||||
// streak. A typo'd content id looks identical, so the reason is carried through to
|
||||
// the status endpoint and the daily heartbeat rather than being swallowed.
|
||||
if (res.status === 400) {
|
||||
let body = null;
|
||||
try {
|
||||
body = await res.json();
|
||||
} catch {
|
||||
// fall through to the generic error below
|
||||
}
|
||||
if (body?.code === 32) {
|
||||
return { kind: "closed", note: "not listed yet (District: content not found)" };
|
||||
}
|
||||
if (body?.msg) throw new CheckError(`HTTP 400: ${body.msg}`);
|
||||
}
|
||||
throw new CheckError(`HTTP ${res.status}`);
|
||||
}
|
||||
|
||||
|
||||
@@ -190,3 +190,115 @@ test("retirement takes precedence over the heartbeat", () => {
|
||||
assert.equal(d.alerts.length, 1);
|
||||
assert.ok(d.alerts[0].title.includes("retired"));
|
||||
});
|
||||
|
||||
test("a 'not listed yet' note rides through to lastGood and the heartbeat", () => {
|
||||
const d = decideProvider(defaultProviderState(),
|
||||
{ kind: "closed", now: T0, note: "not listed yet (District: content not found)" }, CFG, P);
|
||||
assert.equal(d.alerts.length, 0, "waiting for a listing is not an alert");
|
||||
assert.equal(d.baseState.lastGood.kind, "closed");
|
||||
assert.match(d.baseState.lastGood.note, /not listed yet/);
|
||||
|
||||
const hb = decideGlobal(defaultState(), CFG, HB, [
|
||||
{ label: "District", kind: "closed", note: "not listed yet (District: content not found)" },
|
||||
{ label: "BookMyShow", kind: "closed" },
|
||||
]);
|
||||
assert.match(hb.alerts[0].body, /District: closed, not listed yet/,
|
||||
"the daily heartbeat must say why District sees nothing, or silence is ambiguous");
|
||||
assert.match(hb.alerts[0].body, /BookMyShow: closed$/m);
|
||||
});
|
||||
|
||||
test("clearing the note is itself a state change, so the status endpoint stops lying", () => {
|
||||
const withNote = decideProvider(defaultProviderState(), { kind: "closed", now: T0, note: "not listed yet" }, CFG, P).baseState;
|
||||
const after = decideProvider(withNote, { kind: "closed", now: T0 }, CFG, P);
|
||||
assert.equal(after.baseState.lastGood.note, null, "note is dropped once the listing appears");
|
||||
});
|
||||
|
||||
// ------------------------------------------------ withdrawn and re-listed shows
|
||||
//
|
||||
// Cinemas publish early, pull the shows, then re-list them, often under the same
|
||||
// session ids. If dedupe outlives the listing, the re-listing (the moment bookings
|
||||
// really open) is silent. These pin down when a vanished session is forgotten.
|
||||
|
||||
const closed = (now = T0) => ({ kind: "closed", now });
|
||||
const tick = (state, input, p = P) => apply(state, decideProvider(state, input, CFG, p));
|
||||
|
||||
test("a withdrawn session that comes back after 3 missed checks alerts again", () => {
|
||||
let s = tick(defaultProviderState(), openInput(EX_MATCH));
|
||||
assert.ok(s.sidsAlerted["111"], "alerted once");
|
||||
for (let i = 0; i < 3; i++) s = tick(s, closed());
|
||||
assert.equal(s.sidsAlerted["111"], undefined, "forgotten after 3 checks without it");
|
||||
|
||||
const back = decideProvider(s, openInput(EX_MATCH), CFG, P);
|
||||
assert.equal(back.alerts.length, 1, "the re-listing must alert");
|
||||
assert.equal(back.alerts[0].priority, "5");
|
||||
});
|
||||
|
||||
test("a session missing for fewer than 3 checks is not re-alerted", () => {
|
||||
let s = tick(defaultProviderState(), openInput(EX_MATCH));
|
||||
s = tick(s, closed());
|
||||
s = tick(s, closed());
|
||||
const back = decideProvider(s, openInput(EX_MATCH), CFG, P);
|
||||
assert.equal(back.alerts.length, 0, "a brief flicker in the response is not a new listing");
|
||||
assert.deepEqual(apply(s, back).missing, {}, "and the miss counter resets once it is seen again");
|
||||
});
|
||||
|
||||
test("a fully withdrawn date re-arms after 3 closed checks and says so, once", () => {
|
||||
let s = tick(defaultProviderState(), openInput(EX_MATCH));
|
||||
assert.equal(s.dateOpenAlerted, true);
|
||||
s = tick(s, closed());
|
||||
s = tick(s, closed());
|
||||
const third = decideProvider(s, closed(), CFG, P);
|
||||
const notice = third.alerts.find((a) => /withdrawn/i.test(a.title));
|
||||
assert.ok(notice, "the user should hear that the shows were pulled");
|
||||
assert.ok(notice.title.includes("District"));
|
||||
assert.ok(Number(notice.priority) < 5, "informational, not a booking alert");
|
||||
s = apply(s, third);
|
||||
assert.equal(s.dateOpenAlerted, false, "re-armed");
|
||||
|
||||
for (let i = 0; i < 5; i++) {
|
||||
assert.equal(decideProvider(s, closed(), CFG, P).alerts.length, 0, "the notice is not repeated");
|
||||
s = tick(s, closed());
|
||||
}
|
||||
});
|
||||
|
||||
test("the re-arm does not depend on the withdrawal notice being delivered", () => {
|
||||
let s = tick(defaultProviderState(), openInput(EX_MATCH));
|
||||
s = tick(s, closed());
|
||||
s = tick(s, closed());
|
||||
const third = decideProvider(s, closed(), CFG, P);
|
||||
s = third.baseState; // ntfy was down: no alert patches merged
|
||||
assert.equal(s.dateOpenAlerted, false, "re-arming is not gated on the push");
|
||||
assert.equal(s.sidsAlerted["111"], undefined);
|
||||
assert.equal(decideProvider(s, openInput(EX_MATCH), CFG, P).alerts[0].priority, "5",
|
||||
"so a re-listing still gets through even if the notice was lost");
|
||||
});
|
||||
|
||||
test("partial withdrawal forgets only the missing session, with no withdrawal notice", () => {
|
||||
const two = { ...EX_MATCH, matched: [EX_MATCH.matched[0], { ...EX_MATCH.matched[0], sid: "222", time: "1:15 PM" }] };
|
||||
let s = tick(defaultProviderState(), openInput(two));
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const d = decideProvider(s, openInput(EX_MATCH), CFG, P); // 222 gone, date still open
|
||||
assert.equal(d.alerts.filter((a) => /withdrawn/i.test(a.title)).length, 0);
|
||||
s = apply(s, d);
|
||||
}
|
||||
assert.ok(s.sidsAlerted["111"], "the session still listed stays deduped");
|
||||
assert.equal(s.sidsAlerted["222"], undefined, "the pulled one is forgotten");
|
||||
|
||||
const back = decideProvider(s, openInput(two), CFG, P);
|
||||
assert.equal(back.alerts.length, 1);
|
||||
assert.ok(back.alerts[0].body.includes("1:15 PM"), "only the returning session is announced");
|
||||
assert.ok(!back.alerts[0].body.includes("9:00 AM"));
|
||||
});
|
||||
|
||||
test("failed checks do not count as a session being missing", () => {
|
||||
let s = tick(defaultProviderState(), openInput(EX_MATCH));
|
||||
for (let i = 0; i < 5; i++) s = tick(s, { kind: "error", reason: "HTTP 403", now: T0 });
|
||||
assert.ok(s.sidsAlerted["111"], "an outage says nothing about whether the show still exists");
|
||||
assert.equal(decideProvider(s, openInput(EX_MATCH), CFG, P).alerts.filter((a) => a.priority === "5").length, 0);
|
||||
});
|
||||
|
||||
test("waiting on a date that never opened writes no state", () => {
|
||||
let s = tick(defaultProviderState(), closed());
|
||||
const next = decideProvider(s, closed(), CFG, P);
|
||||
assert.equal(JSON.stringify(apply(s, next)), JSON.stringify(s), "no counters ticking while nothing was ever alerted");
|
||||
});
|
||||
@@ -104,3 +104,62 @@ test("bookmyshow: a not-yet-on-sale response has no widgets and extracts to noth
|
||||
assert.deepEqual(bms.extract(closed, CFG, B_PARAMS),
|
||||
{ matched: [], other: [], cinemaCount: 0, sessionCount: 0, showDates: [] });
|
||||
});
|
||||
|
||||
// The 400 branch decides between "waiting for a listing" and "something is wrong",
|
||||
// so it is worth pinning without a network.
|
||||
test("district: 400 code 32 is a waiting state, other 400s are failures", async () => {
|
||||
const realFetch = globalThis.fetch;
|
||||
const stub = (status, body) => {
|
||||
globalThis.fetch = async () => ({ status, json: async () => body });
|
||||
};
|
||||
const p = { movieCode: "X", contentId: "1", cityKey: "c", lat: "1", lng: "2" };
|
||||
try {
|
||||
stub(400, { errors: [], msg: "Content not found", code: 32 });
|
||||
const waiting = await district.check({}, p, "2026-09-25");
|
||||
assert.equal(waiting.kind, "closed");
|
||||
assert.match(waiting.note, /not listed yet/, "the reason must survive for the heartbeat");
|
||||
|
||||
stub(400, { errors: [], msg: "Mandatory search params are missing", code: 3 });
|
||||
await assert.rejects(() => district.check({}, p, "2026-09-25"), /Mandatory search params/,
|
||||
"a real config error must still raise the failure streak");
|
||||
|
||||
stub(500, {});
|
||||
await assert.rejects(() => district.check({}, p, "2026-09-25"), /HTTP 500/);
|
||||
|
||||
stub(204, {});
|
||||
assert.equal((await district.check({}, p, "2026-09-25")).kind, "closed");
|
||||
} finally {
|
||||
globalThis.fetch = realFetch;
|
||||
}
|
||||
});
|
||||
|
||||
// A leftover v1 name silently overriding a DISTRICT_* value that was set on purpose is
|
||||
// the worst kind of config bug: the monitor watches the wrong film and still says "closed".
|
||||
test("config: DISTRICT_* wins per field over leftover v1 names", async () => {
|
||||
const { loadConfig, legacyNamesInUse } = await import("../src/config.js");
|
||||
const saved = { ...process.env };
|
||||
try {
|
||||
Object.assign(process.env, {
|
||||
TARGET_DATE: "2026-09-25", NTFY_TOPIC: "t",
|
||||
MOVIE_CODE: "OLDCODE", CONTENT_ID: "187151", CITY_KEY: "ahmedabad",
|
||||
LAT: "23.03", LNG: "72.57", CINEMA_ID: "1032362",
|
||||
DISTRICT_CONTENT_ID: "227928", DISTRICT_CINEMA_ID: "",
|
||||
BMS_EVENT_CODE: "ET1", BMS_REGION_CODE: "AHD",
|
||||
});
|
||||
const d = loadConfig().providers.find((p) => p.key === "district");
|
||||
assert.equal(d.contentId, "227928", "the explicitly set new name must win");
|
||||
assert.equal(d.venueId, "", "an explicitly emptied new name must not fall back to the old one");
|
||||
assert.equal(d.movieCode, "OLDCODE", "fields with no new value still inherit");
|
||||
assert.ok(legacyNamesInUse().includes("MOVIE_CODE"), "and the leftovers are reported");
|
||||
|
||||
delete process.env.DISTRICT_CONTENT_ID;
|
||||
delete process.env.CONTENT_ID;
|
||||
const only = loadConfig().providers;
|
||||
assert.equal(only.find((p) => p.key === "district"), undefined,
|
||||
"no content id means District is simply not watched");
|
||||
assert.equal(only.length, 1, "and the other site carries on alone");
|
||||
} finally {
|
||||
for (const k of Object.keys(process.env)) if (!(k in saved)) delete process.env[k];
|
||||
Object.assign(process.env, saved);
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user