test: add target normalization helper coverage

This commit is contained in:
Peter Steinberger 2026-03-13 20:06:14 +00:00
parent bf6da81028
commit 146cba46ca
3 changed files with 243 additions and 0 deletions

View File

@ -0,0 +1,59 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
emitHeartbeatEvent,
getLastHeartbeatEvent,
onHeartbeatEvent,
resolveIndicatorType,
} from "./heartbeat-events.js";
describe("resolveIndicatorType", () => {
it("maps heartbeat statuses to indicator types", () => {
expect(resolveIndicatorType("ok-empty")).toBe("ok");
expect(resolveIndicatorType("ok-token")).toBe("ok");
expect(resolveIndicatorType("sent")).toBe("alert");
expect(resolveIndicatorType("failed")).toBe("error");
expect(resolveIndicatorType("skipped")).toBeUndefined();
});
});
describe("heartbeat events", () => {
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-01-09T12:00:00Z"));
});
afterEach(() => {
vi.useRealTimers();
});
it("stores the last event and timestamps emitted payloads", () => {
emitHeartbeatEvent({ status: "sent", to: "+123", preview: "ping" });
expect(getLastHeartbeatEvent()).toEqual({
ts: 1767960000000,
status: "sent",
to: "+123",
preview: "ping",
});
});
it("delivers events to listeners, isolates listener failures, and supports unsubscribe", () => {
const seen: string[] = [];
const unsubscribeFirst = onHeartbeatEvent((evt) => {
seen.push(`first:${evt.status}`);
});
onHeartbeatEvent(() => {
throw new Error("boom");
});
const unsubscribeThird = onHeartbeatEvent((evt) => {
seen.push(`third:${evt.status}`);
});
emitHeartbeatEvent({ status: "ok-empty" });
unsubscribeFirst();
unsubscribeThird();
emitHeartbeatEvent({ status: "failed" });
expect(seen).toEqual(["first:ok-empty", "third:ok-empty"]);
});
});

View File

