/** * 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(); 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 }> = []; const callApi = async (): Promise => ({}) 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 }> = []; 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 (): Promise => ({}) 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) { 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 (): Promise => ({}) 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 | undefined; let pollingController: AbortController | undefined; let finishPollLoop: (() => void) | undefined; const deps = { hasBotToken: () => true, getPollingPromise: () => pollingPromise, setPollingPromise: (promise: Promise | 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((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 | undefined; let pollingController: AbortController | undefined; let finishPollLoop: (() => void) | undefined; const deps = { hasBotToken: () => true, getPollingPromise: () => pollingPromise, setPollingPromise: (promise: Promise | 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((resolve) => { finishPollLoop = () => { events.push(`run-finish:${signal.aborted}`); resolve(); }; }); }, updateStatus: () => {}, recordRuntimeEvent: ( category: string, error: unknown, details?: Record, ) => { 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 | undefined; let pollingController: AbortController | undefined; let statusCalls = 0; const runtimeEvents: string[] = []; const deps = { hasBotToken: () => true, getPollingPromise: () => pollingPromise, setPollingPromise: (promise: Promise | 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, ) => { 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 | undefined; let pollingController: AbortController | undefined; const runtimeEvents: string[] = []; const deps = { hasBotToken: () => true, getPollingPromise: () => pollingPromise, setPollingPromise: (promise: Promise | 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, ) => { 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((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 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"); 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 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"); } 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"]); });