Files
oh-my-pi-telegram/tests/polling.test.ts
evandrodevbr 6f7e266a6e
Validate / validate (push) Successful in 9m52s
feat: keep error status until polling recovers, classify unconfirmed deliveries, document the UI
- the poll loop now clears its error status after the next successful getUpdates, not before the retry, so a slow retry never shows as healthy
- recovery is reported only when getUpdates itself failed
- a send that may have reached Telegram is reported as unconfirmed instead of failed
- README and CHANGELOG describe the states, toasts, queue preview and spinner text
2026-10-01 17:48:15 -03:00

1092 lines
33 KiB
TypeScript

/**
* Regression tests for the Telegram polling runtime domain
* Covers polling request helpers, stop conditions, and the long-poll loop runtime in one suite
*/
import assert from "node:assert/strict";
import { mkdtempSync, readFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import test from "node:test";
import {
applyTelegramThreadCapability,
buildTelegramInitialSyncRequest,
buildTelegramLongPollRequest,
createTelegramPollingActivityReader,
createTelegramPollingController,
createTelegramPollingControllerRuntime,
createTelegramPollingControllerState,
createTelegramPollLoopRunner,
createTelegramThreadAwarePollingPorts,
createTelegramThreadCapabilityStateRuntime,
createTelegramThreadTargetObservationBinding,
getLatestTelegramUpdateId,
isTelegramGetUpdatesConflictError,
isTelegramPollingControllerActive,
runTelegramPollLoop,
shouldStartTelegramPolling,
shouldStopTelegramPolling,
sleepTelegramPollingRetry,
startTelegramPollingRuntime,
stopTelegramPollingRuntime,
TELEGRAM_ALLOWED_UPDATES,
} from "../lib/polling.ts";
import { createTelegramConfigStore } from "../lib/config.ts";
const TEST_CONTEXT = "ctx";
test("Polling helpers build the initial sync request", () => {
assert.deepEqual(buildTelegramInitialSyncRequest(), {
offset: -1,
limit: 1,
timeout: 0,
});
});
test("Polling helpers build long-poll requests with and without lastUpdateId", () => {
assert.deepEqual(buildTelegramLongPollRequest(), {
offset: undefined,
limit: 10,
timeout: 30,
allowed_updates: TELEGRAM_ALLOWED_UPDATES,
});
assert.deepEqual(buildTelegramLongPollRequest(41), {
offset: 42,
limit: 10,
timeout: 30,
allowed_updates: TELEGRAM_ALLOWED_UPDATES,
});
});
test("Polling helpers extract the latest update id", () => {
assert.equal(getLatestTelegramUpdateId([]), undefined);
assert.equal(
getLatestTelegramUpdateId([{ update_id: 1 }, { update_id: 7 }]),
7,
);
});
test("Thread target observation binding supports late runtime composition", async () => {
const events: string[] = [];
const binding = createTelegramThreadTargetObservationBinding<string>();
await binding.handle("before");
binding.set(async (ctx) => {
events.push(ctx);
});
await binding.handle("after");
assert.deepEqual(events, ["after"]);
});
test("Thread capability state runtime owns transition flags", () => {
const state = createTelegramThreadCapabilityStateRuntime();
assert.equal(state.isBusPollingStarted(), false);
assert.equal(state.isTopicModeUnavailable(), false);
assert.equal(state.shouldForceFreshLeaderThread(), false);
state.setBusPollingStarted(true);
state.setTopicModeUnavailable(true);
state.setForceFreshLeaderThread(true);
assert.equal(state.isBusPollingStarted(), true);
assert.equal(state.isTopicModeUnavailable(), true);
assert.equal(state.shouldForceFreshLeaderThread(), true);
});
test("Thread-aware polling blocks follower takeover during Threaded Mode downgrade", async () => {
const events: Array<{ category: string; details?: Record<string, unknown> }> = [];
const callApi = async <TResponse,>(): Promise<TResponse> => ({}) as TResponse;
const store = {
async load() {},
async persist() {},
getBotState() {
return { threadMode: "disabled" as const };
},
setBotState() {},
list() {
return [
{
status: "active",
target: { chatId: 42, threadId: 7 },
},
];
},
};
const ports = createTelegramThreadAwarePollingPorts({
getAllowedUserId: () => 42,
callApi,
topicTargetStore: store,
isBusConfigured: () => true,
isBusRuntimeEnabled: () => false,
isTopicModeUnavailableError: () => false,
getPollingStartedWithTelegramBus: () => false,
setPollingStartedWithTelegramBus() {},
setForceFreshLeaderThreadOnNextStart() {},
setTopicModeUnavailable() {},
startClassicPolling() {},
async stopClassicPolling() {},
async startBusLeaderPolling() {},
async stopBusLeaderPolling() {},
startLeaderHealth() {},
stopLeaderHealth() {},
registerFollowerWithLeader: async () => true,
stopFollowerRegistration() {},
recordEvent(category, _error, details) {
events.push({ category, details });
},
});
await assert.rejects(
ports.registerFollowerWithOwner?.(TEST_CONTEXT, { pid: 1 }),
/current leader remains the classic polling owner/u,
);
assert.deepEqual(events, [
{
category: "bus",
details: {
phase: "follower-register-thread-mode-disabled",
reason: "active-thread-bindings-present",
},
},
]);
});
test("Thread capability downgrade retries classic restore after failure", async () => {
let state: {
threadMode?: "enabled" | "disabled" | "unknown";
updatedAtMs?: number;
lastReconcileAction?: string;
} = {
threadMode: "enabled",
lastReconcileAction: "capability-monitor-enabled",
};
let pollingStartedWithBus = true;
let classicStarts = 0;
let persisted = 0;
const events: Array<{ category: string; details?: Record<string, unknown> }> = [];
const store = {
async load() {},
async persist() {
persisted += 1;
},
getBotState() {
return state;
},
setBotState(next: typeof state) {
state = { ...state, ...next };
},
list() {
return [
{
status: "active",
target: { chatId: 42, threadId: 7 },
},
];
},
};
const deps = {
getAllowedUserId: () => 42,
callApi: async <TResponse,>(): Promise<TResponse> => ({}) as TResponse,
topicTargetStore: store,
isBusConfigured: () => true,
ownsLock: () => true,
getPollingStartedWithTelegramBus: () => pollingStartedWithBus,
setPollingStartedWithTelegramBus(started: boolean) {
pollingStartedWithBus = started;
},
setTopicModeUnavailable() {},
stopFollowerRegistration() {},
startClassicPolling() {
classicStarts += 1;
if (classicStarts === 1) throw new Error("classic unavailable");
},
async stopClassicPolling() {},
async startBusPolling() {},
async stopBusPolling() {},
startLeaderHealth() {},
stopLeaderHealth() {},
isTopicModeUnavailableError: () => false,
updateStatus() {},
recordEvent(category: string, _error: unknown, details?: Record<string, unknown>) {
events.push({ category, details });
},
};
await applyTelegramThreadCapability(
TEST_CONTEXT,
false,
"capability-monitor-disabled-confirmed",
deps,
);
assert.equal(classicStarts, 1);
assert.equal(
state.lastReconcileAction,
"capability-monitor-disabled-confirmed-classic-restore-failed",
);
assert.equal(pollingStartedWithBus, false);
await applyTelegramThreadCapability(
TEST_CONTEXT,
false,
"capability-monitor-disabled-confirmed",
deps,
);
assert.equal(classicStarts, 2);
assert.equal(state.lastReconcileAction, "capability-monitor-disabled-confirmed");
assert.equal(persisted >= 3, true);
assert.equal(events[0].details?.phase, "capability-monitor-disabled-confirmed-classic-restore");
});
test("Thread-aware polling still allows classic takeover path without thread bindings", async () => {
const callApi = async <TResponse,>(): Promise<TResponse> => ({}) as TResponse;
const store = {
async load() {},
async persist() {},
getBotState() {
return { threadMode: "disabled" as const };
},
setBotState() {},
list() {
return [];
},
};
const ports = createTelegramThreadAwarePollingPorts({
getAllowedUserId: () => 42,
callApi,
topicTargetStore: store,
isBusConfigured: () => true,
isBusRuntimeEnabled: () => false,
isTopicModeUnavailableError: () => false,
getPollingStartedWithTelegramBus: () => false,
setPollingStartedWithTelegramBus() {},
setForceFreshLeaderThreadOnNextStart() {},
setTopicModeUnavailable() {},
startClassicPolling() {},
async stopClassicPolling() {},
async startBusLeaderPolling() {},
async stopBusLeaderPolling() {},
startLeaderHealth() {},
stopLeaderHealth() {},
registerFollowerWithLeader: async () => true,
stopFollowerRegistration() {},
recordEvent() {},
});
assert.equal(
await ports.registerFollowerWithOwner?.(TEST_CONTEXT, { pid: 1 }),
undefined,
);
});
test("Polling helpers start only when a bot token exists and polling is idle", () => {
assert.equal(
shouldStartTelegramPolling({
hasBotToken: true,
hasPollingPromise: false,
}),
true,
);
assert.equal(
shouldStartTelegramPolling({
hasBotToken: false,
hasPollingPromise: false,
}),
false,
);
assert.equal(
shouldStartTelegramPolling({
hasBotToken: true,
hasPollingPromise: true,
}),
false,
);
});
test("Polling runtime starts and stops polling through state ports", async () => {
const events: string[] = [];
let pollingPromise: Promise<void> | undefined;
let pollingController: AbortController | undefined;
let finishPollLoop: (() => void) | undefined;
const deps = {
hasBotToken: () => true,
getPollingPromise: () => pollingPromise,
setPollingPromise: (promise: Promise<void> | undefined) => {
pollingPromise = promise;
events.push(`promise:${promise ? "set" : "clear"}`);
},
getPollingController: () => pollingController,
setPollingController: (controller: AbortController | undefined) => {
pollingController = controller;
events.push(`controller:${controller ? "set" : "clear"}`);
},
stopTypingLoop: () => {
events.push("typing:stop");
},
runPollLoop: async (_ctx: string, signal: AbortSignal) => {
events.push(`run:${signal.aborted}`);
await new Promise<void>((resolve) => {
finishPollLoop = resolve;
});
},
updateStatus: (ctx: string) => {
events.push(`status:${ctx}`);
},
};
startTelegramPollingRuntime("ctx", deps);
assert.equal(!!pollingPromise, true);
assert.equal(!!pollingController, true);
const stopPromise = stopTelegramPollingRuntime(deps);
assert.equal(pollingController?.signal.aborted, true);
assert.equal(!!pollingController, true);
finishPollLoop?.();
await stopPromise;
assert.deepEqual(events, [
"controller:set",
"run:false",
"promise:set",
"status:ctx",
"typing:stop",
"promise:clear",
"controller:clear",
"status:ctx",
]);
});
test("Polling runtime still aborts and settles when typing cleanup fails", async () => {
const events: string[] = [];
let pollingPromise: Promise<void> | undefined;
let pollingController: AbortController | undefined;
let finishPollLoop: (() => void) | undefined;
const deps = {
hasBotToken: () => true,
getPollingPromise: () => pollingPromise,
setPollingPromise: (promise: Promise<void> | undefined) => {
pollingPromise = promise;
events.push(`promise:${promise ? "set" : "clear"}`);
},
getPollingController: () => pollingController,
setPollingController: (controller: AbortController | undefined) => {
pollingController = controller;
events.push(`controller:${controller ? "set" : "clear"}`);
},
stopTypingLoop: () => {
events.push("typing:throw");
throw new Error("typing cleanup failed");
},
runPollLoop: async (_ctx: string, signal: AbortSignal) => {
await new Promise<void>((resolve) => {
finishPollLoop = () => {
events.push(`run-finish:${signal.aborted}`);
resolve();
};
});
},
updateStatus: () => {},
recordRuntimeEvent: (
category: string,
error: unknown,
details?: Record<string, unknown>,
) => {
events.push(
`${category}:${error instanceof Error ? error.message : String(error)}:${details?.phase}`,
);
},
};
startTelegramPollingRuntime("ctx", deps);
const stopPromise = stopTelegramPollingRuntime(deps);
assert.equal(pollingController?.signal.aborted, true);
finishPollLoop?.();
await stopPromise;
assert.deepEqual(events, [
"controller:set",
"promise:set",
"typing:throw",
"polling:typing cleanup failed:typing-stop",
"run-finish:true",
"promise:clear",
"controller:clear",
]);
});
test("Polling runtime ignores stale-context status failures during cleanup", async () => {
let pollingPromise: Promise<void> | undefined;
let pollingController: AbortController | undefined;
let statusCalls = 0;
const runtimeEvents: string[] = [];
const deps = {
hasBotToken: () => true,
getPollingPromise: () => pollingPromise,
setPollingPromise: (promise: Promise<void> | undefined) => {
pollingPromise = promise;
},
getPollingController: () => pollingController,
setPollingController: (controller: AbortController | undefined) => {
pollingController = controller;
},
stopTypingLoop: () => {},
runPollLoop: async () => {},
updateStatus: () => {
statusCalls += 1;
if (statusCalls > 1) throw new Error("stale ctx");
},
recordRuntimeEvent: (
category: string,
error: unknown,
details?: Record<string, unknown>,
) => {
const message = error instanceof Error ? error.message : String(error);
runtimeEvents.push(`${category}:${message}:${details?.phase}`);
},
};
startTelegramPollingRuntime("ctx", deps);
await pollingPromise;
assert.equal(statusCalls, 2);
assert.equal(pollingPromise, undefined);
assert.equal(pollingController, undefined);
assert.deepEqual(runtimeEvents, ["polling:stale ctx:status-update"]);
});
test("Polling runtime ignores stale-context status failures during start", () => {
let pollingPromise: Promise<void> | undefined;
let pollingController: AbortController | undefined;
const runtimeEvents: string[] = [];
const deps = {
hasBotToken: () => true,
getPollingPromise: () => pollingPromise,
setPollingPromise: (promise: Promise<void> | undefined) => {
pollingPromise = promise;
},
getPollingController: () => pollingController,
setPollingController: (controller: AbortController | undefined) => {
pollingController = controller;
},
stopTypingLoop: () => {},
runPollLoop: async () => {},
updateStatus: () => {
throw new Error("stale ctx");
},
recordRuntimeEvent: (
category: string,
error: unknown,
details?: Record<string, unknown>,
) => {
const message = error instanceof Error ? error.message : String(error);
runtimeEvents.push(`${category}:${message}:${details?.phase}`);
},
};
assert.doesNotThrow(() => startTelegramPollingRuntime("ctx", deps));
assert.equal(!!pollingPromise, true);
assert.equal(!!pollingController, true);
assert.deepEqual(runtimeEvents, ["polling:stale ctx:status-update"]);
});
test("Polling controller owns polling promise and abort-controller state", async () => {
const events: string[] = [];
let finishPollLoop: (() => void) | undefined;
const state = createTelegramPollingControllerState();
const isPollingActive = createTelegramPollingActivityReader(state);
const controller = createTelegramPollingController({
state,
hasBotToken: () => true,
stopTypingLoop: () => {
events.push("typing:stop");
},
runPollLoop: async (_ctx: string, signal: AbortSignal) => {
events.push(`run:${signal.aborted}`);
await new Promise<void>((resolve) => {
finishPollLoop = resolve;
});
},
updateStatus: (ctx: string) => {
events.push(`status:${ctx}`);
},
});
controller.start("ctx");
assert.equal(controller.isActive(), true);
assert.equal(isTelegramPollingControllerActive(state), true);
assert.equal(isPollingActive(), true);
controller.start("ctx");
const stopPromise = controller.stop();
finishPollLoop?.();
await stopPromise;
assert.equal(controller.isActive(), false);
assert.equal(isTelegramPollingControllerActive(state), false);
assert.equal(isPollingActive(), false);
assert.deepEqual(events, [
"run:false",
"status:ctx",
"typing:stop",
"status:ctx",
]);
});
test("Polling controller runtime binds loop runner and controller state", async () => {
const events: string[] = [];
const state = createTelegramPollingControllerState();
const controller = createTelegramPollingControllerRuntime({
state,
getConfig: () => ({ botToken: "123:abc" }),
hasBotToken: () => true,
deleteWebhook: async () => {
events.push("deleteWebhook");
},
getUpdates: async () => {
throw new DOMException("stop", "AbortError");
},
persistConfig: async () => {
events.push("persist");
},
handleUpdate: async () => {
events.push("handle");
},
stopTypingLoop: () => {
events.push("typing:stop");
},
updateStatus: (_ctx: string, message?: string) => {
events.push(`status:${message ?? "ok"}`);
},
});
controller.start("ctx");
assert.equal(controller.isActive(), true);
await controller.stop();
assert.equal(controller.isActive(), false);
assert.deepEqual(events, [
"deleteWebhook",
"status:ok",
"typing:stop",
"status:ok",
]);
});
test("Polling helpers stop only for abort conditions", () => {
assert.equal(shouldStopTelegramPolling(true, new Error("ignored")), true);
assert.equal(
shouldStopTelegramPolling(false, new DOMException("aborted", "AbortError")),
true,
);
assert.equal(shouldStopTelegramPolling(false, new Error("network")), false);
});
test("Poll loop runner binds config, status, and transport ports", async () => {
const config: { botToken: string; lastUpdateId?: number } = {
botToken: "123:abc",
lastUpdateId: 5,
};
const events: string[] = [];
let calls = 0;
const runPollLoop = createTelegramPollLoopRunner({
getConfig: () => config,
deleteWebhook: async () => {
events.push("deleteWebhook");
},
getUpdates: async () => {
calls += 1;
if (calls === 1) return [{ update_id: 6 }];
throw new DOMException("stop", "AbortError");
},
persistConfig: async () => {
events.push(`persist:${config.lastUpdateId}`);
},
handleUpdate: async (update, ctx: string) => {
events.push(`handle:${ctx}:${update.update_id}`);
},
updateStatus: (ctx, message) => {
events.push(`status:${ctx}:${message ?? "ok"}`);
},
sleep: async () => {
events.push("sleep");
},
});
await runPollLoop("ctx", new AbortController().signal);
assert.deepEqual(events, ["deleteWebhook", "handle:ctx:6", "persist:6"]);
});
test("Poll loop keeps the paired user when the offset is committed after the initial sync replaced the stored config", async () => {
const dir = mkdtempSync(join(tmpdir(), "telegram-pairing-"));
const configPath = join(dir, "telegram.json");
const store = createTelegramConfigStore({ agentDir: dir, configPath });
await store.load();
store.update((config) => {
config.botToken = "123:abc";
});
await store.persist();
let calls = 0;
const runPollLoop = createTelegramPollLoopRunner({
getConfig: store.get,
deleteWebhook: async () => {},
getUpdates: async () => {
calls += 1;
if (calls === 1) return [{ update_id: 1 }]; // initial sync
if (calls === 2) return [{ update_id: 2 }]; // the owner's /start
throw new DOMException("stop", "AbortError");
},
persistConfig: store.persist,
handleUpdate: async () => {
store.setAllowedUserId(777);
await store.persist();
},
updateStatus: () => {},
sleep: async () => {},
});
await runPollLoop("ctx", new AbortController().signal);
assert.equal(store.getAllowedUserId(), 777);
const saved = JSON.parse(readFileSync(configPath, "utf8"));
assert.equal(saved.allowedUserId, 777);
assert.equal(saved.lastUpdateId, 2);
});
test("Poll loop reports recovery once, only after a failed attempt", async () => {
const run = async (failures: number): Promise<string[]> => {
const events: string[] = [];
let calls = 0;
const runPollLoop = createTelegramPollLoopRunner({
getConfig: () => ({ botToken: "123:abc", lastUpdateId: 1 }),
deleteWebhook: async () => {},
getUpdates: async () => {
calls += 1;
if (calls <= failures) throw new Error("network down");
if (calls <= failures + 2) return [];
throw new DOMException("stop", "AbortError");
},
persistConfig: async () => {},
handleUpdate: async () => {},
updateStatus: (_ctx: string, message?: string) => {
events.push(`status:${message ?? "ok"}`);
},
onRecovered: (ctx: string) => {
events.push(`recovered:${ctx}`);
},
sleep: async () => {},
});
await runPollLoop("ctx", new AbortController().signal);
return events;
};
assert.deepEqual(await run(2), [
"status:network down",
"status:network down",
"status:ok",
"recovered:ctx",
]);
assert.deepEqual(await run(0), []);
});
test("Poll loop keeps polling when the recovery notice itself fails", async () => {
const recorded: string[] = [];
let calls = 0;
const runPollLoop = createTelegramPollLoopRunner({
getConfig: () => ({ botToken: "123:abc", lastUpdateId: 1 }),
deleteWebhook: async () => {},
getUpdates: async () => {
calls += 1;
if (calls === 1) throw new Error("network down");
if (calls === 2) return [];
throw new DOMException("stop", "AbortError");
},
persistConfig: async () => {},
handleUpdate: async () => {},
updateStatus: () => {},
onRecovered: () => {
throw new Error("toast broke");
},
sleep: async () => {},
recordRuntimeEvent: (category, error, details) => {
recorded.push(`${category}:${(error as Error).message}:${details?.phase}`);
},
});
await runPollLoop("ctx", new AbortController().signal);
assert.equal(calls, 3);
assert.ok(recorded.includes("polling:toast broke:recovered-notice"), recorded.join("|"));
});
test("Poll loop runner ignores stale-context status failures while retrying", async () => {
const config = { botToken: "123:abc", lastUpdateId: 1 };
const events: string[] = [];
const runtimeEvents: string[] = [];
let calls = 0;
const runPollLoop = createTelegramPollLoopRunner({
getConfig: () => config,
deleteWebhook: async () => {},
getUpdates: async () => {
calls += 1;
if (calls === 1) throw new Error("network down");
if (calls === 2) return [];
throw new DOMException("stop", "AbortError");
},
persistConfig: async () => {},
handleUpdate: async () => {},
updateStatus: (_ctx: string, message?: string) => {
events.push(`status:${message ?? "ok"}`);
throw new Error("stale ctx");
},
sleep: async (ms) => {
events.push(`sleep:${ms}`);
},
recordRuntimeEvent: (category, error, details) => {
const message = error instanceof Error ? error.message : String(error);
runtimeEvents.push(`${category}:${message}:${details?.phase}`);
},
});
await runPollLoop("ctx", new AbortController().signal);
assert.deepEqual(events, ["status:network down", "sleep:3000", "status:ok"]);
assert.deepEqual(runtimeEvents, [
"polling:network down:loop",
"polling:stale ctx:status-update",
"polling:stale ctx:status-update",
]);
});
test("Poll loop keeps the error status until a poll succeeds", async () => {
const events: string[] = [];
let calls = 0;
await runTelegramPollLoop({
ctx: TEST_CONTEXT,
signal: new AbortController().signal,
config: { botToken: "123:abc", lastUpdateId: 1 },
deleteWebhook: async () => {},
getUpdates: async () => {
calls += 1;
if (calls <= 2) throw new Error("rate limited");
if (calls === 3) return [];
throw new DOMException("stop", "AbortError");
},
persistConfig: async () => {},
handleUpdate: async () => {},
onErrorStatus: (message) => {
events.push(`error:${message}`);
},
onStatusReset: () => {
events.push("reset");
},
sleep: async () => {
events.push("sleep");
},
});
assert.deepEqual(events, [
"error:rate limited",
"sleep",
"error:rate limited",
"sleep",
"reset",
]);
});
test("Poll loop does not report recovery when only an update handler failed", async () => {
const events: string[] = [];
let calls = 0;
await runTelegramPollLoop({
ctx: TEST_CONTEXT,
signal: new AbortController().signal,
config: { botToken: "123:abc", lastUpdateId: 1 },
maxUpdateFailures: 3,
deleteWebhook: async () => {},
getUpdates: async () => {
calls += 1;
if (calls <= 3) return [{ update_id: 6 }];
throw new DOMException("stop", "AbortError");
},
persistConfig: async () => {},
handleUpdate: async () => {
throw new Error("handler failed");
},
onErrorStatus: (message) => {
events.push(`error:${message}`);
},
onStatusReset: () => {
events.push("reset");
},
onRecovered: () => {
events.push("recovered");
},
sleep: async () => {},
});
assert.equal(events.includes("recovered"), false, events.join("|"));
assert.ok(events.includes("reset"), events.join("|"));
});
test("Poll loop initializes lastUpdateId and processes updates", async () => {
const handled: number[] = [];
const config: { botToken: string; lastUpdateId?: number } = {
botToken: "123:abc",
};
let getUpdatesCalls = 0;
let persistCount = 0;
const signal = new AbortController().signal;
await runTelegramPollLoop({
ctx: TEST_CONTEXT,
signal,
config,
deleteWebhook: async () => {},
getUpdates: async () => {
getUpdatesCalls += 1;
if (getUpdatesCalls === 1) {
return [{ update_id: 5 }];
}
if (getUpdatesCalls === 2) {
return [{ update_id: 6 }, { update_id: 7 }];
}
throw new DOMException("stop", "AbortError");
},
persistConfig: async () => {
persistCount += 1;
},
handleUpdate: async (update) => {
handled.push(update.update_id);
},
onErrorStatus: () => {},
onStatusReset: () => {},
sleep: async () => {},
});
assert.equal(config.lastUpdateId, 7);
assert.deepEqual(handled, [6, 7]);
assert.equal(persistCount, 3);
});
test("Poll loop persists long-poll offsets only after handling updates", async () => {
const config = { botToken: "123:abc", lastUpdateId: 5 };
const handled: number[] = [];
const persisted: number[] = [];
let calls = 0;
await runTelegramPollLoop({
ctx: TEST_CONTEXT,
signal: new AbortController().signal,
config,
deleteWebhook: async () => {},
getUpdates: async () => {
calls += 1;
if (calls === 1) return [{ update_id: 6 }];
throw new DOMException("stop", "AbortError");
},
persistConfig: async () => {
persisted.push(config.lastUpdateId ?? -1);
},
handleUpdate: async (update) => {
handled.push(update.update_id);
throw new Error("handler failed");
},
onErrorStatus: () => {},
onStatusReset: () => {},
sleep: async () => {},
});
assert.deepEqual(handled, [6]);
assert.equal(config.lastUpdateId, 5);
assert.deepEqual(persisted, []);
});
test("Poll loop does not readmit an update after offset persistence fails", async () => {
const config = { botToken: "123:abc", lastUpdateId: 5 };
const handled: number[] = [];
let getUpdatesCalls = 0;
let persistCalls = 0;
await runTelegramPollLoop({
ctx: TEST_CONTEXT,
signal: new AbortController().signal,
config,
deleteWebhook: async () => {},
getUpdates: async () => {
getUpdatesCalls += 1;
if (getUpdatesCalls <= 2) return [{ update_id: 6 }];
throw new DOMException("stop", "AbortError");
},
persistConfig: async () => {
persistCalls += 1;
if (persistCalls === 1) throw new Error("config commit failed");
},
handleUpdate: async (update) => {
handled.push(update.update_id);
},
onErrorStatus: () => {},
onStatusReset: () => {},
sleep: async () => {},
});
assert.deepEqual(handled, [6]);
assert.equal(persistCalls, 2);
assert.equal(config.lastUpdateId, 6);
});
test("Poll loop skips repeatedly failing updates after the configured threshold", async () => {
const config = { botToken: "123:abc", lastUpdateId: 5 };
const persisted: number[] = [];
const statusMessages: string[] = [];
const runtimeEvents: string[] = [];
let calls = 0;
await runTelegramPollLoop({
ctx: TEST_CONTEXT,
signal: new AbortController().signal,
config,
maxUpdateFailures: 2,
deleteWebhook: async () => {},
getUpdates: async () => {
calls += 1;
if (calls <= 2) return [{ update_id: 6 }];
throw new DOMException("stop", "AbortError");
},
persistConfig: async () => {
persisted.push(config.lastUpdateId ?? -1);
},
handleUpdate: async () => {
throw new Error("handler failed");
},
onErrorStatus: (message) => {
statusMessages.push(message);
},
onStatusReset: () => {
statusMessages.push("reset");
},
sleep: async (ms) => {
statusMessages.push(`sleep:${ms}`);
},
recordRuntimeEvent: (category, error, details) => {
const message = error instanceof Error ? error.message : String(error);
runtimeEvents.push(
`${category}:${message}:${details?.phase}:${details?.failureCount}`,
);
},
});
assert.equal(config.lastUpdateId, 6);
assert.deepEqual(persisted, [6]);
assert.deepEqual(statusMessages, [
"handler failed",
"sleep:3000",
"reset",
"skipping Telegram update 6 after 2 failures: handler failed",
]);
assert.deepEqual(runtimeEvents, [
"polling:handler failed:handleUpdate:1",
"polling:handler failed:handleUpdate:2",
]);
});
test("Polling retry sleep resolves immediately when aborted", async () => {
const controller = new AbortController();
controller.abort();
await sleepTelegramPollingRetry(3000, controller.signal);
});
test("Poll loop stops without status reset when aborted during retry sleep", async () => {
const config = { botToken: "123:abc", lastUpdateId: 1 };
const controller = new AbortController();
const statusMessages: string[] = [];
let calls = 0;
await runTelegramPollLoop({
ctx: TEST_CONTEXT,
signal: controller.signal,
config,
deleteWebhook: async () => {},
getUpdates: async () => {
calls += 1;
throw new Error("network down");
},
persistConfig: async () => {},
handleUpdate: async () => {},
onErrorStatus: (message) => {
statusMessages.push(`error:${message}`);
},
onStatusReset: () => {
statusMessages.push("unexpected:reset");
},
sleep: async (_ms, signal) => {
assert.equal(signal, controller.signal);
controller.abort();
},
});
assert.equal(calls, 1);
assert.deepEqual(statusMessages, ["error:network down"]);
});
test("Poll loop suppresses getUpdates conflicts while another long poll drains", async () => {
const config = { botToken: "123:abc", lastUpdateId: 1 };
const statusMessages: string[] = [];
const runtimeEvents: string[] = [];
let calls = 0;
await runTelegramPollLoop({
ctx: TEST_CONTEXT,
signal: new AbortController().signal,
config,
deleteWebhook: async () => {},
getUpdates: async () => {
calls += 1;
if (calls <= 4) {
throw new Error(
"Telegram API getUpdates failed: HTTP 409: Conflict: terminated by other getUpdates request; make sure that only one bot instance is running",
);
}
throw new DOMException("stop", "AbortError");
},
persistConfig: async () => {},
handleUpdate: async () => {},
onErrorStatus: (message) => {
statusMessages.push(`error:${message}`);
},
onStatusReset: () => {
statusMessages.push("reset");
},
sleep: async (ms) => {
statusMessages.push(`sleep:${ms}`);
},
recordRuntimeEvent: (category, error, details) => {
const message = error instanceof Error ? error.message : String(error);
runtimeEvents.push(`${category}:${message}:${details?.phase}`);
},
});
assert.equal(
isTelegramGetUpdatesConflictError(
new Error("HTTP 409: Conflict: terminated by other getUpdates request"),
),
true,
);
assert.deepEqual(statusMessages, [
"sleep:1000",
"sleep:1000",
"sleep:3000",
"sleep:3000",
]);
assert.equal(runtimeEvents.length, 4);
});
test("Poll loop reports retryable errors and sleeps before retrying", async () => {
const config = { botToken: "123:abc", lastUpdateId: 1 };
const statusMessages: string[] = [];
const runtimeEvents: string[] = [];
let calls = 0;
await runTelegramPollLoop({
ctx: TEST_CONTEXT,
signal: new AbortController().signal,
config,
deleteWebhook: async () => {},
getUpdates: async () => {
calls += 1;
if (calls === 1) {
throw new Error("network down");
}
if (calls === 2) return [];
throw new DOMException("stop", "AbortError");
},
persistConfig: async () => {},
handleUpdate: async () => {},
onErrorStatus: (message) => {
statusMessages.push(`error:${message}`);
},
onStatusReset: () => {
statusMessages.push("reset");
},
sleep: async (ms) => {
statusMessages.push(`sleep:${ms}`);
},
recordRuntimeEvent: (category, error, details) => {
const message = error instanceof Error ? error.message : String(error);
runtimeEvents.push(`${category}:${message}:${details?.phase}`);
},
});
assert.deepEqual(statusMessages, [
"error:network down",
"sleep:3000",
"reset",
]);
assert.deepEqual(runtimeEvents, ["polling:network down:loop"]);
});