From cc359d338ef959f7a706244922c5c8658ffe2282 Mon Sep 17 00:00:00 2001
From: Sebastian <19554889+sebslight@users.noreply.github.com>
Date: Tue, 17 Feb 2026 09:01:30 -0500
Subject: [PATCH] test: add fetch mock helper and reaction coverage
---
CHANGELOG.md | 1 +
src/agents/chutes-oauth.e2e.test.ts | 9 ++--
...minimax-vlm.normalizes-api-key.e2e.test.ts | 3 +-
src/agents/model-scan.e2e.test.ts | 13 +++--
src/agents/tools/image-tool.e2e.test.ts | 7 +--
.../tools/web-fetch.cf-markdown.test.ts | 13 ++---
...irecrawl-api-key-normalization.e2e.test.ts | 3 +-
.../tools/web-fetch.response-limit.test.ts | 3 +-
src/agents/tools/web-fetch.ssrf.e2e.test.ts | 3 +-
.../web-tools.enabled-defaults.e2e.test.ts | 3 +-
src/agents/tools/web-tools.fetch.e2e.test.ts | 3 +-
...-tab-available.prefers-last-target.test.ts | 3 +-
.../server-context.remote-tab-ops.test.ts | 13 ++---
src/commands/chutes-oauth.e2e.test.ts | 7 +--
src/discord/api.test.ts | 18 ++++---
src/discord/resolve-channels.test.ts | 17 ++++---
src/infra/fetch.test.ts | 49 +++++++++++--------
.../provider-usage.fetch.antigravity.test.ts | 7 ++-
.../media-understanding-misc.test.ts | 3 +-
.../providers/deepgram/audio.test.ts | 9 ++--
.../providers/google/video.test.ts | 9 ++--
.../providers/openai/audio.test.ts | 9 ++--
src/memory/embeddings-voyage.test.ts | 35 +++++++------
src/memory/manager.batch.test.ts | 5 +-
src/slack/monitor/media.test.ts | 7 +--
src/telegram/bot.test.ts | 38 ++++++++++++++
src/telegram/probe.test.ts | 3 +-
src/test-utils/fetch-mock.ts | 6 +++
28 files changed, 193 insertions(+), 106 deletions(-)
create mode 100644 src/test-utils/fetch-mock.ts
diff --git a/CHANGELOG.md b/CHANGELOG.md
index f80c8a79c7..f414ce9bb9 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -10,6 +10,7 @@ Docs: https://docs.openclaw.ai
- iOS/Talk: harden barge-in behavior by disabling interrupt-on-speech when output route is built-in speaker/receiver, reducing false interruptions from local TTS bleed-through. Thanks @zeulewan.
- iOS/Talk: add a `Voice Directive Hint` toggle for Talk Mode prompts so users can disable ElevenLabs voice-switching instructions to save tokens when not needed. (#18250) Thanks @zeulewan.
- Telegram/Agents: add inline button `style` support (`primary|success|danger`) across message tool schema, Telegram action parsing, send pipeline, and runtime prompt guidance. (#18241) Thanks @obviyus.
+- Telegram: surface user message reactions as system events, with configurable `channels.telegram.reactionNotifications` scope. (#10075) Thanks @Glucksberg.
- Discord: expose native `/exec` command options (host/security/ask/node) so Discord slash commands get autocomplete and structured inputs. Thanks @thewilloftheshadow.
- Discord: allow reusable interactive components with `components.reusable=true` so buttons, selects, and forms can be used multiple times before expiring. Thanks @thewilloftheshadow.
- Cron/Gateway: separate per-job webhook delivery (`delivery.mode = "webhook"`) from announce delivery, enforce valid HTTP(S) webhook URLs, and keep a temporary legacy `notify + cron.webhook` fallback for stored jobs. (#17901) Thanks @advaitpaliwal.
diff --git a/src/agents/chutes-oauth.e2e.test.ts b/src/agents/chutes-oauth.e2e.test.ts
index 70721bff75..dd960b4998 100644
--- a/src/agents/chutes-oauth.e2e.test.ts
+++ b/src/agents/chutes-oauth.e2e.test.ts
@@ -1,4 +1,5 @@
import { describe, expect, it } from "vitest";
+import { withFetchPreconnect } from "../test-utils/fetch-mock.js";
import {
CHUTES_TOKEN_ENDPOINT,
CHUTES_USERINFO_ENDPOINT,
@@ -15,7 +16,7 @@ const urlToString = (url: Request | URL | string): string => {
describe("chutes-oauth", () => {
it("exchanges code for tokens and stores username as email", async () => {
- const fetchFn: typeof fetch = async (input, init) => {
+ const fetchFn = withFetchPreconnect(async (input, init) => {
const url = urlToString(input);
if (url === CHUTES_TOKEN_ENDPOINT) {
expect(init?.method).toBe("POST");
@@ -41,7 +42,7 @@ describe("chutes-oauth", () => {
});
}
return new Response("not found", { status: 404 });
- };
+ });
const now = 1_000_000;
const creds = await exchangeChutesCodeForTokens({
@@ -65,7 +66,7 @@ describe("chutes-oauth", () => {
});
it("refreshes tokens using stored client id and falls back to old refresh token", async () => {
- const fetchFn: typeof fetch = async (input, init) => {
+ const fetchFn = withFetchPreconnect(async (input, init) => {
const url = urlToString(input);
if (url !== CHUTES_TOKEN_ENDPOINT) {
return new Response("not found", { status: 404 });
@@ -82,7 +83,7 @@ describe("chutes-oauth", () => {
}),
{ status: 200, headers: { "Content-Type": "application/json" } },
);
- };
+ });
const now = 2_000_000;
const refreshed = await refreshChutesTokens({
diff --git a/src/agents/minimax-vlm.normalizes-api-key.e2e.test.ts b/src/agents/minimax-vlm.normalizes-api-key.e2e.test.ts
index 50a8878f37..1b414370ee 100644
--- a/src/agents/minimax-vlm.normalizes-api-key.e2e.test.ts
+++ b/src/agents/minimax-vlm.normalizes-api-key.e2e.test.ts
@@ -1,4 +1,5 @@
import { afterEach, describe, expect, it, vi } from "vitest";
+import { withFetchPreconnect } from "../test-utils/fetch-mock.js";
describe("minimaxUnderstandImage apiKey normalization", () => {
const priorFetch = global.fetch;
@@ -21,7 +22,7 @@ describe("minimaxUnderstandImage apiKey normalization", () => {
{ status: 200, headers: { "Content-Type": "application/json" } },
);
});
- global.fetch = fetchSpy;
+ global.fetch = withFetchPreconnect(fetchSpy);
const { minimaxUnderstandImage } = await import("./minimax-vlm.js");
const text = await minimaxUnderstandImage({
diff --git a/src/agents/model-scan.e2e.test.ts b/src/agents/model-scan.e2e.test.ts
index 59f50861ad..87c457445e 100644
--- a/src/agents/model-scan.e2e.test.ts
+++ b/src/agents/model-scan.e2e.test.ts
@@ -1,13 +1,16 @@
import { describe, expect, it } from "vitest";
import { captureEnv } from "../test-utils/env.js";
+import { withFetchPreconnect } from "../test-utils/fetch-mock.js";
import { scanOpenRouterModels } from "./model-scan.js";
function createFetchFixture(payload: unknown): typeof fetch {
- return async () =>
- new Response(JSON.stringify(payload), {
- status: 200,
- headers: { "content-type": "application/json" },
- });
+ return withFetchPreconnect(
+ async () =>
+ new Response(JSON.stringify(payload), {
+ status: 200,
+ headers: { "content-type": "application/json" },
+ }),
+ );
}
describe("scanOpenRouterModels", () => {
diff --git a/src/agents/tools/image-tool.e2e.test.ts b/src/agents/tools/image-tool.e2e.test.ts
index da96786914..169b03fa15 100644
--- a/src/agents/tools/image-tool.e2e.test.ts
+++ b/src/agents/tools/image-tool.e2e.test.ts
@@ -4,6 +4,7 @@ import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../../config/config.js";
import type { ModelDefinitionConfig } from "../../config/types.models.js";
+import { withFetchPreconnect } from "../../test-utils/fetch-mock.js";
import { createOpenClawCodingTools } from "../pi-tools.js";
import { createHostSandboxFsBridge } from "../test-helpers/host-sandbox-fs-bridge.js";
import { __testing, createImageTool, resolveImageModelConfigForTool } from "./image-tool.js";
@@ -47,7 +48,7 @@ function stubMinimaxOkFetch() {
base_resp: { status_code: 0, status_msg: "" },
}),
});
- global.fetch = fetch;
+ global.fetch = withFetchPreconnect(fetch);
vi.stubEnv("MINIMAX_API_KEY", "minimax-test");
return fetch;
}
@@ -414,7 +415,7 @@ describe("image tool implicit imageModel config", () => {
base_resp: { status_code: 0, status_msg: "" },
}),
});
- global.fetch = fetch;
+ global.fetch = withFetchPreconnect(fetch);
vi.stubEnv("MINIMAX_API_KEY", "minimax-test");
const cfg: OpenClawConfig = {
@@ -487,7 +488,7 @@ describe("image tool MiniMax VLM routing", () => {
base_resp: baseResp,
}),
});
- global.fetch = fetch;
+ global.fetch = withFetchPreconnect(fetch);
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-minimax-vlm-"));
vi.stubEnv("MINIMAX_API_KEY", "minimax-test");
diff --git a/src/agents/tools/web-fetch.cf-markdown.test.ts b/src/agents/tools/web-fetch.cf-markdown.test.ts
index 2afdd24346..6e7768fc43 100644
--- a/src/agents/tools/web-fetch.cf-markdown.test.ts
+++ b/src/agents/tools/web-fetch.cf-markdown.test.ts
@@ -1,5 +1,6 @@
import { describe, expect, it, vi } from "vitest";
import * as logger from "../../logger.js";
+import { withFetchPreconnect } from "../../test-utils/fetch-mock.js";
import {
createBaseWebFetchToolConfig,
installWebFetchSsrfHarness,
@@ -37,7 +38,7 @@ function htmlResponse(body: string): Response {
describe("web_fetch Cloudflare Markdown for Agents", () => {
it("sends Accept header preferring text/markdown", async () => {
const fetchSpy = vi.fn().mockResolvedValue(markdownResponse("# Test Page\n\nHello world."));
- global.fetch = fetchSpy;
+ global.fetch = withFetchPreconnect(fetchSpy);
const tool = createWebFetchTool(baseToolConfig);
@@ -51,7 +52,7 @@ describe("web_fetch Cloudflare Markdown for Agents", () => {
it("uses cf-markdown extractor for text/markdown responses", async () => {
const md = "# CF Markdown\n\nThis is server-rendered markdown.";
const fetchSpy = vi.fn().mockResolvedValue(markdownResponse(md));
- global.fetch = fetchSpy;
+ global.fetch = withFetchPreconnect(fetchSpy);
const tool = createWebFetchTool(baseToolConfig);
@@ -73,7 +74,7 @@ describe("web_fetch Cloudflare Markdown for Agents", () => {
const html =
"
HTML Page
Content here.
";
const fetchSpy = vi.fn().mockResolvedValue(htmlResponse(html));
- global.fetch = fetchSpy;
+ global.fetch = withFetchPreconnect(fetchSpy);
const tool = createWebFetchTool(baseToolConfig);
@@ -88,7 +89,7 @@ describe("web_fetch Cloudflare Markdown for Agents", () => {
const fetchSpy = vi
.fn()
.mockResolvedValue(markdownResponse("# Tokens Test", { "x-markdown-tokens": "1500" }));
- global.fetch = fetchSpy;
+ global.fetch = withFetchPreconnect(fetchSpy);
const tool = createWebFetchTool(baseToolConfig);
@@ -108,7 +109,7 @@ describe("web_fetch Cloudflare Markdown for Agents", () => {
it("converts markdown to text when extractMode is text", async () => {
const md = "# Heading\n\n**Bold text** and [a link](https://example.com).";
const fetchSpy = vi.fn().mockResolvedValue(markdownResponse(md));
- global.fetch = fetchSpy;
+ global.fetch = withFetchPreconnect(fetchSpy);
const tool = createWebFetchTool(baseToolConfig);
@@ -132,7 +133,7 @@ describe("web_fetch Cloudflare Markdown for Agents", () => {
it("does not log x-markdown-tokens when header is absent", async () => {
const logSpy = vi.spyOn(logger, "logDebug").mockImplementation(() => {});
const fetchSpy = vi.fn().mockResolvedValue(markdownResponse("# No tokens"));
- global.fetch = fetchSpy;
+ global.fetch = withFetchPreconnect(fetchSpy);
const tool = createWebFetchTool(baseToolConfig);
diff --git a/src/agents/tools/web-fetch.firecrawl-api-key-normalization.e2e.test.ts b/src/agents/tools/web-fetch.firecrawl-api-key-normalization.e2e.test.ts
index fccff3a9bd..dd477c2b08 100644
--- a/src/agents/tools/web-fetch.firecrawl-api-key-normalization.e2e.test.ts
+++ b/src/agents/tools/web-fetch.firecrawl-api-key-normalization.e2e.test.ts
@@ -1,4 +1,5 @@
import { afterEach, describe, expect, it, vi } from "vitest";
+import { withFetchPreconnect } from "../../test-utils/fetch-mock.js";
vi.mock("../../infra/net/fetch-guard.js", () => {
return {
@@ -33,7 +34,7 @@ describe("web_fetch firecrawl apiKey normalization", () => {
);
});
- global.fetch = fetchSpy;
+ global.fetch = withFetchPreconnect(fetchSpy);
const { createWebFetchTool } = await import("./web-tools.js");
const tool = createWebFetchTool({
diff --git a/src/agents/tools/web-fetch.response-limit.test.ts b/src/agents/tools/web-fetch.response-limit.test.ts
index 48e96b994b..9b246b8a67 100644
--- a/src/agents/tools/web-fetch.response-limit.test.ts
+++ b/src/agents/tools/web-fetch.response-limit.test.ts
@@ -1,4 +1,5 @@
import { describe, expect, it, vi } from "vitest";
+import { withFetchPreconnect } from "../../test-utils/fetch-mock.js";
import {
createBaseWebFetchToolConfig,
installWebFetchSsrfHarness,
@@ -23,7 +24,7 @@ describe("web_fetch response size limits", () => {
});
const fetchSpy = vi.fn().mockResolvedValue(response);
- global.fetch = fetchSpy;
+ global.fetch = withFetchPreconnect(fetchSpy);
const tool = createWebFetchTool(baseToolConfig);
const result = await tool?.execute?.("call", { url: "https://example.com/stream" });
diff --git a/src/agents/tools/web-fetch.ssrf.e2e.test.ts b/src/agents/tools/web-fetch.ssrf.e2e.test.ts
index db6c031f7b..e61b56952b 100644
--- a/src/agents/tools/web-fetch.ssrf.e2e.test.ts
+++ b/src/agents/tools/web-fetch.ssrf.e2e.test.ts
@@ -1,5 +1,6 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import * as ssrf from "../../infra/net/ssrf.js";
+import { withFetchPreconnect } from "../../test-utils/fetch-mock.js";
const lookupMock = vi.fn();
const resolvePinnedHostname = ssrf.resolvePinnedHostname;
@@ -30,7 +31,7 @@ function textResponse(body: string): Response {
function setMockFetch(impl?: (...args: unknown[]) => unknown) {
const fetchSpy = vi.fn(impl);
- global.fetch = fetchSpy as typeof fetch;
+ global.fetch = withFetchPreconnect(fetchSpy);
return fetchSpy;
}
diff --git a/src/agents/tools/web-tools.enabled-defaults.e2e.test.ts b/src/agents/tools/web-tools.enabled-defaults.e2e.test.ts
index ff160d5808..b6f2f2f24e 100644
--- a/src/agents/tools/web-tools.enabled-defaults.e2e.test.ts
+++ b/src/agents/tools/web-tools.enabled-defaults.e2e.test.ts
@@ -1,4 +1,5 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { withFetchPreconnect } from "../../test-utils/fetch-mock.js";
import { createWebFetchTool, createWebSearchTool } from "./web-tools.js";
function installMockFetch(payload: unknown) {
@@ -8,7 +9,7 @@ function installMockFetch(payload: unknown) {
json: () => Promise.resolve(payload),
} as Response),
);
- global.fetch = mockFetch;
+ global.fetch = withFetchPreconnect(mockFetch);
return mockFetch;
}
diff --git a/src/agents/tools/web-tools.fetch.e2e.test.ts b/src/agents/tools/web-tools.fetch.e2e.test.ts
index 1ef58bf4ab..7764322449 100644
--- a/src/agents/tools/web-tools.fetch.e2e.test.ts
+++ b/src/agents/tools/web-tools.fetch.e2e.test.ts
@@ -1,5 +1,6 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import * as ssrf from "../../infra/net/ssrf.js";
+import { withFetchPreconnect } from "../../test-utils/fetch-mock.js";
import { createWebFetchTool } from "./web-tools.js";
type MockResponse = {
@@ -92,7 +93,7 @@ function requestUrl(input: RequestInfo | URL): string {
function installMockFetch(impl: (input: RequestInfo | URL) => Promise) {
const mockFetch = vi.fn(async (input: RequestInfo | URL) => await impl(input));
- global.fetch = mockFetch as typeof global.fetch;
+ global.fetch = withFetchPreconnect(mockFetch);
return mockFetch;
}
diff --git a/src/browser/server-context.ensure-tab-available.prefers-last-target.test.ts b/src/browser/server-context.ensure-tab-available.prefers-last-target.test.ts
index 46e0be4a51..b3f15680de 100644
--- a/src/browser/server-context.ensure-tab-available.prefers-last-target.test.ts
+++ b/src/browser/server-context.ensure-tab-available.prefers-last-target.test.ts
@@ -1,4 +1,5 @@
import { describe, expect, it, vi } from "vitest";
+import { withFetchPreconnect } from "../test-utils/fetch-mock.js";
import type { BrowserServerState } from "./server-context.js";
import "./server-context.chrome-test-harness.js";
import { createBrowserRouteContext } from "./server-context.js";
@@ -56,7 +57,7 @@ function stubChromeJsonList(responses: unknown[]) {
} as unknown as Response;
});
- global.fetch = fetchMock;
+ global.fetch = withFetchPreconnect(fetchMock);
return fetchMock;
}
diff --git a/src/browser/server-context.remote-tab-ops.test.ts b/src/browser/server-context.remote-tab-ops.test.ts
index 9d7f876bef..42cd53c1eb 100644
--- a/src/browser/server-context.remote-tab-ops.test.ts
+++ b/src/browser/server-context.remote-tab-ops.test.ts
@@ -1,4 +1,5 @@
import { afterEach, describe, expect, it, vi } from "vitest";
+import { withFetchPreconnect } from "../test-utils/fetch-mock.js";
import * as cdpModule from "./cdp.js";
import * as pwAiModule from "./pw-ai-module.js";
import type { BrowserServerState } from "./server-context.js";
@@ -70,7 +71,7 @@ describe("browser server-context remote profile tab operations", () => {
throw new Error("unexpected fetch");
});
- global.fetch = fetchMock;
+ global.fetch = withFetchPreconnect(fetchMock);
const state = makeState("remote");
const ctx = createBrowserRouteContext({ getState: () => state });
@@ -135,7 +136,7 @@ describe("browser server-context remote profile tab operations", () => {
throw new Error("unexpected fetch");
});
- global.fetch = fetchMock;
+ global.fetch = withFetchPreconnect(fetchMock);
const state = makeState("remote");
const ctx = createBrowserRouteContext({ getState: () => state });
@@ -162,7 +163,7 @@ describe("browser server-context remote profile tab operations", () => {
throw new Error("unexpected fetch");
});
- global.fetch = fetchMock;
+ global.fetch = withFetchPreconnect(fetchMock);
const state = makeState("remote");
const ctx = createBrowserRouteContext({ getState: () => state });
@@ -188,7 +189,7 @@ describe("browser server-context remote profile tab operations", () => {
throw new Error("unexpected fetch");
});
- global.fetch = fetchMock;
+ global.fetch = withFetchPreconnect(fetchMock);
const state = makeState("remote");
const ctx = createBrowserRouteContext({ getState: () => state });
@@ -220,7 +221,7 @@ describe("browser server-context remote profile tab operations", () => {
} as unknown as Response;
});
- global.fetch = fetchMock;
+ global.fetch = withFetchPreconnect(fetchMock);
const state = makeState("remote");
const ctx = createBrowserRouteContext({ getState: () => state });
@@ -255,7 +256,7 @@ describe("browser server-context tab selection state", () => {
} as unknown as Response;
});
- global.fetch = fetchMock;
+ global.fetch = withFetchPreconnect(fetchMock);
const state = makeState("openclaw");
const ctx = createBrowserRouteContext({ getState: () => state });
diff --git a/src/commands/chutes-oauth.e2e.test.ts b/src/commands/chutes-oauth.e2e.test.ts
index 254d25177e..812f0ba0a5 100644
--- a/src/commands/chutes-oauth.e2e.test.ts
+++ b/src/commands/chutes-oauth.e2e.test.ts
@@ -1,6 +1,7 @@
import net from "node:net";
import { describe, expect, it, vi } from "vitest";
import { CHUTES_TOKEN_ENDPOINT, CHUTES_USERINFO_ENDPOINT } from "../agents/chutes-oauth.js";
+import { withFetchPreconnect } from "../test-utils/fetch-mock.js";
import { loginChutes } from "./chutes-oauth.js";
async function getFreePort(): Promise {
@@ -32,7 +33,7 @@ function createOAuthFetchFn(params: {
username: string;
passthrough?: boolean;
}): typeof fetch {
- return async (input, init) => {
+ return withFetchPreconnect(async (input, init) => {
const url = urlToString(input);
if (url === CHUTES_TOKEN_ENDPOINT) {
return new Response(
@@ -54,7 +55,7 @@ function createOAuthFetchFn(params: {
return fetch(input, init);
}
return new Response("not found", { status: 404 });
- };
+ });
}
describe("loginChutes", () => {
@@ -157,7 +158,7 @@ describe("loginChutes", () => {
});
it("rejects pasted redirect URLs missing state", async () => {
- const fetchFn: typeof fetch = async () => new Response("not found", { status: 404 });
+ const fetchFn = withFetchPreconnect(async () => new Response("not found", { status: 404 }));
await expect(
loginChutes({
diff --git a/src/discord/api.test.ts b/src/discord/api.test.ts
index 557f794239..a737e47bf3 100644
--- a/src/discord/api.test.ts
+++ b/src/discord/api.test.ts
@@ -1,4 +1,5 @@
import { describe, expect, it } from "vitest";
+import { withFetchPreconnect } from "../test-utils/fetch-mock.js";
import { fetchDiscord } from "./api.js";
function jsonResponse(body: unknown, status = 200) {
@@ -7,7 +8,7 @@ function jsonResponse(body: unknown, status = 200) {
describe("fetchDiscord", () => {
it("formats rate limit payloads without raw JSON", async () => {
- const fetcher = async () =>
+ const fetcher = withFetchPreconnect(async () =>
jsonResponse(
{
message: "You are being rate limited.",
@@ -15,11 +16,12 @@ describe("fetchDiscord", () => {
global: false,
},
429,
- );
+ ),
+ );
let error: unknown;
try {
- await fetchDiscord("/users/@me/guilds", "test", fetcher as typeof fetch, {
+ await fetchDiscord("/users/@me/guilds", "test", fetcher, {
retry: { attempts: 1 },
});
} catch (err) {
@@ -35,9 +37,9 @@ describe("fetchDiscord", () => {
});
it("preserves non-JSON error text", async () => {
- const fetcher = async () => new Response("Not Found", { status: 404 });
+ const fetcher = withFetchPreconnect(async () => new Response("Not Found", { status: 404 }));
await expect(
- fetchDiscord("/users/@me/guilds", "test", fetcher as typeof fetch, {
+ fetchDiscord("/users/@me/guilds", "test", fetcher, {
retry: { attempts: 1 },
}),
).rejects.toThrow("Discord API /users/@me/guilds failed (404): Not Found");
@@ -45,7 +47,7 @@ describe("fetchDiscord", () => {
it("retries rate limits before succeeding", async () => {
let calls = 0;
- const fetcher = async () => {
+ const fetcher = withFetchPreconnect(async () => {
calls += 1;
if (calls === 1) {
return jsonResponse(
@@ -58,12 +60,12 @@ describe("fetchDiscord", () => {
);
}
return jsonResponse([{ id: "1", name: "Guild" }], 200);
- };
+ });
const result = await fetchDiscord>(
"/users/@me/guilds",
"test",
- fetcher as typeof fetch,
+ fetcher,
{ retry: { attempts: 2, minDelayMs: 0, maxDelayMs: 0 } },
);
diff --git a/src/discord/resolve-channels.test.ts b/src/discord/resolve-channels.test.ts
index 5f5163f070..89ca9b416f 100644
--- a/src/discord/resolve-channels.test.ts
+++ b/src/discord/resolve-channels.test.ts
@@ -1,4 +1,5 @@
import { describe, expect, it } from "vitest";
+import { withFetchPreconnect } from "../test-utils/fetch-mock.js";
import { resolveDiscordChannelAllowlist } from "./resolve-channels.js";
function jsonResponse(body: unknown) {
@@ -14,7 +15,7 @@ const urlToString = (url: Request | URL | string): string => {
describe("resolveDiscordChannelAllowlist", () => {
it("resolves guild/channel by name", async () => {
- const fetcher = async (input: RequestInfo | URL) => {
+ const fetcher = withFetchPreconnect(async (input: RequestInfo | URL) => {
const url = urlToString(input);
if (url.endsWith("/users/@me/guilds")) {
return jsonResponse([{ id: "g1", name: "My Guild" }]);
@@ -26,7 +27,7 @@ describe("resolveDiscordChannelAllowlist", () => {
]);
}
return new Response("not found", { status: 404 });
- };
+ });
const res = await resolveDiscordChannelAllowlist({
token: "test",
@@ -40,7 +41,7 @@ describe("resolveDiscordChannelAllowlist", () => {
});
it("resolves channel id to guild", async () => {
- const fetcher = async (input: RequestInfo | URL) => {
+ const fetcher = withFetchPreconnect(async (input: RequestInfo | URL) => {
const url = urlToString(input);
if (url.endsWith("/users/@me/guilds")) {
return jsonResponse([{ id: "g1", name: "Guild One" }]);
@@ -49,7 +50,7 @@ describe("resolveDiscordChannelAllowlist", () => {
return jsonResponse({ id: "123", name: "general", guild_id: "g1", type: 0 });
}
return new Response("not found", { status: 404 });
- };
+ });
const res = await resolveDiscordChannelAllowlist({
token: "test",
@@ -63,7 +64,7 @@ describe("resolveDiscordChannelAllowlist", () => {
});
it("resolves guild: prefixed id as guild (not channel)", async () => {
- const fetcher = async (input: RequestInfo | URL) => {
+ const fetcher = withFetchPreconnect(async (input: RequestInfo | URL) => {
const url = urlToString(input);
if (url.endsWith("/users/@me/guilds")) {
return jsonResponse([{ id: "111222333444555666", name: "Guild One" }]);
@@ -73,7 +74,7 @@ describe("resolveDiscordChannelAllowlist", () => {
throw new Error("guild id was incorrectly routed to /channels/");
}
return new Response("not found", { status: 404 });
- };
+ });
const res = await resolveDiscordChannelAllowlist({
token: "test",
@@ -90,7 +91,7 @@ describe("resolveDiscordChannelAllowlist", () => {
// Demonstrates why provider.ts must prefix guild-only entries with "guild:"
// In reality, Discord returns 404 when a guild ID is sent to /channels/,
// which causes fetchDiscord to throw and the entire resolver to crash.
- const fetcher = async (input: RequestInfo | URL) => {
+ const fetcher = withFetchPreconnect(async (input: RequestInfo | URL) => {
const url = urlToString(input);
if (url.endsWith("/users/@me/guilds")) {
return jsonResponse([{ id: "999", name: "My Server" }]);
@@ -100,7 +101,7 @@ describe("resolveDiscordChannelAllowlist", () => {
return new Response(JSON.stringify({ message: "Unknown Channel" }), { status: 404 });
}
return new Response("not found", { status: 404 });
- };
+ });
// Without the guild: prefix, a bare numeric string hits /channels/999 → 404 → throws
await expect(
diff --git a/src/infra/fetch.test.ts b/src/infra/fetch.test.ts
index b46d75e427..3da1399944 100644
--- a/src/infra/fetch.test.ts
+++ b/src/infra/fetch.test.ts
@@ -1,4 +1,5 @@
import { describe, expect, it, vi } from "vitest";
+import { withFetchPreconnect } from "../test-utils/fetch-mock.js";
import { resolveFetch, wrapFetchWithAbortSignal } from "./fetch.js";
function createForeignSignalHarness() {
@@ -29,10 +30,12 @@ function createForeignSignalHarness() {
describe("wrapFetchWithAbortSignal", () => {
it("adds duplex for requests with a body", async () => {
let seenInit: RequestInit | undefined;
- const fetchImpl = vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => {
- seenInit = init;
- return {} as Response;
- });
+ const fetchImpl = withFetchPreconnect(
+ vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => {
+ seenInit = init;
+ return {} as Response;
+ }),
+ );
const wrapped = wrapFetchWithAbortSignal(fetchImpl);
@@ -43,10 +46,12 @@ describe("wrapFetchWithAbortSignal", () => {
it("converts foreign abort signals to native controllers", async () => {
let seenSignal: AbortSignal | undefined;
- const fetchImpl = vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => {
- seenSignal = init?.signal as AbortSignal | undefined;
- return {} as Response;
- });
+ const fetchImpl = withFetchPreconnect(
+ vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => {
+ seenSignal = init?.signal as AbortSignal | undefined;
+ return {} as Response;
+ }),
+ );
const wrapped = wrapFetchWithAbortSignal(fetchImpl);
@@ -71,8 +76,8 @@ describe("wrapFetchWithAbortSignal", () => {
process.on("unhandledRejection", onUnhandled);
const fetchError = new TypeError("fetch failed");
- const fetchImpl = vi.fn((_input: RequestInfo | URL, _init?: RequestInit) =>
- Promise.reject(fetchError),
+ const fetchImpl = withFetchPreconnect(
+ vi.fn((_input: RequestInfo | URL, _init?: RequestInit) => Promise.reject(fetchError)),
);
const wrapped = wrapFetchWithAbortSignal(fetchImpl);
@@ -92,9 +97,11 @@ describe("wrapFetchWithAbortSignal", () => {
it("cleans up listener and rethrows when fetch throws synchronously", () => {
const syncError = new TypeError("sync fetch failure");
- const fetchImpl = vi.fn(() => {
- throw syncError;
- });
+ const fetchImpl = withFetchPreconnect(
+ vi.fn(() => {
+ throw syncError;
+ }),
+ );
const wrapped = wrapFetchWithAbortSignal(fetchImpl);
const { fakeSignal, removeEventListener } = createForeignSignalHarness();
@@ -106,8 +113,8 @@ describe("wrapFetchWithAbortSignal", () => {
it("preserves original rejection when listener cleanup throws", async () => {
const fetchError = new TypeError("fetch failed");
const cleanupError = new TypeError("cleanup failed");
- const fetchImpl = vi.fn((_input: RequestInfo | URL, _init?: RequestInit) =>
- Promise.reject(fetchError),
+ const fetchImpl = withFetchPreconnect(
+ vi.fn((_input: RequestInfo | URL, _init?: RequestInit) => Promise.reject(fetchError)),
);
const wrapped = wrapFetchWithAbortSignal(fetchImpl);
@@ -128,9 +135,11 @@ describe("wrapFetchWithAbortSignal", () => {
it("preserves original sync throw when listener cleanup throws", () => {
const syncError = new TypeError("sync fetch failure");
const cleanupError = new TypeError("cleanup failed");
- const fetchImpl = vi.fn(() => {
- throw syncError;
- });
+ const fetchImpl = withFetchPreconnect(
+ vi.fn(() => {
+ throw syncError;
+ }),
+ );
const wrapped = wrapFetchWithAbortSignal(fetchImpl);
const removeEventListener = vi.fn(() => {
@@ -150,7 +159,7 @@ describe("wrapFetchWithAbortSignal", () => {
it("skips listener cleanup when foreign signal is already aborted", async () => {
const addEventListener = vi.fn();
const removeEventListener = vi.fn();
- const fetchImpl = vi.fn(async () => ({ ok: true }) as Response);
+ const fetchImpl = withFetchPreconnect(vi.fn(async () => ({ ok: true }) as Response));
const wrapped = wrapFetchWithAbortSignal(fetchImpl);
const fakeSignal = {
@@ -166,7 +175,7 @@ describe("wrapFetchWithAbortSignal", () => {
});
it("returns the same function when called with an already wrapped fetch", () => {
- const fetchImpl = vi.fn(async () => ({ ok: true }) as Response);
+ const fetchImpl = withFetchPreconnect(vi.fn(async () => ({ ok: true }) as Response));
const wrapped = wrapFetchWithAbortSignal(fetchImpl);
expect(wrapFetchWithAbortSignal(wrapped)).toBe(wrapped);
diff --git a/src/infra/provider-usage.fetch.antigravity.test.ts b/src/infra/provider-usage.fetch.antigravity.test.ts
index 83e01741a3..c85c28c6c8 100644
--- a/src/infra/provider-usage.fetch.antigravity.test.ts
+++ b/src/infra/provider-usage.fetch.antigravity.test.ts
@@ -1,4 +1,5 @@
import { describe, expect, it, vi } from "vitest";
+import { withFetchPreconnect } from "../test-utils/fetch-mock.js";
import { fetchAntigravityUsage } from "./provider-usage.fetch.antigravity.js";
const makeResponse = (status: number, body: unknown): Response => {
@@ -12,10 +13,12 @@ const toRequestUrl = (input: Parameters[0]): string =>
const createAntigravityFetch = (
handler: (url: string, init?: Parameters[1]) => Promise | Response,
-) =>
- vi.fn(async (input: string | Request | URL, init?: RequestInit) =>
+) => {
+ const mockFetch = vi.fn(async (input: string | Request | URL, init?: RequestInit) =>
handler(toRequestUrl(input), init),
);
+ return withFetchPreconnect(mockFetch) as typeof fetch & typeof mockFetch;
+};
const getRequestBody = (init?: Parameters[1]) =>
typeof init?.body === "string" ? init.body : undefined;
diff --git a/src/media-understanding/media-understanding-misc.test.ts b/src/media-understanding/media-understanding-misc.test.ts
index 97cd139014..b48b9a2117 100644
--- a/src/media-understanding/media-understanding-misc.test.ts
+++ b/src/media-understanding/media-understanding-misc.test.ts
@@ -1,4 +1,5 @@
import { afterEach, describe, expect, it, vi } from "vitest";
+import { withFetchPreconnect } from "../test-utils/fetch-mock.js";
import { MediaAttachmentCache } from "./attachments.js";
import { normalizeMediaUnderstandingChatType, resolveMediaUnderstandingScope } from "./scope.js";
@@ -28,7 +29,7 @@ describe("media understanding attachments SSRF", () => {
it("blocks private IP URLs before fetching", async () => {
const fetchSpy = vi.fn();
- globalThis.fetch = fetchSpy as typeof fetch;
+ globalThis.fetch = withFetchPreconnect(fetchSpy);
const cache = new MediaAttachmentCache([{ index: 0, url: "http://127.0.0.1/secret.jpg" }]);
diff --git a/src/media-understanding/providers/deepgram/audio.test.ts b/src/media-understanding/providers/deepgram/audio.test.ts
index 1ad4d6a929..dab4f9b0fc 100644
--- a/src/media-understanding/providers/deepgram/audio.test.ts
+++ b/src/media-understanding/providers/deepgram/audio.test.ts
@@ -1,4 +1,5 @@
import { describe, expect, it } from "vitest";
+import { withFetchPreconnect } from "../../../test-utils/fetch-mock.js";
import { installPinnedHostnameTestHooks, resolveRequestUrl } from "../audio.test-helpers.js";
import { transcribeDeepgramAudio } from "./audio.js";
@@ -7,7 +8,7 @@ installPinnedHostnameTestHooks();
describe("transcribeDeepgramAudio", () => {
it("respects lowercase authorization header overrides", async () => {
let seenAuth: string | null = null;
- const fetchFn = async (_input: RequestInfo | URL, init?: RequestInit) => {
+ const fetchFn = withFetchPreconnect(async (_input: RequestInfo | URL, init?: RequestInit) => {
const headers = new Headers(init?.headers);
seenAuth = headers.get("authorization");
return new Response(
@@ -19,7 +20,7 @@ describe("transcribeDeepgramAudio", () => {
headers: { "content-type": "application/json" },
},
);
- };
+ });
const result = await transcribeDeepgramAudio({
buffer: Buffer.from("audio"),
@@ -37,7 +38,7 @@ describe("transcribeDeepgramAudio", () => {
it("builds the expected request payload", async () => {
let seenUrl: string | null = null;
let seenInit: RequestInit | undefined;
- const fetchFn = async (input: RequestInfo | URL, init?: RequestInit) => {
+ const fetchFn = withFetchPreconnect(async (input: RequestInfo | URL, init?: RequestInit) => {
seenUrl = resolveRequestUrl(input);
seenInit = init;
return new Response(
@@ -49,7 +50,7 @@ describe("transcribeDeepgramAudio", () => {
headers: { "content-type": "application/json" },
},
);
- };
+ });
const result = await transcribeDeepgramAudio({
buffer: Buffer.from("audio-bytes"),
diff --git a/src/media-understanding/providers/google/video.test.ts b/src/media-understanding/providers/google/video.test.ts
index 9675d9eaa8..fb2682d81f 100644
--- a/src/media-understanding/providers/google/video.test.ts
+++ b/src/media-understanding/providers/google/video.test.ts
@@ -1,5 +1,6 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import * as ssrf from "../../../infra/net/ssrf.js";
+import { withFetchPreconnect } from "../../../test-utils/fetch-mock.js";
import { describeGeminiVideo } from "./video.js";
const TEST_NET_IP = "203.0.113.10";
@@ -47,7 +48,7 @@ describe("describeGeminiVideo", () => {
it("respects case-insensitive x-goog-api-key overrides", async () => {
let seenKey: string | null = null;
- const fetchFn = async (_input: RequestInfo | URL, init?: RequestInit) => {
+ const fetchFn = withFetchPreconnect(async (_input: RequestInfo | URL, init?: RequestInit) => {
const headers = new Headers(init?.headers);
seenKey = headers.get("x-goog-api-key");
return new Response(
@@ -56,7 +57,7 @@ describe("describeGeminiVideo", () => {
}),
{ status: 200, headers: { "content-type": "application/json" } },
);
- };
+ });
const result = await describeGeminiVideo({
buffer: Buffer.from("video"),
@@ -74,7 +75,7 @@ describe("describeGeminiVideo", () => {
it("builds the expected request payload", async () => {
let seenUrl: string | null = null;
let seenInit: RequestInit | undefined;
- const fetchFn = async (input: RequestInfo | URL, init?: RequestInit) => {
+ const fetchFn = withFetchPreconnect(async (input: RequestInfo | URL, init?: RequestInit) => {
seenUrl = resolveRequestUrl(input);
seenInit = init;
return new Response(
@@ -89,7 +90,7 @@ describe("describeGeminiVideo", () => {
}),
{ status: 200, headers: { "content-type": "application/json" } },
);
- };
+ });
const result = await describeGeminiVideo({
buffer: Buffer.from("video-bytes"),
diff --git a/src/media-understanding/providers/openai/audio.test.ts b/src/media-understanding/providers/openai/audio.test.ts
index c0b986881c..1e80a9ed52 100644
--- a/src/media-understanding/providers/openai/audio.test.ts
+++ b/src/media-understanding/providers/openai/audio.test.ts
@@ -1,4 +1,5 @@
import { describe, expect, it } from "vitest";
+import { withFetchPreconnect } from "../../../test-utils/fetch-mock.js";
import { installPinnedHostnameTestHooks, resolveRequestUrl } from "../audio.test-helpers.js";
import { transcribeOpenAiCompatibleAudio } from "./audio.js";
@@ -7,14 +8,14 @@ installPinnedHostnameTestHooks();
describe("transcribeOpenAiCompatibleAudio", () => {
it("respects lowercase authorization header overrides", async () => {
let seenAuth: string | null = null;
- const fetchFn = async (_input: RequestInfo | URL, init?: RequestInit) => {
+ const fetchFn = withFetchPreconnect(async (_input: RequestInfo | URL, init?: RequestInit) => {
const headers = new Headers(init?.headers);
seenAuth = headers.get("authorization");
return new Response(JSON.stringify({ text: "ok" }), {
status: 200,
headers: { "content-type": "application/json" },
});
- };
+ });
const result = await transcribeOpenAiCompatibleAudio({
buffer: Buffer.from("audio"),
@@ -32,14 +33,14 @@ describe("transcribeOpenAiCompatibleAudio", () => {
it("builds the expected request payload", async () => {
let seenUrl: string | null = null;
let seenInit: RequestInit | undefined;
- const fetchFn = async (input: RequestInfo | URL, init?: RequestInit) => {
+ const fetchFn = withFetchPreconnect(async (input: RequestInfo | URL, init?: RequestInit) => {
seenUrl = resolveRequestUrl(input);
seenInit = init;
return new Response(JSON.stringify({ text: "hello" }), {
status: 200,
headers: { "content-type": "application/json" },
});
- };
+ });
const result = await transcribeOpenAiCompatibleAudio({
buffer: Buffer.from("audio-bytes"),
diff --git a/src/memory/embeddings-voyage.test.ts b/src/memory/embeddings-voyage.test.ts
index e97c01de9f..371ab9bbeb 100644
--- a/src/memory/embeddings-voyage.test.ts
+++ b/src/memory/embeddings-voyage.test.ts
@@ -1,5 +1,6 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import * as authModule from "../agents/model-auth.js";
+import { withFetchPreconnect } from "../test-utils/fetch-mock.js";
import { createVoyageEmbeddingProvider, normalizeVoyageModel } from "./embeddings-voyage.js";
vi.mock("../agents/model-auth.js", () => ({
@@ -12,12 +13,14 @@ vi.mock("../agents/model-auth.js", () => ({
},
}));
-const createFetchMock = () =>
- vi.fn(async () => ({
+const createFetchMock = () => {
+ const fetchMock = vi.fn(async () => ({
ok: true,
status: 200,
json: async () => ({ data: [{ embedding: [0.1, 0.2, 0.3] }] }),
- })) as unknown as typeof fetch;
+ }));
+ return withFetchPreconnect(fetchMock) as typeof fetch & typeof fetchMock;
+};
describe("voyage embedding provider", () => {
afterEach(() => {
@@ -26,8 +29,8 @@ describe("voyage embedding provider", () => {
});
it("configures client with correct defaults and headers", async () => {
- const fetchMock = createFetchMock() as ReturnType;
- vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch);
+ const fetchMock = createFetchMock();
+ vi.stubGlobal("fetch", fetchMock);
vi.mocked(authModule.resolveApiKeyForProvider).mockResolvedValue({
apiKey: "voyage-key-123",
@@ -64,8 +67,8 @@ describe("voyage embedding provider", () => {
});
it("respects remote overrides for baseUrl and apiKey", async () => {
- const fetchMock = createFetchMock() as ReturnType;
- vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch);
+ const fetchMock = createFetchMock();
+ vi.stubGlobal("fetch", fetchMock);
const result = await createVoyageEmbeddingProvider({
config: {} as never,
@@ -90,14 +93,16 @@ describe("voyage embedding provider", () => {
});
it("passes input_type=document for embedBatch", async () => {
- const fetchMock = vi.fn(async () => ({
- ok: true,
- status: 200,
- json: async () => ({
- data: [{ embedding: [0.1, 0.2] }, { embedding: [0.3, 0.4] }],
- }),
- })) as ReturnType;
- vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch);
+ const fetchMock = withFetchPreconnect(
+ vi.fn(async () => ({
+ ok: true,
+ status: 200,
+ json: async () => ({
+ data: [{ embedding: [0.1, 0.2] }, { embedding: [0.3, 0.4] }],
+ }),
+ })),
+ );
+ vi.stubGlobal("fetch", fetchMock);
vi.mocked(authModule.resolveApiKeyForProvider).mockResolvedValue({
apiKey: "voyage-key-123",
diff --git a/src/memory/manager.batch.test.ts b/src/memory/manager.batch.test.ts
index 3f00114375..2ed5ad0b73 100644
--- a/src/memory/manager.batch.test.ts
+++ b/src/memory/manager.batch.test.ts
@@ -43,7 +43,8 @@ describe("memory indexing with OpenAI batches", () => {
async function readOpenAIBatchUploadRequests(body: FormData) {
let uploadedRequests: Array<{ custom_id?: string }> = [];
- for (const [key, value] of body.entries()) {
+ const entries = body.entries() as IterableIterator<[string, FormDataEntryValue]>;
+ for (const [key, value] of entries) {
if (key !== "file") {
continue;
}
@@ -51,7 +52,7 @@ describe("memory indexing with OpenAI batches", () => {
uploadedRequests = text
.split("\n")
.filter(Boolean)
- .map((line) => JSON.parse(line) as { custom_id?: string });
+ .map((line: string) => JSON.parse(line) as { custom_id?: string });
}
return uploadedRequests;
}
diff --git a/src/slack/monitor/media.test.ts b/src/slack/monitor/media.test.ts
index 81eec0a442..05b30615e8 100644
--- a/src/slack/monitor/media.test.ts
+++ b/src/slack/monitor/media.test.ts
@@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import * as ssrf from "../../infra/net/ssrf.js";
import type { SavedMedia } from "../../media/store.js";
import * as mediaStore from "../../media/store.js";
+import { withFetchPreconnect } from "../../test-utils/fetch-mock.js";
import {
fetchWithSlackAuth,
resolveSlackAttachmentContent,
@@ -23,7 +24,7 @@ describe("fetchWithSlackAuth", () => {
beforeEach(() => {
// Create a new mock for each test
mockFetch = vi.fn();
- globalThis.fetch = mockFetch as typeof fetch;
+ globalThis.fetch = withFetchPreconnect(mockFetch);
});
afterEach(() => {
@@ -169,7 +170,7 @@ describe("fetchWithSlackAuth", () => {
describe("resolveSlackMedia", () => {
beforeEach(() => {
mockFetch = vi.fn();
- globalThis.fetch = mockFetch as typeof fetch;
+ globalThis.fetch = withFetchPreconnect(mockFetch);
vi.spyOn(ssrf, "resolvePinnedHostname").mockImplementation(async (hostname) => {
const normalized = hostname.trim().toLowerCase().replace(/\.$/, "");
const addresses = ["93.184.216.34"];
@@ -432,7 +433,7 @@ describe("resolveSlackMedia", () => {
describe("resolveSlackAttachmentContent", () => {
beforeEach(() => {
mockFetch = vi.fn();
- globalThis.fetch = mockFetch as typeof fetch;
+ globalThis.fetch = withFetchPreconnect(mockFetch);
vi.spyOn(ssrf, "resolvePinnedHostnameWithPolicy").mockImplementation(async (hostname) => {
const normalized = hostname.trim().toLowerCase().replace(/\.$/, "");
const addresses = ["93.184.216.34"];
diff --git a/src/telegram/bot.test.ts b/src/telegram/bot.test.ts
index a5c31ad328..e8c2e39e0d 100644
--- a/src/telegram/bot.test.ts
+++ b/src/telegram/bot.test.ts
@@ -1001,6 +1001,44 @@ describe("createTelegramBot", () => {
expect(enqueueSystemEventSpy).not.toHaveBeenCalled();
});
+ it("enqueues one event per added emoji reaction", async () => {
+ onSpy.mockReset();
+ enqueueSystemEventSpy.mockReset();
+
+ loadConfig.mockReturnValue({
+ channels: {
+ telegram: { dmPolicy: "open", reactionNotifications: "all" },
+ },
+ });
+
+ createTelegramBot({ token: "tok" });
+ const handler = getOnHandler("message_reaction") as (
+ ctx: Record,
+ ) => Promise;
+
+ await handler({
+ update: { update_id: 505 },
+ messageReaction: {
+ chat: { id: 1234, type: "private" },
+ message_id: 42,
+ user: { id: 9, first_name: "Ada" },
+ date: 1736380800,
+ old_reaction: [{ type: "emoji", emoji: "👍" }],
+ new_reaction: [
+ { type: "emoji", emoji: "👍" },
+ { type: "emoji", emoji: "🔥" },
+ { type: "emoji", emoji: "🎉" },
+ ],
+ },
+ });
+
+ expect(enqueueSystemEventSpy).toHaveBeenCalledTimes(2);
+ expect(enqueueSystemEventSpy.mock.calls.map((call) => call[0])).toEqual([
+ "Telegram reaction added: 🔥 by Ada on msg 42",
+ "Telegram reaction added: 🎉 by Ada on msg 42",
+ ]);
+ });
+
it("routes forum group reactions to the general topic (thread id not available on reactions)", async () => {
onSpy.mockReset();
enqueueSystemEventSpy.mockReset();
diff --git a/src/telegram/probe.test.ts b/src/telegram/probe.test.ts
index fdc2808393..9b65c8e0f4 100644
--- a/src/telegram/probe.test.ts
+++ b/src/telegram/probe.test.ts
@@ -1,4 +1,5 @@
import { type Mock, describe, expect, it, vi, beforeEach, afterEach } from "vitest";
+import { withFetchPreconnect } from "../test-utils/fetch-mock.js";
import { probeTelegram } from "./probe.js";
describe("probeTelegram retry logic", () => {
@@ -38,7 +39,7 @@ describe("probeTelegram retry logic", () => {
beforeEach(() => {
vi.useFakeTimers();
fetchMock = vi.fn();
- global.fetch = fetchMock;
+ global.fetch = withFetchPreconnect(fetchMock);
});
afterEach(() => {
diff --git a/src/test-utils/fetch-mock.ts b/src/test-utils/fetch-mock.ts
new file mode 100644
index 0000000000..719152f03e
--- /dev/null
+++ b/src/test-utils/fetch-mock.ts
@@ -0,0 +1,6 @@
+export const withFetchPreconnect = unknown>(
+ fn: T,
+): typeof fetch =>
+ Object.assign(fn, {
+ preconnect: () => {},
+ }) as unknown as typeof fetch;