refactor(test): dedupe startup and nostr test fixtures

This commit is contained in:
Peter Steinberger
2026-03-22 00:52:52 +00:00
parent 3775651480
commit f1b2c5639a
20 changed files with 529 additions and 574 deletions

View File

@@ -1,10 +1,7 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import type {
ChannelAccountSnapshot,
ChannelGatewayContext,
} from "../../../src/channels/plugins/types.js";
import type { PluginRuntime } from "../../../src/plugins/runtime/types.js";
import { createRuntimeEnv } from "../../../test/helpers/extensions/runtime-env.js";
import { createStartAccountContext } from "../../../test/helpers/extensions/start-account-context.js";
import type { ResolvedDiscordAccount } from "./accounts.js";
import { discordPlugin } from "./channel.js";
import type { OpenClawConfig } from "./runtime-api.js";
@@ -49,33 +46,6 @@ function createCfg(): OpenClawConfig {
} as OpenClawConfig;
}
function createStartAccountCtx(params: {
cfg: OpenClawConfig;
accountId: string;
runtime: ReturnType<typeof createRuntimeEnv>;
}): ChannelGatewayContext<ResolvedDiscordAccount> {
const account = discordPlugin.config.resolveAccount(
params.cfg,
params.accountId,
) as ResolvedDiscordAccount;
const snapshot: ChannelAccountSnapshot = {
accountId: params.accountId,
configured: true,
enabled: true,
running: false,
};
return {
accountId: params.accountId,
account,
cfg: params.cfg,
runtime: params.runtime,
abortSignal: new AbortController().signal,
log: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
getStatus: () => snapshot,
setStatus: vi.fn(),
};
}
afterEach(() => {
probeDiscordMock.mockReset();
monitorDiscordProviderMock.mockReset();
@@ -189,9 +159,9 @@ describe("discordPlugin outbound", () => {
const cfg = createCfg();
await discordPlugin.gateway!.startAccount!(
createStartAccountCtx({
createStartAccountContext({
account: discordPlugin.config.resolveAccount(cfg, "default") as ResolvedDiscordAccount,
cfg,
accountId: "default",
runtime: createRuntimeEnv(),
}),
);

View File

@@ -1,10 +1,10 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import {
abortStartedAccount,
expectLifecyclePatch,
expectPendingUntilAbort,
startAccountAndTrackLifecycle,
waitForStartedMocks,
} from "../../../test/helpers/extensions/start-account-lifecycle.js";
import type { ChannelAccountSnapshot } from "../runtime-api.js";
import type { ResolvedGoogleChatAccount } from "./accounts.js";
const hoisted = vi.hoisted(() => ({
@@ -21,6 +21,21 @@ vi.mock("./monitor.js", async () => {
import { googlechatPlugin } from "./channel.js";
function buildAccount(): ResolvedGoogleChatAccount {
return {
accountId: "default",
enabled: true,
credentialSource: "inline",
credentials: {},
config: {
webhookPath: "/googlechat",
webhookUrl: "https://example.com/googlechat",
audienceType: "app-url",
audience: "https://example.com/googlechat",
},
};
}
describe("googlechatPlugin gateway.startAccount", () => {
afterEach(() => {
vi.clearAllMocks();
@@ -30,28 +45,12 @@ describe("googlechatPlugin gateway.startAccount", () => {
const unregister = vi.fn();
hoisted.startGoogleChatMonitor.mockResolvedValue(unregister);
const account: ResolvedGoogleChatAccount = {
accountId: "default",
enabled: true,
credentialSource: "inline",
credentials: {},
config: {
webhookPath: "/googlechat",
webhookUrl: "https://example.com/googlechat",
audienceType: "app-url",
audience: "https://example.com/googlechat",
},
};
const { abort, patches, task, isSettled } = startAccountAndTrackLifecycle({
startAccount: googlechatPlugin.gateway!.startAccount!,
account,
account: buildAccount(),
});
await expectPendingUntilAbort({
waitForStarted: () =>
vi.waitFor(() => {
expect(hoisted.startGoogleChatMonitor).toHaveBeenCalledOnce();
}),
waitForStarted: waitForStartedMocks(hoisted.startGoogleChatMonitor),
isSettled,
abort,
task,
@@ -62,7 +61,7 @@ describe("googlechatPlugin gateway.startAccount", () => {
expect(unregister).toHaveBeenCalledOnce();
},
});
expect(patches.some((entry) => entry.running === true)).toBe(true);
expect(patches.some((entry) => entry.running === false)).toBe(true);
expectLifecyclePatch(patches, { running: true });
expectLifecyclePatch(patches, { running: false });
});
});

View File

@@ -2,6 +2,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
import {
expectStopPendingUntilAbort,
startAccountAndTrackLifecycle,
waitForStartedMocks,
} from "../../../test/helpers/extensions/start-account-lifecycle.js";
import type { ResolvedIrcAccount } from "./accounts.js";
@@ -19,6 +20,24 @@ vi.mock("./monitor.js", async () => {
import { ircPlugin } from "./channel.js";
function buildAccount(): ResolvedIrcAccount {
return {
accountId: "default",
enabled: true,
name: "default",
configured: true,
host: "irc.example.com",
port: 6697,
tls: true,
nick: "openclaw",
username: "openclaw",
realname: "OpenClaw",
password: "",
passwordSource: "none",
config: {} as ResolvedIrcAccount["config"],
};
}
describe("ircPlugin gateway.startAccount", () => {
afterEach(() => {
vi.clearAllMocks();
@@ -28,32 +47,13 @@ describe("ircPlugin gateway.startAccount", () => {
const stop = vi.fn();
hoisted.monitorIrcProvider.mockResolvedValue({ stop });
const account: ResolvedIrcAccount = {
accountId: "default",
enabled: true,
name: "default",
configured: true,
host: "irc.example.com",
port: 6697,
tls: true,
nick: "openclaw",
username: "openclaw",
realname: "OpenClaw",
password: "",
passwordSource: "none",
config: {} as ResolvedIrcAccount["config"],
};
const { abort, task, isSettled } = startAccountAndTrackLifecycle({
startAccount: ircPlugin.gateway!.startAccount!,
account,
account: buildAccount(),
});
await expectStopPendingUntilAbort({
waitForStarted: () =>
vi.waitFor(() => {
expect(hoisted.monitorIrcProvider).toHaveBeenCalledOnce();
}),
waitForStarted: waitForStartedMocks(hoisted.monitorIrcProvider),
isSettled,
abort,
task,

View File

@@ -1,12 +1,7 @@
import { describe, expect, it, vi } from "vitest";
import { createRuntimeEnv } from "../../../test/helpers/extensions/runtime-env.js";
import type {
ChannelGatewayContext,
ChannelAccountSnapshot,
OpenClawConfig,
PluginRuntime,
ResolvedLineAccount,
} from "../api.js";
import { createStartAccountContext } from "../../../test/helpers/extensions/start-account-context.js";
import type { OpenClawConfig, PluginRuntime, ResolvedLineAccount } from "../api.js";
import { linePlugin } from "./channel.js";
import { setLineRuntime } from "./runtime.js";
@@ -33,34 +28,14 @@ function createRuntime() {
return { runtime, probeLineBot, monitorLineProvider };
}
function createStartAccountCtx(params: {
token: string;
secret: string;
runtime: ReturnType<typeof createRuntimeEnv>;
abortSignal?: AbortSignal;
}): ChannelGatewayContext<ResolvedLineAccount> {
const snapshot: ChannelAccountSnapshot = {
accountId: "default",
configured: true,
enabled: true,
running: false,
};
function createAccount(params: { token: string; secret: string }): ResolvedLineAccount {
return {
accountId: "default",
account: {
accountId: "default",
enabled: true,
channelAccessToken: params.token,
channelSecret: params.secret,
tokenSource: "config" as const,
config: {} as ResolvedLineAccount["config"],
},
cfg: {} as OpenClawConfig,
runtime: params.runtime,
abortSignal: params.abortSignal ?? new AbortController().signal,
log: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
getStatus: () => snapshot,
setStatus: vi.fn(),
enabled: true,
channelAccessToken: params.token,
channelSecret: params.secret,
tokenSource: "config",
config: {} as ResolvedLineAccount["config"],
};
}
@@ -71,9 +46,8 @@ describe("linePlugin gateway.startAccount", () => {
await expect(
linePlugin.gateway!.startAccount!(
createStartAccountCtx({
token: "token",
secret: " ",
createStartAccountContext({
account: createAccount({ token: "token", secret: " " }),
runtime: createRuntimeEnv(),
}),
),
@@ -89,9 +63,8 @@ describe("linePlugin gateway.startAccount", () => {
await expect(
linePlugin.gateway!.startAccount!(
createStartAccountCtx({
token: " ",
secret: "secret",
createStartAccountContext({
account: createAccount({ token: " ", secret: "secret" }),
runtime: createRuntimeEnv(),
}),
),
@@ -107,9 +80,8 @@ describe("linePlugin gateway.startAccount", () => {
const abort = new AbortController();
const task = linePlugin.gateway!.startAccount!(
createStartAccountCtx({
token: "token",
secret: "secret",
createStartAccountContext({
account: createAccount({ token: "token", secret: "secret" }),
runtime: createRuntimeEnv(),
abortSignal: abort.signal,
}),

View File

@@ -3,6 +3,7 @@ import { createStartAccountContext } from "../../../test/helpers/extensions/star
import {
expectStopPendingUntilAbort,
startAccountAndTrackLifecycle,
waitForStartedMocks,
} from "../../../test/helpers/extensions/start-account-lifecycle.js";
import type { ResolvedNextcloudTalkAccount } from "./accounts.js";
@@ -49,10 +50,7 @@ describe("nextcloudTalkPlugin gateway.startAccount", () => {
account: buildAccount(),
});
await expectStopPendingUntilAbort({
waitForStarted: () =>
vi.waitFor(() => {
expect(hoisted.monitorNextcloudTalkProvider).toHaveBeenCalledOnce();
}),
waitForStarted: waitForStartedMocks(hoisted.monitorNextcloudTalkProvider),
isSettled,
abort,
task,

View File

@@ -3,6 +3,11 @@ import { createStartAccountContext } from "../../../test/helpers/extensions/star
import type { PluginRuntime } from "../runtime-api.js";
import { nostrPlugin } from "./channel.js";
import { setNostrRuntime } from "./runtime.js";
import {
TEST_RELAY_URL,
TEST_RESOLVED_PRIVATE_KEY,
buildResolvedNostrAccount,
} from "./test-fixtures.js";
const mocks = vi.hoisted(() => ({
normalizePubkey: vi.fn((value: string) => `normalized-${value.toLowerCase()}`),
@@ -16,6 +21,16 @@ vi.mock("./nostr-bus.js", () => ({
startNostrBus: mocks.startNostrBus,
}));
function createCfg() {
return {
channels: {
nostr: {
privateKey: TEST_RESOLVED_PRIVATE_KEY, // pragma: allowlist secret
},
},
};
}
describe("nostr outbound cfg threading", () => {
afterEach(() => {
mocks.normalizePubkey.mockClear();
@@ -47,26 +62,11 @@ describe("nostr outbound cfg threading", () => {
const cleanup = (await nostrPlugin.gateway!.startAccount!(
createStartAccountContext({
account: {
accountId: "default",
enabled: true,
configured: true,
privateKey: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", // pragma: allowlist secret
publicKey: "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789", // pragma: allowlist secret
relays: ["wss://relay.example.com"],
config: {},
},
abortSignal: new AbortController().signal,
account: buildResolvedNostrAccount(),
}),
)) as { stop: () => void };
const cfg = {
channels: {
nostr: {
privateKey: "resolved-nostr-private-key", // pragma: allowlist secret
},
},
};
const cfg = createCfg();
await nostrPlugin.outbound!.sendText!({
cfg: cfg as any,
to: "NPUB123",

View File

@@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest";
import { nostrPlugin } from "./channel.js";
import { TEST_HEX_PRIVATE_KEY, createConfiguredNostrCfg } from "./test-fixtures.js";
describe("nostrPlugin", () => {
describe("meta", () => {
@@ -42,13 +43,7 @@ describe("nostrPlugin", () => {
});
it("listAccountIds returns default for configured", () => {
const cfg = {
channels: {
nostr: {
privateKey: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
},
},
};
const cfg = createConfiguredNostrCfg();
const ids = nostrPlugin.config.listAccountIds(cfg);
expect(ids).toContain("default");
});
@@ -74,8 +69,7 @@ describe("nostrPlugin", () => {
return;
}
const hexPubkey = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
expect(looksLikeId(hexPubkey)).toBe(true);
expect(looksLikeId(TEST_HEX_PRIVATE_KEY)).toBe(true);
});
it("rejects invalid input", () => {
@@ -94,8 +88,7 @@ describe("nostrPlugin", () => {
return;
}
const hexPubkey = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
expect(normalize(`nostr:${hexPubkey}`)).toBe(hexPubkey);
expect(normalize(`nostr:${TEST_HEX_PRIVATE_KEY}`)).toBe(TEST_HEX_PRIVATE_KEY);
});
});
@@ -120,8 +113,7 @@ describe("nostrPlugin", () => {
return;
}
const hexPubkey = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
expect(normalize(`nostr:${hexPubkey}`)).toBe(hexPubkey);
expect(normalize(`nostr:${TEST_HEX_PRIVATE_KEY}`)).toBe(TEST_HEX_PRIVATE_KEY);
});
});

View File

@@ -2,6 +2,23 @@ import { describe, expect, it } from "vitest";
import { createMetrics, type MetricName } from "./metrics.js";
import { validatePrivateKey, isValidPubkey, normalizePubkey } from "./nostr-bus.js";
import { createSeenTracker } from "./seen-tracker.js";
import { TEST_HEX_PRIVATE_KEY } from "./test-fixtures.js";
function createTracker(maxEntries = 100) {
return createSeenTracker({ maxEntries });
}
function createPlainMetrics() {
return createMetrics();
}
function createCollectingMetrics() {
const events: unknown[] = [];
return {
events,
metrics: createMetrics((event) => events.push(event)),
};
}
// ============================================================================
// Fuzz Tests for validatePrivateKey
@@ -47,7 +64,7 @@ describe("validatePrivateKey fuzz", () => {
});
it("rejects RTL override", () => {
const withRtl = "\u202E0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
const withRtl = `\u202E${TEST_HEX_PRIVATE_KEY}`;
expect(() => validatePrivateKey(withRtl)).toThrow();
});
@@ -191,14 +208,12 @@ describe("normalizePubkey fuzz", () => {
describe("case sensitivity", () => {
it("normalizes uppercase to lowercase", () => {
const upper = "0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF";
const lower = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
expect(normalizePubkey(upper)).toBe(lower);
expect(normalizePubkey(upper)).toBe(TEST_HEX_PRIVATE_KEY);
});
it("normalizes mixed case to lowercase", () => {
const mixed = "0123456789AbCdEf0123456789AbCdEf0123456789AbCdEf0123456789AbCdEf";
const lower = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
expect(normalizePubkey(mixed)).toBe(lower);
expect(normalizePubkey(mixed)).toBe(TEST_HEX_PRIVATE_KEY);
});
});
});
@@ -210,14 +225,14 @@ describe("normalizePubkey fuzz", () => {
describe("SeenTracker fuzz", () => {
describe("malformed IDs", () => {
it("handles empty string IDs", () => {
const tracker = createSeenTracker({ maxEntries: 100 });
const tracker = createTracker();
expect(() => tracker.add("")).not.toThrow();
expect(tracker.peek("")).toBe(true);
tracker.stop();
});
it("handles very long IDs", () => {
const tracker = createSeenTracker({ maxEntries: 100 });
const tracker = createTracker();
const longId = "a".repeat(100000);
expect(() => tracker.add(longId)).not.toThrow();
expect(tracker.peek(longId)).toBe(true);
@@ -225,7 +240,7 @@ describe("SeenTracker fuzz", () => {
});
it("handles unicode IDs", () => {
const tracker = createSeenTracker({ maxEntries: 100 });
const tracker = createTracker();
const unicodeId = "事件ID_🎉_тест";
expect(() => tracker.add(unicodeId)).not.toThrow();
expect(tracker.peek(unicodeId)).toBe(true);
@@ -233,7 +248,7 @@ describe("SeenTracker fuzz", () => {
});
it("handles IDs with null bytes", () => {
const tracker = createSeenTracker({ maxEntries: 100 });
const tracker = createTracker();
const idWithNull = "event\x00id";
expect(() => tracker.add(idWithNull)).not.toThrow();
expect(tracker.peek(idWithNull)).toBe(true);
@@ -241,7 +256,7 @@ describe("SeenTracker fuzz", () => {
});
it("handles prototype property names as IDs", () => {
const tracker = createSeenTracker({ maxEntries: 100 });
const tracker = createTracker();
// These should not affect the tracker's internal operation
expect(() => tracker.add("__proto__")).not.toThrow();
@@ -260,7 +275,7 @@ describe("SeenTracker fuzz", () => {
describe("rapid operations", () => {
it("handles rapid add/check cycles", () => {
const tracker = createSeenTracker({ maxEntries: 1000 });
const tracker = createTracker(1000);
for (let i = 0; i < 10000; i++) {
const id = `event-${i}`;
@@ -277,7 +292,7 @@ describe("SeenTracker fuzz", () => {
});
it("handles concurrent-style operations", () => {
const tracker = createSeenTracker({ maxEntries: 100 });
const tracker = createTracker();
// Simulate interleaved operations
for (let i = 0; i < 100; i++) {
@@ -296,21 +311,21 @@ describe("SeenTracker fuzz", () => {
describe("seed edge cases", () => {
it("handles empty seed array", () => {
const tracker = createSeenTracker({ maxEntries: 100 });
const tracker = createTracker();
expect(() => tracker.seed([])).not.toThrow();
expect(tracker.size()).toBe(0);
tracker.stop();
});
it("handles seed with duplicate IDs", () => {
const tracker = createSeenTracker({ maxEntries: 100 });
const tracker = createTracker();
tracker.seed(["id1", "id1", "id1", "id2", "id2"]);
expect(tracker.size()).toBe(2);
tracker.stop();
});
it("handles seed larger than maxEntries", () => {
const tracker = createSeenTracker({ maxEntries: 5 });
const tracker = createTracker(5);
const ids = Array.from({ length: 100 }, (_, i) => `id-${i}`);
tracker.seed(ids);
expect(tracker.size()).toBeLessThanOrEqual(5);
@@ -326,7 +341,7 @@ describe("SeenTracker fuzz", () => {
describe("Metrics fuzz", () => {
describe("invalid metric names", () => {
it("handles unknown metric names gracefully", () => {
const metrics = createMetrics();
const metrics = createPlainMetrics();
// Cast to bypass type checking - testing runtime behavior
expect(() => {
@@ -337,21 +352,21 @@ describe("Metrics fuzz", () => {
describe("invalid label values", () => {
it("handles null relay label", () => {
const metrics = createMetrics();
const metrics = createPlainMetrics();
expect(() => {
metrics.emit("relay.connect", 1, { relay: null as unknown as string });
}).not.toThrow();
});
it("handles undefined relay label", () => {
const metrics = createMetrics();
const metrics = createPlainMetrics();
expect(() => {
metrics.emit("relay.connect", 1, { relay: undefined as unknown as string });
}).not.toThrow();
});
it("handles very long relay URL", () => {
const metrics = createMetrics();
const metrics = createPlainMetrics();
const longUrl = "wss://" + "a".repeat(10000) + ".com";
expect(() => {
metrics.emit("relay.connect", 1, { relay: longUrl });
@@ -364,7 +379,7 @@ describe("Metrics fuzz", () => {
describe("extreme values", () => {
it("handles NaN value", () => {
const metrics = createMetrics();
const metrics = createPlainMetrics();
expect(() => metrics.emit("event.received", NaN)).not.toThrow();
const snapshot = metrics.getSnapshot();
@@ -372,7 +387,7 @@ describe("Metrics fuzz", () => {
});
it("handles Infinity value", () => {
const metrics = createMetrics();
const metrics = createPlainMetrics();
expect(() => metrics.emit("event.received", Infinity)).not.toThrow();
const snapshot = metrics.getSnapshot();
@@ -380,7 +395,7 @@ describe("Metrics fuzz", () => {
});
it("handles negative value", () => {
const metrics = createMetrics();
const metrics = createPlainMetrics();
metrics.emit("event.received", -1);
const snapshot = metrics.getSnapshot();
@@ -388,7 +403,7 @@ describe("Metrics fuzz", () => {
});
it("handles very large value", () => {
const metrics = createMetrics();
const metrics = createPlainMetrics();
metrics.emit("event.received", Number.MAX_SAFE_INTEGER);
const snapshot = metrics.getSnapshot();
@@ -398,8 +413,7 @@ describe("Metrics fuzz", () => {
describe("rapid emissions", () => {
it("handles many rapid emissions", () => {
const events: unknown[] = [];
const metrics = createMetrics((e) => events.push(e));
const { events, metrics } = createCollectingMetrics();
for (let i = 0; i < 10000; i++) {
metrics.emit("event.received");
@@ -413,7 +427,7 @@ describe("Metrics fuzz", () => {
describe("reset during operation", () => {
it("handles reset mid-operation safely", () => {
const metrics = createMetrics();
const metrics = createPlainMetrics();
metrics.emit("event.received");
metrics.emit("event.received");

View File

@@ -1,6 +1,33 @@
import { describe, expect, it, vi } from "vitest";
import { createMetrics, createNoopMetrics, type MetricEvent } from "./metrics.js";
import { createSeenTracker } from "./seen-tracker.js";
import { TEST_RELAY_URL } from "./test-fixtures.js";
const TEST_RELAY_URL_1 = "wss://relay1.com";
const TEST_RELAY_URL_2 = "wss://relay2.com";
const TEST_RELAY_URL_PRIMARY = "wss://relay.com";
const TEST_RELAY_URL_GOOD = "wss://good-relay.com";
const TEST_RELAY_URL_BAD = "wss://bad-relay.com";
function createTracker(overrides?: Partial<Parameters<typeof createSeenTracker>[0]>) {
return createSeenTracker({
maxEntries: 100,
ttlMs: 60000,
...overrides,
});
}
function createCollectingMetrics() {
const events: MetricEvent[] = [];
return {
events,
metrics: createMetrics((event) => events.push(event)),
};
}
function createPlainMetrics() {
return createMetrics();
}
// ============================================================================
// Seen Tracker Integration Tests
@@ -9,7 +36,7 @@ import { createSeenTracker } from "./seen-tracker.js";
describe("SeenTracker", () => {
describe("basic operations", () => {
it("tracks seen IDs", () => {
const tracker = createSeenTracker({ maxEntries: 100, ttlMs: 60000 });
const tracker = createTracker();
// First check returns false and adds
expect(tracker.has("id1")).toBe(false);
@@ -20,7 +47,7 @@ describe("SeenTracker", () => {
});
it("peek does not add", () => {
const tracker = createSeenTracker({ maxEntries: 100, ttlMs: 60000 });
const tracker = createTracker();
expect(tracker.peek("id1")).toBe(false);
expect(tracker.peek("id1")).toBe(false); // Still false
@@ -32,7 +59,7 @@ describe("SeenTracker", () => {
});
it("delete removes entries", () => {
const tracker = createSeenTracker({ maxEntries: 100, ttlMs: 60000 });
const tracker = createTracker();
tracker.add("id1");
expect(tracker.peek("id1")).toBe(true);
@@ -44,7 +71,7 @@ describe("SeenTracker", () => {
});
it("clear removes all entries", () => {
const tracker = createSeenTracker({ maxEntries: 100, ttlMs: 60000 });
const tracker = createTracker();
tracker.add("id1");
tracker.add("id2");
@@ -59,7 +86,7 @@ describe("SeenTracker", () => {
});
it("seed pre-populates entries", () => {
const tracker = createSeenTracker({ maxEntries: 100, ttlMs: 60000 });
const tracker = createTracker();
tracker.seed(["id1", "id2", "id3"]);
expect(tracker.size()).toBe(3);
@@ -73,7 +100,7 @@ describe("SeenTracker", () => {
describe("LRU eviction", () => {
it("evicts least recently used when at capacity", () => {
const tracker = createSeenTracker({ maxEntries: 3, ttlMs: 60000 });
const tracker = createTracker({ maxEntries: 3 });
tracker.add("id1");
tracker.add("id2");
@@ -92,7 +119,7 @@ describe("SeenTracker", () => {
});
it("accessing an entry moves it to front (prevents eviction)", () => {
const tracker = createSeenTracker({ maxEntries: 3, ttlMs: 60000 });
const tracker = createTracker({ maxEntries: 3 });
tracker.add("id1");
tracker.add("id2");
@@ -112,7 +139,7 @@ describe("SeenTracker", () => {
});
it("handles capacity of 1", () => {
const tracker = createSeenTracker({ maxEntries: 1, ttlMs: 60000 });
const tracker = createTracker({ maxEntries: 1 });
tracker.add("id1");
expect(tracker.peek("id1")).toBe(true);
@@ -125,7 +152,7 @@ describe("SeenTracker", () => {
});
it("seed respects maxEntries", () => {
const tracker = createSeenTracker({ maxEntries: 2, ttlMs: 60000 });
const tracker = createTracker({ maxEntries: 2 });
tracker.seed(["id1", "id2", "id3", "id4"]);
expect(tracker.size()).toBe(2);
@@ -142,7 +169,7 @@ describe("SeenTracker", () => {
it("expires entries after TTL", async () => {
vi.useFakeTimers();
const tracker = createSeenTracker({
const tracker = createTracker({
maxEntries: 100,
ttlMs: 100,
pruneIntervalMs: 50,
@@ -164,7 +191,7 @@ describe("SeenTracker", () => {
it("has() refreshes TTL", async () => {
vi.useFakeTimers();
const tracker = createSeenTracker({
const tracker = createTracker({
maxEntries: 100,
ttlMs: 100,
pruneIntervalMs: 50,
@@ -197,8 +224,7 @@ describe("SeenTracker", () => {
describe("Metrics", () => {
describe("createMetrics", () => {
it("emits metric events to callback", () => {
const events: MetricEvent[] = [];
const metrics = createMetrics((event) => events.push(event));
const { events, metrics } = createCollectingMetrics();
metrics.emit("event.received");
metrics.emit("event.processed");
@@ -211,16 +237,15 @@ describe("Metrics", () => {
});
it("includes labels in metric events", () => {
const events: MetricEvent[] = [];
const metrics = createMetrics((event) => events.push(event));
const { events, metrics } = createCollectingMetrics();
metrics.emit("relay.connect", 1, { relay: "wss://relay.example.com" });
metrics.emit("relay.connect", 1, { relay: TEST_RELAY_URL });
expect(events[0].labels).toEqual({ relay: "wss://relay.example.com" });
expect(events[0].labels).toEqual({ relay: TEST_RELAY_URL });
});
it("accumulates counters in snapshot", () => {
const metrics = createMetrics();
const metrics = createPlainMetrics();
metrics.emit("event.received");
metrics.emit("event.received");
@@ -236,39 +261,39 @@ describe("Metrics", () => {
});
it("tracks per-relay stats", () => {
const metrics = createMetrics();
const metrics = createPlainMetrics();
metrics.emit("relay.connect", 1, { relay: "wss://relay1.com" });
metrics.emit("relay.connect", 1, { relay: "wss://relay2.com" });
metrics.emit("relay.error", 1, { relay: "wss://relay1.com" });
metrics.emit("relay.error", 1, { relay: "wss://relay1.com" });
metrics.emit("relay.connect", 1, { relay: TEST_RELAY_URL_1 });
metrics.emit("relay.connect", 1, { relay: TEST_RELAY_URL_2 });
metrics.emit("relay.error", 1, { relay: TEST_RELAY_URL_1 });
metrics.emit("relay.error", 1, { relay: TEST_RELAY_URL_1 });
const snapshot = metrics.getSnapshot();
expect(snapshot.relays["wss://relay1.com"]).toBeDefined();
expect(snapshot.relays["wss://relay1.com"].connects).toBe(1);
expect(snapshot.relays["wss://relay1.com"].errors).toBe(2);
expect(snapshot.relays["wss://relay2.com"].connects).toBe(1);
expect(snapshot.relays["wss://relay2.com"].errors).toBe(0);
expect(snapshot.relays[TEST_RELAY_URL_1]).toBeDefined();
expect(snapshot.relays[TEST_RELAY_URL_1].connects).toBe(1);
expect(snapshot.relays[TEST_RELAY_URL_1].errors).toBe(2);
expect(snapshot.relays[TEST_RELAY_URL_2].connects).toBe(1);
expect(snapshot.relays[TEST_RELAY_URL_2].errors).toBe(0);
});
it("tracks circuit breaker state changes", () => {
const metrics = createMetrics();
const metrics = createPlainMetrics();
metrics.emit("relay.circuit_breaker.open", 1, { relay: "wss://relay.com" });
metrics.emit("relay.circuit_breaker.open", 1, { relay: TEST_RELAY_URL_PRIMARY });
let snapshot = metrics.getSnapshot();
expect(snapshot.relays["wss://relay.com"].circuitBreakerState).toBe("open");
expect(snapshot.relays["wss://relay.com"].circuitBreakerOpens).toBe(1);
expect(snapshot.relays[TEST_RELAY_URL_PRIMARY].circuitBreakerState).toBe("open");
expect(snapshot.relays[TEST_RELAY_URL_PRIMARY].circuitBreakerOpens).toBe(1);
metrics.emit("relay.circuit_breaker.close", 1, { relay: "wss://relay.com" });
metrics.emit("relay.circuit_breaker.close", 1, { relay: TEST_RELAY_URL_PRIMARY });
snapshot = metrics.getSnapshot();
expect(snapshot.relays["wss://relay.com"].circuitBreakerState).toBe("closed");
expect(snapshot.relays["wss://relay.com"].circuitBreakerCloses).toBe(1);
expect(snapshot.relays[TEST_RELAY_URL_PRIMARY].circuitBreakerState).toBe("closed");
expect(snapshot.relays[TEST_RELAY_URL_PRIMARY].circuitBreakerCloses).toBe(1);
});
it("tracks all rejection reasons", () => {
const metrics = createMetrics();
const metrics = createPlainMetrics();
metrics.emit("event.rejected.invalid_shape");
metrics.emit("event.rejected.wrong_kind");
@@ -295,17 +320,17 @@ describe("Metrics", () => {
});
it("tracks relay message types", () => {
const metrics = createMetrics();
const metrics = createPlainMetrics();
metrics.emit("relay.message.event", 1, { relay: "wss://relay.com" });
metrics.emit("relay.message.eose", 1, { relay: "wss://relay.com" });
metrics.emit("relay.message.closed", 1, { relay: "wss://relay.com" });
metrics.emit("relay.message.notice", 1, { relay: "wss://relay.com" });
metrics.emit("relay.message.ok", 1, { relay: "wss://relay.com" });
metrics.emit("relay.message.auth", 1, { relay: "wss://relay.com" });
metrics.emit("relay.message.event", 1, { relay: TEST_RELAY_URL_PRIMARY });
metrics.emit("relay.message.eose", 1, { relay: TEST_RELAY_URL_PRIMARY });
metrics.emit("relay.message.closed", 1, { relay: TEST_RELAY_URL_PRIMARY });
metrics.emit("relay.message.notice", 1, { relay: TEST_RELAY_URL_PRIMARY });
metrics.emit("relay.message.ok", 1, { relay: TEST_RELAY_URL_PRIMARY });
metrics.emit("relay.message.auth", 1, { relay: TEST_RELAY_URL_PRIMARY });
const snapshot = metrics.getSnapshot();
const relay = snapshot.relays["wss://relay.com"];
const relay = snapshot.relays[TEST_RELAY_URL_PRIMARY];
expect(relay.messagesReceived.event).toBe(1);
expect(relay.messagesReceived.eose).toBe(1);
expect(relay.messagesReceived.closed).toBe(1);
@@ -315,7 +340,7 @@ describe("Metrics", () => {
});
it("tracks decrypt success/failure", () => {
const metrics = createMetrics();
const metrics = createPlainMetrics();
metrics.emit("decrypt.success");
metrics.emit("decrypt.success");
@@ -327,7 +352,7 @@ describe("Metrics", () => {
});
it("tracks memory gauges (replaces rather than accumulates)", () => {
const metrics = createMetrics();
const metrics = createPlainMetrics();
metrics.emit("memory.seen_tracker_size", 100);
metrics.emit("memory.seen_tracker_size", 150);
@@ -338,11 +363,11 @@ describe("Metrics", () => {
});
it("reset clears all counters", () => {
const metrics = createMetrics();
const metrics = createPlainMetrics();
metrics.emit("event.received");
metrics.emit("event.processed");
metrics.emit("relay.connect", 1, { relay: "wss://relay.com" });
metrics.emit("relay.connect", 1, { relay: TEST_RELAY_URL_PRIMARY });
metrics.reset();
@@ -359,7 +384,7 @@ describe("Metrics", () => {
expect(() => {
metrics.emit("event.received");
metrics.emit("relay.connect", 1, { relay: "wss://relay.com" });
metrics.emit("relay.connect", 1, { relay: TEST_RELAY_URL_PRIMARY });
}).not.toThrow();
});
@@ -380,18 +405,17 @@ describe("Metrics", () => {
describe("Circuit Breaker Behavior", () => {
// Test the circuit breaker logic through metrics emissions
it("emits circuit breaker metrics in correct sequence", () => {
const events: MetricEvent[] = [];
const metrics = createMetrics((event) => events.push(event));
const { events, metrics } = createCollectingMetrics();
// Simulate 5 failures -> open
for (let i = 0; i < 5; i++) {
metrics.emit("relay.error", 1, { relay: "wss://relay.com" });
metrics.emit("relay.error", 1, { relay: TEST_RELAY_URL_PRIMARY });
}
metrics.emit("relay.circuit_breaker.open", 1, { relay: "wss://relay.com" });
metrics.emit("relay.circuit_breaker.open", 1, { relay: TEST_RELAY_URL_PRIMARY });
// Simulate recovery
metrics.emit("relay.circuit_breaker.half_open", 1, { relay: "wss://relay.com" });
metrics.emit("relay.circuit_breaker.close", 1, { relay: "wss://relay.com" });
metrics.emit("relay.circuit_breaker.half_open", 1, { relay: TEST_RELAY_URL_PRIMARY });
metrics.emit("relay.circuit_breaker.close", 1, { relay: TEST_RELAY_URL_PRIMARY });
const cbEvents = events.filter((e) => e.name.startsWith("relay.circuit_breaker"));
expect(cbEvents).toHaveLength(3);
@@ -407,19 +431,19 @@ describe("Circuit Breaker Behavior", () => {
describe("Health Scoring", () => {
it("metrics track relay errors for health scoring", () => {
const metrics = createMetrics();
const metrics = createPlainMetrics();
// Simulate mixed success/failure pattern
metrics.emit("relay.connect", 1, { relay: "wss://good-relay.com" });
metrics.emit("relay.connect", 1, { relay: "wss://bad-relay.com" });
metrics.emit("relay.connect", 1, { relay: TEST_RELAY_URL_GOOD });
metrics.emit("relay.connect", 1, { relay: TEST_RELAY_URL_BAD });
metrics.emit("relay.error", 1, { relay: "wss://bad-relay.com" });
metrics.emit("relay.error", 1, { relay: "wss://bad-relay.com" });
metrics.emit("relay.error", 1, { relay: "wss://bad-relay.com" });
metrics.emit("relay.error", 1, { relay: TEST_RELAY_URL_BAD });
metrics.emit("relay.error", 1, { relay: TEST_RELAY_URL_BAD });
metrics.emit("relay.error", 1, { relay: TEST_RELAY_URL_BAD });
const snapshot = metrics.getSnapshot();
expect(snapshot.relays["wss://good-relay.com"].errors).toBe(0);
expect(snapshot.relays["wss://bad-relay.com"].errors).toBe(3);
expect(snapshot.relays[TEST_RELAY_URL_GOOD].errors).toBe(0);
expect(snapshot.relays[TEST_RELAY_URL_BAD].errors).toBe(3);
});
});

View File

@@ -6,26 +6,23 @@ import {
normalizePubkey,
pubkeyToNpub,
} from "./nostr-bus.js";
// Test private key (DO NOT use in production - this is a known test key)
const TEST_HEX_KEY = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
const TEST_NSEC = "nsec1qypqxpq9qtpqscx7peytzfwtdjmcv0mrz5rjpej8vjppfkqfqy8skqfv3l";
import { TEST_HEX_PRIVATE_KEY, TEST_NSEC } from "./test-fixtures.js";
describe("validatePrivateKey", () => {
describe("hex format", () => {
it("accepts valid 64-char hex key", () => {
const result = validatePrivateKey(TEST_HEX_KEY);
const result = validatePrivateKey(TEST_HEX_PRIVATE_KEY);
expect(result).toBeInstanceOf(Uint8Array);
expect(result.length).toBe(32);
});
it("accepts lowercase hex", () => {
const result = validatePrivateKey(TEST_HEX_KEY.toLowerCase());
const result = validatePrivateKey(TEST_HEX_PRIVATE_KEY.toLowerCase());
expect(result).toBeInstanceOf(Uint8Array);
});
it("accepts uppercase hex", () => {
const result = validatePrivateKey(TEST_HEX_KEY.toUpperCase());
const result = validatePrivateKey(TEST_HEX_PRIVATE_KEY.toUpperCase());
expect(result).toBeInstanceOf(Uint8Array);
});
@@ -36,23 +33,23 @@ describe("validatePrivateKey", () => {
});
it("trims whitespace", () => {
const result = validatePrivateKey(` ${TEST_HEX_KEY} `);
const result = validatePrivateKey(` ${TEST_HEX_PRIVATE_KEY} `);
expect(result).toBeInstanceOf(Uint8Array);
});
it("trims newlines", () => {
const result = validatePrivateKey(`${TEST_HEX_KEY}\n`);
const result = validatePrivateKey(`${TEST_HEX_PRIVATE_KEY}\n`);
expect(result).toBeInstanceOf(Uint8Array);
});
it("rejects 63-char hex (too short)", () => {
expect(() => validatePrivateKey(TEST_HEX_KEY.slice(0, 63))).toThrow(
expect(() => validatePrivateKey(TEST_HEX_PRIVATE_KEY.slice(0, 63))).toThrow(
"Private key must be 64 hex characters",
);
});
it("rejects 65-char hex (too long)", () => {
expect(() => validatePrivateKey(TEST_HEX_KEY + "0")).toThrow(
expect(() => validatePrivateKey(TEST_HEX_PRIVATE_KEY + "0")).toThrow(
"Private key must be 64 hex characters",
);
});
@@ -71,7 +68,7 @@ describe("validatePrivateKey", () => {
});
it("rejects key with 0x prefix", () => {
expect(() => validatePrivateKey("0x" + TEST_HEX_KEY)).toThrow(
expect(() => validatePrivateKey("0x" + TEST_HEX_PRIVATE_KEY)).toThrow(
"Private key must be 64 hex characters",
);
});
@@ -93,8 +90,7 @@ describe("validatePrivateKey", () => {
describe("isValidPubkey", () => {
describe("hex format", () => {
it("accepts valid 64-char hex pubkey", () => {
const validHex = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
expect(isValidPubkey(validHex)).toBe(true);
expect(isValidPubkey(TEST_HEX_PRIVATE_KEY)).toBe(true);
});
it("accepts uppercase hex", () => {
@@ -108,7 +104,7 @@ describe("isValidPubkey", () => {
});
it("rejects 65-char hex", () => {
const longHex = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0";
const longHex = `${TEST_HEX_PRIVATE_KEY}0`;
expect(isValidPubkey(longHex)).toBe(false);
});
@@ -134,8 +130,7 @@ describe("isValidPubkey", () => {
});
it("handles whitespace-padded input", () => {
const validHex = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
expect(isValidPubkey(` ${validHex} `)).toBe(true);
expect(isValidPubkey(` ${TEST_HEX_PRIVATE_KEY} `)).toBe(true);
});
});
});
@@ -149,8 +144,7 @@ describe("normalizePubkey", () => {
});
it("trims whitespace", () => {
const hex = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
expect(normalizePubkey(` ${hex} `)).toBe(hex);
expect(normalizePubkey(` ${TEST_HEX_PRIVATE_KEY} `)).toBe(TEST_HEX_PRIVATE_KEY);
});
it("rejects invalid hex", () => {
@@ -161,14 +155,14 @@ describe("normalizePubkey", () => {
describe("getPublicKeyFromPrivate", () => {
it("derives public key from hex private key", () => {
const pubkey = getPublicKeyFromPrivate(TEST_HEX_KEY);
const pubkey = getPublicKeyFromPrivate(TEST_HEX_PRIVATE_KEY);
expect(pubkey).toMatch(/^[0-9a-f]{64}$/);
expect(pubkey.length).toBe(64);
});
it("derives consistent public key", () => {
const pubkey1 = getPublicKeyFromPrivate(TEST_HEX_KEY);
const pubkey2 = getPublicKeyFromPrivate(TEST_HEX_KEY);
const pubkey1 = getPublicKeyFromPrivate(TEST_HEX_PRIVATE_KEY);
const pubkey2 = getPublicKeyFromPrivate(TEST_HEX_PRIVATE_KEY);
expect(pubkey1).toBe(pubkey2);
});
@@ -179,21 +173,18 @@ describe("getPublicKeyFromPrivate", () => {
describe("pubkeyToNpub", () => {
it("converts hex pubkey to npub format", () => {
const hex = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
const npub = pubkeyToNpub(hex);
const npub = pubkeyToNpub(TEST_HEX_PRIVATE_KEY);
expect(npub).toMatch(/^npub1[a-z0-9]+$/);
});
it("produces consistent output", () => {
const hex = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
const npub1 = pubkeyToNpub(hex);
const npub2 = pubkeyToNpub(hex);
const npub1 = pubkeyToNpub(TEST_HEX_PRIVATE_KEY);
const npub2 = pubkeyToNpub(TEST_HEX_PRIVATE_KEY);
expect(npub1).toBe(npub2);
});
it("normalizes uppercase hex first", () => {
const lower = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
const upper = lower.toUpperCase();
expect(pubkeyToNpub(lower)).toBe(pubkeyToNpub(upper));
const upper = TEST_HEX_PRIVATE_KEY.toUpperCase();
expect(pubkeyToNpub(TEST_HEX_PRIVATE_KEY)).toBe(pubkeyToNpub(upper));
});
});

View File

@@ -27,11 +27,14 @@ vi.mock("./nostr-profile-import.js", () => ({
import { publishNostrProfile, getNostrProfileState } from "./channel.js";
import { importProfileFromRelays } from "./nostr-profile-import.js";
import { TEST_HEX_PUBLIC_KEY, TEST_SETUP_RELAY_URLS } from "./test-fixtures.js";
// ============================================================================
// Test Helpers
// ============================================================================
const TEST_PROFILE_RELAY_URL = TEST_SETUP_RELAY_URLS[0];
function createMockRequest(
method: string,
url: string,
@@ -98,13 +101,15 @@ function createMockResponse(): ServerResponse & {
return res as ServerResponse & { _getData: () => string; _getStatusCode: () => number };
}
type MockResponse = ReturnType<typeof createMockResponse>;
function createMockContext(overrides?: Partial<NostrProfileHttpContext>): NostrProfileHttpContext {
return {
getConfigProfile: vi.fn().mockReturnValue(undefined),
updateConfigProfile: vi.fn().mockResolvedValue(undefined),
getAccountInfo: vi.fn().mockReturnValue({
pubkey: "abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234",
relays: ["wss://relay.damus.io"],
pubkey: TEST_HEX_PUBLIC_KEY,
relays: [TEST_PROFILE_RELAY_URL],
}),
log: {
info: vi.fn(),
@@ -115,7 +120,29 @@ function createMockContext(overrides?: Partial<NostrProfileHttpContext>): NostrP
};
}
function expectOkResponse(res: ReturnType<typeof createMockResponse>) {
function createProfileHttpHarness(
method: string,
url: string,
options?: {
body?: unknown;
ctx?: Partial<NostrProfileHttpContext>;
req?: Parameters<typeof createMockRequest>[3];
},
) {
const ctx = createMockContext(options?.ctx);
const handler = createNostrProfileHttpHandler(ctx);
const req = createMockRequest(method, url, options?.body, options?.req);
const res = createMockResponse();
return {
ctx,
req,
res,
run: () => handler(req, res),
};
}
function expectOkResponse(res: MockResponse) {
expect(res._getStatusCode()).toBe(200);
const data = JSON.parse(res._getData());
expect(data.ok).toBe(true);
@@ -131,11 +158,11 @@ function mockSuccessfulProfileImport() {
},
event: {
id: "evt123",
pubkey: "abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234",
pubkey: TEST_HEX_PUBLIC_KEY,
created_at: 1234567890,
},
relaysQueried: ["wss://relay.damus.io"],
sourceRelay: "wss://relay.damus.io",
relaysQueried: [TEST_PROFILE_RELAY_URL],
sourceRelay: TEST_PROFILE_RELAY_URL,
});
}
@@ -151,36 +178,25 @@ describe("nostr-profile-http", () => {
describe("route matching", () => {
it("returns false for non-nostr paths", async () => {
const ctx = createMockContext();
const handler = createNostrProfileHttpHandler(ctx);
const req = createMockRequest("GET", "/api/channels/telegram/profile");
const res = createMockResponse();
const result = await handler(req, res);
const { res, run } = createProfileHttpHarness("GET", "/api/channels/telegram/profile");
const result = await run();
expect(result).toBe(false);
});
it("returns false for paths without accountId", async () => {
const ctx = createMockContext();
const handler = createNostrProfileHttpHandler(ctx);
const req = createMockRequest("GET", "/api/channels/nostr/");
const res = createMockResponse();
const result = await handler(req, res);
const { res, run } = createProfileHttpHarness("GET", "/api/channels/nostr/");
const result = await run();
expect(result).toBe(false);
});
it("handles /api/channels/nostr/:accountId/profile", async () => {
const ctx = createMockContext();
const handler = createNostrProfileHttpHandler(ctx);
const req = createMockRequest("GET", "/api/channels/nostr/default/profile");
const res = createMockResponse();
const { run } = createProfileHttpHarness("GET", "/api/channels/nostr/default/profile");
vi.mocked(getNostrProfileState).mockResolvedValue(null);
const result = await handler(req, res);
const result = await run();
expect(result).toBe(true);
});
@@ -188,23 +204,22 @@ describe("nostr-profile-http", () => {
describe("GET /api/channels/nostr/:accountId/profile", () => {
it("returns profile and publish state", async () => {
const ctx = createMockContext({
getConfigProfile: vi.fn().mockReturnValue({
name: "testuser",
displayName: "Test User",
}),
const { res, run } = createProfileHttpHarness("GET", "/api/channels/nostr/default/profile", {
ctx: {
getConfigProfile: vi.fn().mockReturnValue({
name: "testuser",
displayName: "Test User",
}),
},
});
const handler = createNostrProfileHttpHandler(ctx);
const req = createMockRequest("GET", "/api/channels/nostr/default/profile");
const res = createMockResponse();
vi.mocked(getNostrProfileState).mockResolvedValue({
lastPublishedAt: 1234567890,
lastPublishedEventId: "abc123",
lastPublishResults: { "wss://relay.damus.io": "ok" },
lastPublishResults: { [TEST_PROFILE_RELAY_URL]: "ok" },
});
await handler(req, res);
await run();
expect(res._getStatusCode()).toBe(200);
const data = JSON.parse(res._getData());
@@ -219,7 +234,7 @@ describe("nostr-profile-http", () => {
vi.mocked(publishNostrProfile).mockResolvedValue({
eventId: "event123",
createdAt: 1234567890,
successes: ["wss://relay.damus.io"],
successes: [TEST_PROFILE_RELAY_URL],
failures: [],
});
}
@@ -232,98 +247,80 @@ describe("nostr-profile-http", () => {
}
async function expectPrivatePictureRejected(pictureUrl: string) {
const ctx = createMockContext();
const handler = createNostrProfileHttpHandler(ctx);
const req = createMockRequest("PUT", "/api/channels/nostr/default/profile", {
name: "hacker",
picture: pictureUrl,
const { res, run } = createProfileHttpHarness("PUT", "/api/channels/nostr/default/profile", {
body: {
name: "hacker",
picture: pictureUrl,
},
});
const res = createMockResponse();
await handler(req, res);
await run();
const data = expectBadRequestResponse(res);
expect(data.error).toContain("private");
}
it("validates profile and publishes", async () => {
const ctx = createMockContext();
const handler = createNostrProfileHttpHandler(ctx);
const req = createMockRequest("PUT", "/api/channels/nostr/default/profile", {
name: "satoshi",
displayName: "Satoshi Nakamoto",
about: "Creator of Bitcoin",
});
const res = createMockResponse();
const { ctx, res, run } = createProfileHttpHarness(
"PUT",
"/api/channels/nostr/default/profile",
{
body: {
name: "satoshi",
displayName: "Satoshi Nakamoto",
about: "Creator of Bitcoin",
},
},
);
mockPublishSuccess();
await handler(req, res);
await run();
const data = expectOkResponse(res);
expect(data.eventId).toBe("event123");
expect(data.successes).toContain("wss://relay.damus.io");
expect(data.successes).toContain(TEST_PROFILE_RELAY_URL);
expect(data.persisted).toBe(true);
expect(ctx.updateConfigProfile).toHaveBeenCalled();
});
it("rejects profile mutation from non-loopback remote address", async () => {
const ctx = createMockContext();
const handler = createNostrProfileHttpHandler(ctx);
const req = createMockRequest(
"PUT",
"/api/channels/nostr/default/profile",
{ name: "attacker" },
{ remoteAddress: "198.51.100.10" },
);
const res = createMockResponse();
const { res, run } = createProfileHttpHarness("PUT", "/api/channels/nostr/default/profile", {
body: { name: "attacker" },
req: { remoteAddress: "198.51.100.10" },
});
await handler(req, res);
await run();
expect(res._getStatusCode()).toBe(403);
});
it("rejects cross-origin profile mutation attempts", async () => {
const ctx = createMockContext();
const handler = createNostrProfileHttpHandler(ctx);
const req = createMockRequest(
"PUT",
"/api/channels/nostr/default/profile",
{ name: "attacker" },
{ headers: { origin: "https://evil.example" } },
);
const res = createMockResponse();
const { res, run } = createProfileHttpHarness("PUT", "/api/channels/nostr/default/profile", {
body: { name: "attacker" },
req: { headers: { origin: "https://evil.example" } },
});
await handler(req, res);
await run();
expect(res._getStatusCode()).toBe(403);
});
it("rejects profile mutation with cross-site sec-fetch-site header", async () => {
const ctx = createMockContext();
const handler = createNostrProfileHttpHandler(ctx);
const req = createMockRequest(
"PUT",
"/api/channels/nostr/default/profile",
{ name: "attacker" },
{ headers: { "sec-fetch-site": "cross-site" } },
);
const res = createMockResponse();
const { res, run } = createProfileHttpHarness("PUT", "/api/channels/nostr/default/profile", {
body: { name: "attacker" },
req: { headers: { "sec-fetch-site": "cross-site" } },
});
await handler(req, res);
await run();
expect(res._getStatusCode()).toBe(403);
});
it("rejects profile mutation when forwarded client ip is non-loopback", async () => {
const ctx = createMockContext();
const handler = createNostrProfileHttpHandler(ctx);
const req = createMockRequest(
"PUT",
"/api/channels/nostr/default/profile",
{ name: "attacker" },
{ headers: { "x-forwarded-for": "203.0.113.99, 127.0.0.1" } },
);
const res = createMockResponse();
const { res, run } = createProfileHttpHarness("PUT", "/api/channels/nostr/default/profile", {
body: { name: "attacker" },
req: { headers: { "x-forwarded-for": "203.0.113.99, 127.0.0.1" } },
});
await handler(req, res);
await run();
expect(res._getStatusCode()).toBe(403);
});
@@ -336,15 +333,14 @@ describe("nostr-profile-http", () => {
});
it("rejects non-https URLs", async () => {
const ctx = createMockContext();
const handler = createNostrProfileHttpHandler(ctx);
const req = createMockRequest("PUT", "/api/channels/nostr/default/profile", {
name: "test",
picture: "http://example.com/pic.jpg",
const { res, run } = createProfileHttpHarness("PUT", "/api/channels/nostr/default/profile", {
body: {
name: "test",
picture: "http://example.com/pic.jpg",
},
});
const res = createMockResponse();
await handler(req, res);
await run();
const data = expectBadRequestResponse(res);
// The schema validation catches non-https URLs before SSRF check
@@ -354,21 +350,24 @@ describe("nostr-profile-http", () => {
});
it("does not persist if all relays fail", async () => {
const ctx = createMockContext();
const handler = createNostrProfileHttpHandler(ctx);
const req = createMockRequest("PUT", "/api/channels/nostr/default/profile", {
name: "test",
});
const res = createMockResponse();
const { ctx, res, run } = createProfileHttpHarness(
"PUT",
"/api/channels/nostr/default/profile",
{
body: {
name: "test",
},
},
);
vi.mocked(publishNostrProfile).mockResolvedValue({
eventId: "event123",
createdAt: 1234567890,
successes: [],
failures: [{ relay: "wss://relay.damus.io", error: "timeout" }],
failures: [{ relay: TEST_PROFILE_RELAY_URL, error: "timeout" }],
});
await handler(req, res);
await run();
expect(res._getStatusCode()).toBe(200);
const data = JSON.parse(res._getData());
@@ -377,18 +376,20 @@ describe("nostr-profile-http", () => {
});
it("enforces rate limiting", async () => {
const ctx = createMockContext();
const handler = createNostrProfileHttpHandler(ctx);
mockPublishSuccess();
// Make 6 requests (limit is 5/min)
for (let i = 0; i < 6; i++) {
const req = createMockRequest("PUT", "/api/channels/nostr/rate-test/profile", {
name: `user${i}`,
});
const res = createMockResponse();
await handler(req, res);
const { res, run } = createProfileHttpHarness(
"PUT",
"/api/channels/nostr/rate-test/profile",
{
body: {
name: `user${i}`,
},
},
);
await run();
if (i < 5) {
expectOkResponse(res);
@@ -428,77 +429,77 @@ describe("nostr-profile-http", () => {
}
it("imports profile from relays", async () => {
const ctx = createMockContext();
const handler = createNostrProfileHttpHandler(ctx);
const req = createMockRequest("POST", "/api/channels/nostr/default/profile/import", {});
const res = createMockResponse();
const { res, run } = createProfileHttpHarness(
"POST",
"/api/channels/nostr/default/profile/import",
{ body: {} },
);
mockSuccessfulProfileImport();
await handler(req, res);
await run();
const data = expectImportSuccessResponse(res);
expect(data.saved).toBe(false); // autoMerge not requested
});
it("rejects import mutation from non-loopback remote address", async () => {
const ctx = createMockContext();
const handler = createNostrProfileHttpHandler(ctx);
const req = createMockRequest(
const { res, run } = createProfileHttpHarness(
"POST",
"/api/channels/nostr/default/profile/import",
{},
{ remoteAddress: "203.0.113.10" },
{
body: {},
req: { remoteAddress: "203.0.113.10" },
},
);
const res = createMockResponse();
await handler(req, res);
await run();
expect(res._getStatusCode()).toBe(403);
});
it("rejects cross-origin import mutation attempts", async () => {
const ctx = createMockContext();
const handler = createNostrProfileHttpHandler(ctx);
const req = createMockRequest(
const { res, run } = createProfileHttpHarness(
"POST",
"/api/channels/nostr/default/profile/import",
{},
{ headers: { origin: "https://evil.example" } },
{
body: {},
req: { headers: { origin: "https://evil.example" } },
},
);
const res = createMockResponse();
await handler(req, res);
await run();
expect(res._getStatusCode()).toBe(403);
});
it("rejects import mutation when x-real-ip is non-loopback", async () => {
const ctx = createMockContext();
const handler = createNostrProfileHttpHandler(ctx);
const req = createMockRequest(
const { res, run } = createProfileHttpHarness(
"POST",
"/api/channels/nostr/default/profile/import",
{},
{ headers: { "x-real-ip": "198.51.100.55" } },
{
body: {},
req: { headers: { "x-real-ip": "198.51.100.55" } },
},
);
const res = createMockResponse();
await handler(req, res);
await run();
expect(res._getStatusCode()).toBe(403);
});
it("auto-merges when requested", async () => {
const ctx = createMockContext({
getConfigProfile: vi.fn().mockReturnValue({ about: "local bio" }),
});
const handler = createNostrProfileHttpHandler(ctx);
const req = createMockRequest("POST", "/api/channels/nostr/default/profile/import", {
autoMerge: true,
});
const res = createMockResponse();
const { ctx, res, run } = createProfileHttpHarness(
"POST",
"/api/channels/nostr/default/profile/import",
{
body: { autoMerge: true },
ctx: {
getConfigProfile: vi.fn().mockReturnValue({ about: "local bio" }),
},
},
);
mockSuccessfulProfileImport();
await handler(req, res);
await run();
const data = expectImportSuccessResponse(res);
expect(data.saved).toBe(true);
@@ -506,14 +507,18 @@ describe("nostr-profile-http", () => {
});
it("returns error when account not found", async () => {
const ctx = createMockContext({
getAccountInfo: vi.fn().mockReturnValue(null),
});
const handler = createNostrProfileHttpHandler(ctx);
const req = createMockRequest("POST", "/api/channels/nostr/unknown/profile/import", {});
const res = createMockResponse();
const { res, run } = createProfileHttpHarness(
"POST",
"/api/channels/nostr/unknown/profile/import",
{
body: {},
ctx: {
getAccountInfo: vi.fn().mockReturnValue(null),
},
},
);
await handler(req, res);
await run();
expect(res._getStatusCode()).toBe(404);
const data = JSON.parse(res._getData());

View File

@@ -6,10 +6,11 @@ import {
validateProfile,
sanitizeProfileForDisplay,
} from "./nostr-profile.js";
import { TEST_HEX_PRIVATE_KEY_BYTES } from "./test-fixtures.js";
// Test private key
const TEST_HEX_KEY = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
const TEST_SK = new Uint8Array(TEST_HEX_KEY.match(/.{2}/g)!.map((byte) => parseInt(byte, 16)));
function createTestProfileEvent(profile: NostrProfile, lastPublishedAt?: number) {
return createProfileEvent(TEST_HEX_PRIVATE_KEY_BYTES, profile, lastPublishedAt);
}
// ============================================================================
// Unicode Attack Vectors
@@ -444,7 +445,7 @@ describe("event creation edge cases", () => {
lud16: "e".repeat(200) + "@example.com",
};
const event = createProfileEvent(TEST_SK, profile);
const event = createTestProfileEvent(profile);
expect(event.kind).toBe(0);
// Content should be parseable JSON
@@ -457,7 +458,7 @@ describe("event creation edge cases", () => {
// Create events in quick succession
let lastTimestamp = 0;
for (let i = 0; i < 25; i++) {
const event = createProfileEvent(TEST_SK, profile, lastTimestamp);
const event = createTestProfileEvent(profile, lastTimestamp);
expect(event.created_at).toBeGreaterThan(lastTimestamp);
lastTimestamp = event.created_at;
}
@@ -469,7 +470,7 @@ describe("event creation edge cases", () => {
about: "line1\nline2\ttab\\backslash",
};
const event = createProfileEvent(TEST_SK, profile);
const event = createTestProfileEvent(profile);
const parsed = JSON.parse(event.content) as { name: string; about: string };
expect(parsed.name).toBe('test"user');

View File

@@ -9,11 +9,13 @@ import {
sanitizeProfileForDisplay,
type ProfileContent,
} from "./nostr-profile.js";
import { TEST_HEX_PRIVATE_KEY_BYTES } from "./test-fixtures.js";
// Test private key (DO NOT use in production - this is a known test key)
const TEST_HEX_KEY = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
const TEST_SK = new Uint8Array(TEST_HEX_KEY.match(/.{2}/g)!.map((byte) => parseInt(byte, 16)));
const TEST_PUBKEY = getPublicKey(TEST_SK);
const TEST_PUBKEY = getPublicKey(TEST_HEX_PRIVATE_KEY_BYTES);
function createTestProfileEvent(profile: NostrProfile, lastPublishedAt?: number) {
return createProfileEvent(TEST_HEX_PRIVATE_KEY_BYTES, profile, lastPublishedAt);
}
// ============================================================================
// Profile Content Conversion Tests
@@ -123,7 +125,7 @@ describe("createProfileEvent", () => {
about: "A test bot",
};
const event = createProfileEvent(TEST_SK, profile);
const event = createTestProfileEvent(profile);
expect(event.kind).toBe(0);
expect(event.pubkey).toBe(TEST_PUBKEY);
@@ -139,7 +141,7 @@ describe("createProfileEvent", () => {
about: "Testing JSON serialization",
};
const event = createProfileEvent(TEST_SK, profile);
const event = createTestProfileEvent(profile);
const parsedContent = JSON.parse(event.content) as ProfileContent;
expect(parsedContent.name).toBe("jsontest");
@@ -149,14 +151,14 @@ describe("createProfileEvent", () => {
it("produces a verifiable signature", () => {
const profile: NostrProfile = { name: "signaturetest" };
const event = createProfileEvent(TEST_SK, profile);
const event = createTestProfileEvent(profile);
expect(verifyEvent(event)).toBe(true);
});
it("uses current timestamp when no lastPublishedAt provided", () => {
const profile: NostrProfile = { name: "timestamptest" };
const event = createProfileEvent(TEST_SK, profile);
const event = createTestProfileEvent(profile);
const expectedTimestamp = Math.floor(Date.now() / 1000);
expect(event.created_at).toBe(expectedTimestamp);
@@ -167,7 +169,7 @@ describe("createProfileEvent", () => {
const futureTimestamp = 1705320000 + 3600; // 1 hour in the future
const profile: NostrProfile = { name: "monotonictest" };
const event = createProfileEvent(TEST_SK, profile, futureTimestamp);
const event = createTestProfileEvent(profile, futureTimestamp);
expect(event.created_at).toBe(futureTimestamp + 1);
});
@@ -176,7 +178,7 @@ describe("createProfileEvent", () => {
const pastTimestamp = 1705320000 - 3600; // 1 hour in the past
const profile: NostrProfile = { name: "pasttest" };
const event = createProfileEvent(TEST_SK, profile, pastTimestamp);
const event = createTestProfileEvent(profile, pastTimestamp);
const expectedTimestamp = Math.floor(Date.now() / 1000);
expect(event.created_at).toBe(expectedTimestamp);
@@ -364,7 +366,7 @@ describe("edge cases", () => {
expect(content.name).toBe("🤖 Bot");
expect(content.about).toBe("I am a 🤖 robot! 🎉");
const event = createProfileEvent(TEST_SK, profile);
const event = createTestProfileEvent(profile);
const parsed = JSON.parse(event.content) as ProfileContent;
expect(parsed.name).toBe("🤖 Bot");
});
@@ -378,7 +380,7 @@ describe("edge cases", () => {
const content = profileToContent(profile);
expect(content.name).toBe("日本語ユーザー");
const event = createProfileEvent(TEST_SK, profile);
const event = createTestProfileEvent(profile);
expect(verifyEvent(event)).toBe(true);
});
@@ -390,7 +392,7 @@ describe("edge cases", () => {
const content = profileToContent(profile);
expect(content.about).toBe("Line 1\nLine 2\nLine 3");
const event = createProfileEvent(TEST_SK, profile);
const event = createTestProfileEvent(profile);
const parsed = JSON.parse(event.content) as ProfileContent;
expect(parsed.about).toBe("Line 1\nLine 2\nLine 3");
});
@@ -404,7 +406,7 @@ describe("edge cases", () => {
const result = validateProfile(profile);
expect(result.valid).toBe(true);
const event = createProfileEvent(TEST_SK, profile);
const event = createTestProfileEvent(profile);
expect(verifyEvent(event)).toBe(true);
});
});

View File

@@ -7,6 +7,7 @@ import {
} from "../../../test/helpers/extensions/setup-wizard.js";
import type { OpenClawConfig } from "../runtime-api.js";
import { nostrPlugin } from "./channel.js";
import { TEST_HEX_PRIVATE_KEY, TEST_SETUP_RELAY_URLS } from "./test-fixtures.js";
const nostrConfigure = createPluginSetupWizardConfigure(nostrPlugin);
@@ -15,10 +16,10 @@ describe("nostr setup wizard", () => {
const prompter = createTestWizardPrompter({
text: vi.fn(async ({ message }: { message: string }) => {
if (message === "Nostr private key (nsec... or hex)") {
return "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
return TEST_HEX_PRIVATE_KEY;
}
if (message === "Relay URLs (comma-separated, optional)") {
return "wss://relay.damus.io, wss://relay.primal.net";
return TEST_SETUP_RELAY_URLS.join(", ");
}
throw new Error(`Unexpected prompt: ${message}`);
}) as WizardPrompter["text"],
@@ -33,12 +34,7 @@ describe("nostr setup wizard", () => {
expect(result.accountId).toBe("default");
expect(result.cfg.channels?.nostr?.enabled).toBe(true);
expect(result.cfg.channels?.nostr?.privateKey).toBe(
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
);
expect(result.cfg.channels?.nostr?.relays).toEqual([
"wss://relay.damus.io",
"wss://relay.primal.net",
]);
expect(result.cfg.channels?.nostr?.privateKey).toBe(TEST_HEX_PRIVATE_KEY);
expect(result.cfg.channels?.nostr?.relays).toEqual(TEST_SETUP_RELAY_URLS);
});
});

View File

@@ -0,0 +1,46 @@
import type { OpenClawConfig } from "../api.js";
import type { ResolvedNostrAccount } from "./types.js";
export const TEST_HEX_PRIVATE_KEY =
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
export const TEST_HEX_PUBLIC_KEY =
"abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789";
export const TEST_NSEC = "nsec1qypqxpq9qtpqscx7peytzfwtdjmcv0mrz5rjpej8vjppfkqfqy8skqfv3l";
export const TEST_RELAY_URL = "wss://relay.example.com";
export const TEST_SETUP_RELAY_URLS = ["wss://relay.damus.io", "wss://relay.primal.net"];
export const TEST_RESOLVED_PRIVATE_KEY = "resolved-nostr-private-key";
export const TEST_HEX_PRIVATE_KEY_BYTES = new Uint8Array(
TEST_HEX_PRIVATE_KEY.match(/.{2}/g)!.map((byte) => parseInt(byte, 16)),
);
export function createConfiguredNostrCfg(overrides: Record<string, unknown> = {}): {
channels: { nostr: Record<string, unknown> };
} {
return {
channels: {
nostr: {
privateKey: TEST_HEX_PRIVATE_KEY,
...overrides,
},
},
};
}
export function buildResolvedNostrAccount(
overrides: Partial<ResolvedNostrAccount> = {},
): ResolvedNostrAccount {
return {
accountId: "default",
enabled: true,
configured: true,
privateKey: TEST_HEX_PRIVATE_KEY,
publicKey: TEST_HEX_PUBLIC_KEY,
relays: [TEST_RELAY_URL],
config: {},
...overrides,
};
}

View File

@@ -1,8 +1,7 @@
import { describe, expect, it } from "vitest";
import { TEST_HEX_PRIVATE_KEY, createConfiguredNostrCfg } from "./test-fixtures.js";
import { listNostrAccountIds, resolveDefaultNostrAccountId, resolveNostrAccount } from "./types.js";
const TEST_PRIVATE_KEY = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
describe("listNostrAccountIds", () => {
it("returns empty array when not configured", () => {
const cfg = { channels: {} };
@@ -15,31 +14,19 @@ describe("listNostrAccountIds", () => {
});
it("returns default when privateKey is configured", () => {
const cfg = {
channels: {
nostr: { privateKey: TEST_PRIVATE_KEY },
},
};
const cfg = createConfiguredNostrCfg();
expect(listNostrAccountIds(cfg)).toEqual(["default"]);
});
it("returns configured defaultAccount when privateKey is configured", () => {
const cfg = {
channels: {
nostr: { privateKey: TEST_PRIVATE_KEY, defaultAccount: "work" },
},
};
const cfg = createConfiguredNostrCfg({ defaultAccount: "work" });
expect(listNostrAccountIds(cfg)).toEqual(["work"]);
});
});
describe("resolveDefaultNostrAccountId", () => {
it("returns default when configured", () => {
const cfg = {
channels: {
nostr: { privateKey: TEST_PRIVATE_KEY },
},
};
const cfg = createConfiguredNostrCfg();
expect(resolveDefaultNostrAccountId(cfg)).toBe("default");
});
@@ -49,34 +36,25 @@ describe("resolveDefaultNostrAccountId", () => {
});
it("prefers configured defaultAccount when present", () => {
const cfg = {
channels: {
nostr: { privateKey: TEST_PRIVATE_KEY, defaultAccount: "work" },
},
};
const cfg = createConfiguredNostrCfg({ defaultAccount: "work" });
expect(resolveDefaultNostrAccountId(cfg)).toBe("work");
});
});
describe("resolveNostrAccount", () => {
it("resolves configured account", () => {
const cfg = {
channels: {
nostr: {
privateKey: TEST_PRIVATE_KEY,
name: "Test Bot",
relays: ["wss://test.relay"],
dmPolicy: "pairing" as const,
},
},
};
const cfg = createConfiguredNostrCfg({
name: "Test Bot",
relays: ["wss://test.relay"],
dmPolicy: "pairing" as const,
});
const account = resolveNostrAccount({ cfg });
expect(account.accountId).toBe("default");
expect(account.name).toBe("Test Bot");
expect(account.enabled).toBe(true);
expect(account.configured).toBe(true);
expect(account.privateKey).toBe(TEST_PRIVATE_KEY);
expect(account.privateKey).toBe(TEST_HEX_PRIVATE_KEY);
expect(account.publicKey).toMatch(/^[0-9a-f]{64}$/);
expect(account.relays).toEqual(["wss://test.relay"]);
});
@@ -95,14 +73,7 @@ describe("resolveNostrAccount", () => {
});
it("handles disabled channel", () => {
const cfg = {
channels: {
nostr: {
enabled: false,
privateKey: TEST_PRIVATE_KEY,
},
},
};
const cfg = createConfiguredNostrCfg({ enabled: false });
const account = resolveNostrAccount({ cfg });
expect(account.enabled).toBe(false);
@@ -110,25 +81,16 @@ describe("resolveNostrAccount", () => {
});
it("handles custom accountId parameter", () => {
const cfg = {
channels: {
nostr: { privateKey: TEST_PRIVATE_KEY },
},
};
const cfg = createConfiguredNostrCfg();
const account = resolveNostrAccount({ cfg, accountId: "custom" });
expect(account.accountId).toBe("custom");
});
it("handles allowFrom config", () => {
const cfg = {
channels: {
nostr: {
privateKey: TEST_PRIVATE_KEY,
allowFrom: ["npub1test", "0123456789abcdef"],
},
},
};
const cfg = createConfiguredNostrCfg({
allowFrom: ["npub1test", "0123456789abcdef"],
});
const account = resolveNostrAccount({ cfg });
expect(account.config.allowFrom).toEqual(["npub1test", "0123456789abcdef"]);
@@ -149,22 +111,17 @@ describe("resolveNostrAccount", () => {
});
it("preserves all config options", () => {
const cfg = {
channels: {
nostr: {
privateKey: TEST_PRIVATE_KEY,
name: "Bot",
enabled: true,
relays: ["wss://relay1", "wss://relay2"],
dmPolicy: "allowlist" as const,
allowFrom: ["pubkey1", "pubkey2"],
},
},
};
const cfg = createConfiguredNostrCfg({
name: "Bot",
enabled: true,
relays: ["wss://relay1", "wss://relay2"],
dmPolicy: "allowlist" as const,
allowFrom: ["pubkey1", "pubkey2"],
});
const account = resolveNostrAccount({ cfg });
expect(account.config).toEqual({
privateKey: TEST_PRIVATE_KEY,
privateKey: TEST_HEX_PRIVATE_KEY,
name: "Bot",
enabled: true,
relays: ["wss://relay1", "wss://relay2"],

View File

@@ -1,11 +1,8 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import type {
ChannelAccountSnapshot,
ChannelGatewayContext,
} from "../../../src/channels/plugins/types.js";
import type { OpenClawConfig } from "../../../src/config/config.js";
import type { PluginRuntime } from "../../../src/plugins/runtime/types.js";
import { createRuntimeEnv } from "../../../test/helpers/extensions/runtime-env.js";
import { createStartAccountContext } from "../../../test/helpers/extensions/start-account-context.js";
import type { ResolvedTelegramAccount } from "./accounts.js";
import * as auditModule from "./audit.js";
import { telegramPlugin } from "./channel.js";
@@ -58,31 +55,8 @@ function createCfg(): OpenClawConfig {
} as OpenClawConfig;
}
function createStartAccountCtx(params: {
cfg: OpenClawConfig;
accountId: string;
runtime: ReturnType<typeof createRuntimeEnv>;
}): ChannelGatewayContext<ResolvedTelegramAccount> {
const account = telegramPlugin.config.resolveAccount(
params.cfg,
params.accountId,
) as ResolvedTelegramAccount;
const snapshot: ChannelAccountSnapshot = {
accountId: params.accountId,
configured: true,
enabled: true,
running: false,
};
return {
accountId: params.accountId,
account,
cfg: params.cfg,
runtime: params.runtime,
abortSignal: new AbortController().signal,
log: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
getStatus: () => snapshot,
setStatus: vi.fn(),
};
function resolveAccount(cfg: OpenClawConfig, accountId: string): ResolvedTelegramAccount {
return telegramPlugin.config.resolveAccount(cfg, accountId) as ResolvedTelegramAccount;
}
function installGatewayRuntime(params?: { probeOk?: boolean; botUsername?: string }) {
@@ -225,12 +199,13 @@ describe("telegramPlugin duplicate token guard", () => {
const { monitorTelegramProvider, probeTelegram } = installGatewayRuntime({
probeOk: true,
});
const cfg = createCfg();
await expect(
telegramPlugin.gateway!.startAccount!(
createStartAccountCtx({
cfg: createCfg(),
accountId: "work",
createStartAccountContext({
account: resolveAccount(cfg, "work"),
cfg,
runtime: createRuntimeEnv(),
}),
),
@@ -263,9 +238,9 @@ describe("telegramPlugin duplicate token guard", () => {
};
await telegramPlugin.gateway!.startAccount!(
createStartAccountCtx({
createStartAccountContext({
account: resolveAccount(cfg, "ops"),
cfg,
accountId: "ops",
runtime: createRuntimeEnv(),
}),
);
@@ -521,9 +496,9 @@ describe("telegramPlugin duplicate token guard", () => {
monitorTelegramProviderMock.mockResolvedValue(undefined);
const cfg = createCfg();
const ctx = createStartAccountCtx({
const ctx = createStartAccountContext({
account: resolveAccount(cfg, "ops"),
cfg,
accountId: "ops",
runtime: createRuntimeEnv(),
});
ctx.account = {

View File

@@ -1,9 +1,10 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import {
expectLifecyclePatch,
expectPendingUntilAbort,
startAccountAndTrackLifecycle,
waitForStartedMocks,
} from "../../../test/helpers/extensions/start-account-lifecycle.js";
import type { ChannelAccountSnapshot } from "../runtime-api.js";
import type { ResolvedZaloAccount } from "./accounts.js";
const hoisted = vi.hoisted(() => ({
@@ -66,21 +67,13 @@ describe("zaloPlugin gateway.startAccount", () => {
});
await expectPendingUntilAbort({
waitForStarted: () =>
vi.waitFor(() => {
expect(hoisted.probeZalo).toHaveBeenCalledOnce();
expect(hoisted.monitorZaloProvider).toHaveBeenCalledOnce();
}),
waitForStarted: waitForStartedMocks(hoisted.probeZalo, hoisted.monitorZaloProvider),
isSettled,
abort,
task,
});
expect(patches).toContainEqual(
expect.objectContaining({
accountId: "default",
}),
);
expectLifecyclePatch(patches, { accountId: "default" });
expect(isSettled()).toBe(true);
expect(hoisted.monitorZaloProvider).toHaveBeenCalledWith(
expect.objectContaining({

View File

@@ -2,13 +2,16 @@ import type {
ChannelAccountSnapshot,
ChannelGatewayContext,
OpenClawConfig,
RuntimeEnv,
} from "openclaw/plugin-sdk/testing";
import { vi } from "vitest";
import { createRuntimeEnv } from "./runtime-env.js";
export function createStartAccountContext<TAccount extends { accountId: string }>(params: {
account: TAccount;
abortSignal: AbortSignal;
abortSignal?: AbortSignal;
cfg?: OpenClawConfig;
runtime?: RuntimeEnv;
statusPatchSink?: (next: ChannelAccountSnapshot) => void;
}): ChannelGatewayContext<TAccount> {
const snapshot: ChannelAccountSnapshot = {
@@ -20,9 +23,9 @@ export function createStartAccountContext<TAccount extends { accountId: string }
return {
accountId: params.account.accountId,
account: params.account,
cfg: {} as OpenClawConfig,
runtime: createRuntimeEnv(),
abortSignal: params.abortSignal,
cfg: params.cfg ?? ({} as OpenClawConfig),
runtime: params.runtime ?? createRuntimeEnv(),
abortSignal: params.abortSignal ?? new AbortController().signal,
log: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
getStatus: () => snapshot,
setStatus: (next) => {

View File

@@ -35,6 +35,23 @@ export async function abortStartedAccount(params: {
await params.task;
}
export function waitForStartedMocks(...mocks: Array<ReturnType<typeof vi.fn>>) {
return async () => {
await vi.waitFor(() => {
for (const mock of mocks) {
expect(mock).toHaveBeenCalledOnce();
}
});
};
}
export function expectLifecyclePatch(
patches: ChannelAccountSnapshot[],
expected: Partial<ChannelAccountSnapshot>,
) {
expect(patches).toContainEqual(expect.objectContaining(expected));
}
export async function expectPendingUntilAbort(params: {
waitForStarted: () => Promise<void>;
isSettled: () => boolean;