@ -0,0 +1,142 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const normalizeChannelIdMock = vi.hoisted(() => vi.fn());
const getChannelPluginMock = vi.hoisted(() => vi.fn());
const getActivePluginRegistryVersionMock = vi.hoisted(() => vi.fn());
vi.mock("../../channels/plugins/index.js", () => ({
normalizeChannelId: (...args: unknown[]) => normalizeChannelIdMock(...args),
getChannelPlugin: (...args: unknown[]) => getChannelPluginMock(...args),
}));
vi.mock("../../plugins/runtime.js", () => ({
getActivePluginRegistryVersion: (...args: unknown[]) =>
getActivePluginRegistryVersionMock(...args),
}));
import {
buildTargetResolverSignature,
normalizeChannelTargetInput,
normalizeTargetForProvider,
} from "./target-normalization.js";
describe("normalizeChannelTargetInput", () => {
it("trims raw target input", () => {
expect(normalizeChannelTargetInput(" channel:C1 ")).toBe("channel:C1");
});
});
describe("normalizeTargetForProvider", () => {
beforeEach(() => {
normalizeChannelIdMock.mockReset();
getChannelPluginMock.mockReset();
getActivePluginRegistryVersionMock.mockReset();
});
it("returns undefined for missing or blank raw input", () => {
expect(normalizeTargetForProvider("telegram")).toBeUndefined();
expect(normalizeTargetForProvider("telegram", " ")).toBeUndefined();
});
it("falls back to trimmed input when the provider is unknown or has no normalizer", () => {
normalizeChannelIdMock.mockReturnValueOnce(null);
expect(normalizeTargetForProvider("unknown", " raw-id ")).toBe("raw-id");
normalizeChannelIdMock.mockReturnValueOnce("telegram");
getActivePluginRegistryVersionMock.mockReturnValueOnce(1);
getChannelPluginMock.mockReturnValueOnce(undefined);
expect(normalizeTargetForProvider("telegram", " raw-id ")).toBe("raw-id");
});
it("uses the cached target normalizer until the plugin registry version changes", () => {
const firstNormalizer = vi.fn((raw: string) => raw.trim().toUpperCase());
const secondNormalizer = vi.fn((raw: string) => `next:${raw.trim()}`);
normalizeChannelIdMock.mockReturnValue("telegram");
getActivePluginRegistryVersionMock
.mockReturnValueOnce(10)
.mockReturnValueOnce(10)
.mockReturnValueOnce(11);
getChannelPluginMock
.mockReturnValueOnce({
messaging: { normalizeTarget: firstNormalizer },
})
.mockReturnValueOnce({
messaging: { normalizeTarget: secondNormalizer },
});
expect(normalizeTargetForProvider("telegram", " abc ")).toBe("ABC");
expect(normalizeTargetForProvider("telegram", " def ")).toBe("DEF");
expect(normalizeTargetForProvider("telegram", " ghi ")).toBe("next:ghi");
expect(getChannelPluginMock).toHaveBeenCalledTimes(2);
expect(firstNormalizer).toHaveBeenCalledTimes(2);
expect(secondNormalizer).toHaveBeenCalledTimes(1);
});
it("returns undefined when the provider normalizer resolves to an empty value", () => {
normalizeChannelIdMock.mockReturnValueOnce("telegram");
getActivePluginRegistryVersionMock.mockReturnValueOnce(20);
getChannelPluginMock.mockReturnValueOnce({
messaging: {
normalizeTarget: () => "",
},
});
expect(normalizeTargetForProvider("telegram", " raw-id ")).toBeUndefined();
});
});
describe("buildTargetResolverSignature", () => {
beforeEach(() => {
getChannelPluginMock.mockReset();
});
it("builds stable signatures from resolver hint and looksLikeId source", () => {
const looksLikeId = (value: string) => value.startsWith("C");
getChannelPluginMock.mockReturnValueOnce({
messaging: {
targetResolver: {
hint: "Use channel id",
looksLikeId,
},
},
});
const first = buildTargetResolverSignature("slack");
getChannelPluginMock.mockReturnValueOnce({
messaging: {
targetResolver: {
hint: "Use channel id",
looksLikeId,
},
},
});
const second = buildTargetResolverSignature("slack");
expect(first).toBe(second);
});
it("changes when resolver metadata changes", () => {
getChannelPluginMock.mockReturnValueOnce({
messaging: {
targetResolver: {
hint: "Use channel id",
looksLikeId: (value: string) => value.startsWith("C"),
},
},
});
const first = buildTargetResolverSignature("slack");
getChannelPluginMock.mockReturnValueOnce({
messaging: {
targetResolver: {
hint: "Use user id",
looksLikeId: (value: string) => value.startsWith("U"),
},
},
});
const second = buildTargetResolverSignature("slack");
expect(first).not.toBe(second);
});
});

View File

@ -0,0 +1,42 @@
import { describe, expect, it } from "vitest";
import { extractToolPayload } from "./tool-payload.js";
describe("extractToolPayload", () => {
it("prefers explicit details payloads", () => {
expect(
extractToolPayload({
details: { ok: true },
content: [{ type: "text", text: '{"ignored":true}' }],
} as never),
).toEqual({ ok: true });
});
it("parses JSON text blocks from tool content", () => {
expect(
extractToolPayload({
content: [
{ type: "image", url: "https://example.com/a.png" },
{ type: "text", text: '{"ok":true,"count":2}' },
],
} as never),
).toEqual({ ok: true, count: 2 });
});
it("falls back to raw text, then content, then the whole result", () => {
expect(
extractToolPayload({
content: [{ type: "text", text: "not json" }],
} as never),
).toBe("not json");
const content = [{ type: "image", url: "https://example.com/a.png" }];
expect(
extractToolPayload({
content,
} as never),
).toBe(content);
const result = { status: "ok" };
expect(extractToolPayload(result as never)).toBe(result);
});
});