mirror of
https://github.com/aculix/ticket-monitoring.git
synced 2026-10-27 07:15:51 +00:00
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.
This commit is contained in:
1 parent
9442bca696
commit
67e97f5a6a
5 files changed
+75
-6
No files matched your search
+4
-3
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
+5
-3
@@ -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",
|
||||
|
||||
@@ -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,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");
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user