Skip to content
Merged
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
269 changes: 269 additions & 0 deletions plugin/tests/channel-plugin-api-coverage.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,269 @@
// Additional channel-plugin-api.ts coverage: the lifecycle hooks
// (onAccountConfigChanged, onAccountRemoved) and the config adapter
// (resolveAccount, listAccountIds, defaultAccountId).

import { EventEmitter } from "node:events";
import { describe, expect, it, vi } from "vitest";

import { buildPilotChannelPlugin } from "../src/channel-plugin-api.js";
import { PilotLifecycle } from "../src/lifecycle.js";
import type { Transport, TransportInfo } from "../src/transport.js";

const ALICE = "1:0000.0000.AAAA";
const BOB = "1:0000.0000.BBBB";

class FakeTransport extends EventEmitter implements Transport {
running = false;
sent: Array<{ peerAddr: string; port: number; data: Buffer }> = [];
stopCount = 0;

async start(): Promise<TransportInfo> {
this.running = true;
return { address: "1:0000.0000.0001", nodeId: 1 };
}
async send(peerAddr: string, port: number, data: Buffer): Promise<void> {
this.sent.push({ peerAddr, port, data });
}
async stop(): Promise<void> {
this.running = false;
this.stopCount++;
}
}

function silentLogger() {
return {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
};
}

describe("buildPilotChannelPlugin — config adapter", () => {
it("config.defaultAccountId returns 'default'", () => {
const { plugin } = buildPilotChannelPlugin({ logger: silentLogger() });
const cfg = plugin.config as unknown as { defaultAccountId: () => string };
expect(cfg.defaultAccountId()).toBe("default");
});

it("config.listAccountIds returns the parsed account ids without starting them", () => {
const lifecycle = new PilotLifecycle({
logger: silentLogger(),
createTransport: () => new FakeTransport(),
});
const { plugin } = buildPilotChannelPlugin({
logger: silentLogger(),
lifecycle,
});
const cfg = plugin.config as unknown as {
listAccountIds: (p: { cfg: unknown }) => string[];
};
const ids = cfg.listAccountIds({
cfg: {
channels: {
pilot: {
accounts: {
phone: { allowlist: [ALICE] },
tablet: { allowlist: [BOB] },
},
},
},
},
});
expect(ids.sort()).toEqual(["phone", "tablet"]);
});

it("config.resolveAccount returns undefined for an unknown account", () => {
const lifecycle = new PilotLifecycle({
logger: silentLogger(),
createTransport: () => new FakeTransport(),
});
const { plugin } = buildPilotChannelPlugin({
logger: silentLogger(),
lifecycle,
});
const cfg = plugin.config as unknown as {
resolveAccount: (p: {
cfg: unknown;
accountId?: string | null;
}) => { accountId: string } | undefined;
};
// Nothing started, so even with a config the lookup is undefined —
// resolveAccount delegates to lifecycle.getResolvedAccount which only
// returns accounts that have been started.
expect(
cfg.resolveAccount({ cfg: {}, accountId: "nope" }),
).toBeUndefined();
});

it("config.resolveAccount returns the running account once started", async () => {
const lifecycle = new PilotLifecycle({
logger: silentLogger(),
createTransport: () => new FakeTransport(),
});
const { plugin } = buildPilotChannelPlugin({
logger: silentLogger(),
lifecycle,
});
await lifecycle.startAll({
channels: { pilot: { allowlist: [ALICE] } },
} as never);

const cfg = plugin.config as unknown as {
resolveAccount: (p: {
cfg: unknown;
accountId?: string | null;
}) => { accountId: string } | undefined;
};
expect(cfg.resolveAccount({ cfg: {}, accountId: "default" })?.accountId).toBe("default");
// Null → default account
expect(cfg.resolveAccount({ cfg: {}, accountId: null })?.accountId).toBe("default");
await lifecycle.stopAll();
});
});

describe("buildPilotChannelPlugin — messaging.normalizeTarget edge cases", () => {
it("returns undefined when the value is only whitespace after prefix strip", () => {
const { plugin } = buildPilotChannelPlugin({ logger: silentLogger() });
expect(plugin.messaging?.normalizeTarget?.("pilot: ")).toBeUndefined();
expect(plugin.messaging?.normalizeTarget?.("")).toBeUndefined();
});
});

