Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 60 additions & 8 deletions src/channels/__tests__/email.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,14 +23,27 @@ const mockFetch = mock(function* () {
const mockMessageFlagsAdd = mock(() => Promise.resolve());
const mockSendMail = mock(() => Promise.resolve({ messageId: "<test@phantom.local>" }));

const MockImapFlow = mock((_opts: Record<string, unknown>) => ({
connect: mockConnect,
logout: mockLogout,
getMailboxLock: mockGetMailboxLock,
idle: mockIdle,
fetch: mockFetch,
messageFlagsAdd: mockMessageFlagsAdd,
}));
// Captured per-instance so tests can simulate a socket-timeout emission.
interface CapturedClient {
errorHandlers: Array<(err: unknown) => void>;
}
const capturedClients: CapturedClient[] = [];

const MockImapFlow = mock((_opts: Record<string, unknown>) => {
const handlers: CapturedClient = { errorHandlers: [] };
capturedClients.push(handlers);
return {
on: mock((event: string, handler: (err: unknown) => void) => {
if (event === "error") handlers.errorHandlers.push(handler);
}),
connect: mockConnect,
logout: mockLogout,
getMailboxLock: mockGetMailboxLock,
idle: mockIdle,
fetch: mockFetch,
messageFlagsAdd: mockMessageFlagsAdd,
};
});

const mockCreateTransport = mock((_opts: Record<string, unknown>) => ({
sendMail: mockSendMail,
Expand Down Expand Up @@ -70,6 +83,7 @@ describe("EmailChannel", () => {
mockLogout.mockClear();
mockSendMail.mockClear();
mockGetMailboxLock.mockClear();
capturedClients.length = 0;
});

test("has correct id and capabilities", () => {
Expand Down Expand Up @@ -192,4 +206,42 @@ describe("EmailChannel", () => {
const id2 = calls[1][0].messageId;
expect(id1).not.toBe(id2);
});

test("attaches an ImapFlow error listener on connect (no crash on socket timeout)", async () => {
const channel = new EmailChannel(testConfig);
await channel.connect();

// The first constructed client must have an error handler registered.
expect(capturedClients.length).toBeGreaterThan(0);
expect(capturedClients[0].errorHandlers.length).toBe(1);

// Emitting 'error' must not throw and must not reject any promise.
// Before the fix this is what crashed the Phantom process.
await expect(
Promise.resolve(capturedClients[0].errorHandlers[0](new Error("Socket timeout"))),
).resolves.toBeUndefined();

await channel.disconnect();
});

test("error emission triggers a reconnect that builds a fresh client", async () => {
const channel = new EmailChannel(testConfig);
await channel.connect();

expect(capturedClients.length).toBe(1);
const initialClientCount = capturedClients.length;

// Simulate the socket-timeout emission from imapflow.
capturedClients[0].errorHandlers[0](new Error("Socket timeout"));

// Allow the reconnect path (with its 1s backoff) to run.
await new Promise((resolve) => setTimeout(resolve, 1_500));

// A second client should have been constructed.
expect(capturedClients.length).toBe(initialClientCount + 1);
expect(capturedClients[capturedClients.length - 1].errorHandlers.length).toBe(1);
expect(mockConnect.mock.calls.length).toBeGreaterThanOrEqual(2);

await channel.disconnect();
});
});
118 changes: 118 additions & 0 deletions src/channels/__tests__/interaction-adapter.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import { describe, expect, mock, test } from "bun:test";
import {
type ChannelInteractionFactory,
type ChannelInteractionInstance,
ChannelInteractionRegistry,
} from "../interaction-adapter.ts";
import type { InboundMessage } from "../types.ts";

function makeMessage(overrides: Partial<InboundMessage> = {}): InboundMessage {
return {
id: "msg-1",
channelId: "test",
conversationId: "test:conv-1",
senderId: "user-1",
text: "hello",
timestamp: new Date(),
...overrides,
};
}

describe("ChannelInteractionRegistry", () => {
test("starts empty", () => {
const registry = new ChannelInteractionRegistry();
expect(registry.size()).toBe(0);
});

test("buildFor returns empty array when no factories registered", () => {
const registry = new ChannelInteractionRegistry();
const instances = registry.buildFor(makeMessage());
expect(instances).toEqual([]);
});

test("registers factories and reports size", () => {
const registry = new ChannelInteractionRegistry();
registry.register(() => null);
registry.register(() => null);
expect(registry.size()).toBe(2);
});

test("buildFor calls each factory with the message", () => {
const registry = new ChannelInteractionRegistry();
const factoryA = mock((_msg: InboundMessage) => null);
const factoryB = mock((_msg: InboundMessage) => null);
registry.register(factoryA);
registry.register(factoryB);

const msg = makeMessage({ channelId: "slack" });
registry.buildFor(msg);

expect(factoryA).toHaveBeenCalledWith(msg);
expect(factoryB).toHaveBeenCalledWith(msg);
});

test("buildFor returns only non-null instances in registration order", () => {
const registry = new ChannelInteractionRegistry();
const instanceA: ChannelInteractionInstance = { dispose: () => {} };
const instanceC: ChannelInteractionInstance = { dispose: () => {} };

// A returns instance, B opts out (null), C returns instance
registry.register(() => instanceA);
registry.register(() => null);
registry.register(() => instanceC);

const instances = registry.buildFor(makeMessage());
expect(instances).toEqual([instanceA, instanceC]);
});

test("factory can decide based on the message channelId", () => {
const registry = new ChannelInteractionRegistry();
const slackInstance: ChannelInteractionInstance = { dispose: () => {} };

const slackFactory: ChannelInteractionFactory = (msg) => (msg.channelId === "slack" ? slackInstance : null);
registry.register(slackFactory);

const slackMsg = makeMessage({ channelId: "slack" });
const telegramMsg = makeMessage({ channelId: "telegram" });

expect(registry.buildFor(slackMsg)).toEqual([slackInstance]);
expect(registry.buildFor(telegramMsg)).toEqual([]);
});

test("clearForTests empties the registry", () => {
const registry = new ChannelInteractionRegistry();
registry.register(() => null);
registry.register(() => null);
expect(registry.size()).toBe(2);
registry.clearForTests();
expect(registry.size()).toBe(0);
});

test("instances may have any subset of optional hooks", () => {
const registry = new ChannelInteractionRegistry();
// All hooks present
const fullInstance: ChannelInteractionInstance = {
onTurnStart: () => {},
onRuntimeEvent: () => {},
onTurnEnd: () => {},
deliverResponse: () => false,
dispose: () => {},
};
// Only one hook
const minimalInstance: ChannelInteractionInstance = {
dispose: () => {},
};
// Truly empty
const emptyInstance: ChannelInteractionInstance = {};

registry.register(() => fullInstance);
registry.register(() => minimalInstance);
registry.register(() => emptyInstance);

const instances = registry.buildFor(makeMessage());
expect(instances.length).toBe(3);
expect(instances[0].onTurnStart).toBeDefined();
expect(instances[1].onTurnStart).toBeUndefined();
expect(instances[2].dispose).toBeUndefined();
});
});
Loading