From 67e97f5a6af059d1fc27a3d8bbf680534c3e7502 Mon Sep 17 00:00:00 2001 From: Aculix Technologies Date: Tue, 8 Sep 2026 14:57:31 +0530 Subject: [PATCH] fix: treat District 'content not found' as waiting, not as a failure A film that is announced but has no showtimes yet answers 400 with code 32, which is exactly the state you are monitoring for before a release. Counting that as a check failure meant pointing District at an upcoming film produced a false BROKEN alert every six minutes until the listing appeared. Code 32 now reports closed and carries a note through to the status endpoint and the daily heartbeat, so a typo'd content id still surfaces rather than looking like a quiet 'not open yet' forever. Other 400s, including the missing-parameter error, still fail loudly. --- src/index.js | 7 ++++--- src/monitor.js | 8 +++++--- src/providers/district.js | 16 ++++++++++++++++ test/monitor.test.js | 22 ++++++++++++++++++++++ test/providers.test.js | 28 ++++++++++++++++++++++++++++ 5 files changed, 75 insertions(+), 6 deletions(-) diff --git a/src/index.js b/src/index.js index c71f2dc..11f384a 100644 --- a/src/index.js +++ b/src/index.js @@ -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, }); } } diff --git a/src/monitor.js b/src/monitor.js index 7a9c81b..9c77355 100644 --- a/src/monitor.js +++ b/src/monitor.js @@ -73,8 +73,9 @@ 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() }; } if (effectiveKind === "open") { @@ -131,7 +132,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", diff --git a/src/providers/district.js b/src/providers/district.js index ff04c21..065eb71 100644 --- a/src/providers/district.js +++ b/src/providers/district.js @@ -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}`); } diff --git a/test/monitor.test.js b/test/monitor.test.js index 1b0ab4d..b92e6fe 100644 --- a/test/monitor.test.js +++ b/test/monitor.test.js @@ -190,3 +190,25 @@ 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"); +}); diff --git a/test/providers.test.js b/test/providers.test.js index 94e67fa..b83d438 100644 --- a/test/providers.test.js +++ b/test/providers.test.js @@ -104,3 +104,31 @@ 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; + } +});