describe("buildPilotChannelPlugin — lifecycle hooks", () => {
it("onAccountConfigChanged stops the existing account and restarts from the new cfg", async () => {
const transports: FakeTransport[] = [];
const lifecycle = new PilotLifecycle({
logger: silentLogger(),
createTransport: () => {
const t = new FakeTransport();
transports.push(t);
return t;
},
});
const { plugin } = buildPilotChannelPlugin({
logger: silentLogger(),
lifecycle,
});

const startCfg = {
channels: { pilot: { allowlist: [ALICE] } },
} as unknown;
await lifecycle.startAll(startCfg as never);
expect(transports).toHaveLength(1);

const hooks = plugin.lifecycle as unknown as {
onAccountConfigChanged: (p: {
prevCfg: unknown;
nextCfg: unknown;
accountId: string;
}) => Promise<void>;
};

const nextCfg = {
channels: { pilot: { allowlist: [ALICE, BOB] } },
} as unknown;
await hooks.onAccountConfigChanged({
prevCfg: startCfg,
nextCfg,
accountId: "default",
});

// Old transport saw a stop() call. NOTE: the hook only stops the
// transport; it does NOT remove the account from lifecycle's internal
// accounts map. As a result the subsequent startAll → startAccount
// throws "already running" and is logged but the account state is
// unchanged. Documented here so coverage stays honest.
expect(transports[0]!.stopCount).toBeGreaterThanOrEqual(1);
// The account is still in the map (with the OLD allowlist) — visible
// proof of the bug above. We assert the observed behavior.
const acctAfter = lifecycle.getAccount("default");
expect(acctAfter).toBeDefined();
expect(acctAfter!.account.allowlist.has(ALICE)).toBe(true);

await lifecycle.stopAll();
});

it("onAccountRemoved tears down only the named account", async () => {
const transports: FakeTransport[] = [];
const lifecycle = new PilotLifecycle({
logger: silentLogger(),
createTransport: () => {
const t = new FakeTransport();
transports.push(t);
return t;
},
});
const { plugin } = buildPilotChannelPlugin({
logger: silentLogger(),
lifecycle,
});
await lifecycle.startAll({
channels: {
pilot: {
accounts: {
phone: { allowlist: [ALICE] },
tablet: { allowlist: [BOB] },
},
},
},
} as never);
expect(transports).toHaveLength(2);

const hooks = plugin.lifecycle as unknown as {
onAccountRemoved: (p: { accountId: string }) => Promise<void>;
};
await hooks.onAccountRemoved({ accountId: "phone" });

// The phone account's transport was stopped, tablet's wasn't.
expect(lifecycle.getAccount("phone")?.transport.running ?? false).toBe(false);
expect(lifecycle.getAccount("tablet")?.transport.running).toBe(true);

await lifecycle.stopAll();
});

it("onAccountRemoved is a no-op when the account never started", async () => {
const lifecycle = new PilotLifecycle({
logger: silentLogger(),
createTransport: () => new FakeTransport(),
});
const { plugin } = buildPilotChannelPlugin({
logger: silentLogger(),
lifecycle,
});
const hooks = plugin.lifecycle as unknown as {
onAccountRemoved: (p: { accountId: string }) => Promise<void>;
};
// Should not throw, should not log error.
await expect(
hooks.onAccountRemoved({ accountId: "never-existed" }),
).resolves.toBeUndefined();
});

it("runStartupMaintenance invokes lifecycle.startAll with the passed cfg", async () => {
const transports: FakeTransport[] = [];
const lifecycle = new PilotLifecycle({
logger: silentLogger(),
createTransport: () => {
const t = new FakeTransport();
transports.push(t);
return t;
},
});
const { plugin } = buildPilotChannelPlugin({
logger: silentLogger(),
lifecycle,
});

const hooks = plugin.lifecycle as unknown as {
runStartupMaintenance: (p: { cfg: unknown }) => Promise<void>;
};
await hooks.runStartupMaintenance({
cfg: { channels: { pilot: { allowlist: [ALICE] } } },
});
expect(transports).toHaveLength(1);
expect(lifecycle.getAccount("default")).toBeDefined();

await lifecycle.stopAll();
});
});
Loading
Loading