Validate / validate (push) Successful in 9m52s
- 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
1092 lines
33 KiB
TypeScript
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"]);
|
|
});
|