import { describe, expect, it } from "vitest"; import type { MsgContext } from "../auto-reply/templating.js"; import { typedCases } from "../test-utils/typed-cases.js"; import { type ChannelMatchSource, buildChannelKeyCandidates, normalizeChannelSlug, resolveChannelEntryMatch, resolveChannelEntryMatchWithFallback, resolveNestedAllowlistDecision, applyChannelMatchMeta, resolveChannelMatchConfig, } from "./channel-config.js"; import { validateSenderIdentity } from "./sender-identity.js"; describe("buildChannelKeyCandidates", () => { it("dedupes and trims keys", () => { expect(buildChannelKeyCandidates(" a ", "a", "", "b", "b")).toEqual(["a", "b"]); }); }); describe("normalizeChannelSlug", () => { it("normalizes names into slugs", () => { expect(normalizeChannelSlug("My Team")).toBe("my-team"); expect(normalizeChannelSlug("#General Chat")).toBe("general-chat"); expect(normalizeChannelSlug(" Dev__Chat ")).toBe("dev-chat"); }); }); describe("resolveChannelEntryMatch", () => { it("returns matched entry and wildcard metadata", () => { const entries = { a: { allow: true }, "*": { allow: false } }; const match = resolveChannelEntryMatch({ entries, keys: ["missing", "a"], wildcardKey: "*", }); expect(match.entry).toBe(entries.a); expect(match.key).toBe("a"); expect(match.wildcardEntry).toBe(entries["*"]); expect(match.wildcardKey).toBe("*"); }); }); describe("resolveChannelEntryMatchWithFallback", () => { const fallbackCases = typedCases<{ name: string; entries: Record; args: { keys: string[]; parentKeys?: string[]; wildcardKey?: string; }; expectedEntryKey: string; expectedSource: ChannelMatchSource; expectedMatchKey: string; }>([ { name: "prefers direct matches over parent and wildcard", entries: { a: { allow: true }, parent: { allow: false }, "*": { allow: false } }, args: { keys: ["a"], parentKeys: ["parent"], wildcardKey: "*" }, expectedEntryKey: "a", expectedSource: "direct", expectedMatchKey: "a", }, { name: "falls back to parent when direct misses", entries: { parent: { allow: false }, "*": { allow: true } }, args: { keys: ["missing"], parentKeys: ["parent"], wildcardKey: "*" }, expectedEntryKey: "parent", expectedSource: "parent", expectedMatchKey: "parent", }, { name: "falls back to wildcard when no direct or parent match", entries: { "*": { allow: true } }, args: { keys: ["missing"], parentKeys: ["still-missing"], wildcardKey: "*" }, expectedEntryKey: "*", expectedSource: "wildcard", expectedMatchKey: "*", }, ]); for (const testCase of fallbackCases) { it(testCase.name, () => { const match = resolveChannelEntryMatchWithFallback({ entries: testCase.entries, ...testCase.args, }); expect(match.entry).toBe(testCase.entries[testCase.expectedEntryKey]); expect(match.matchSource).toBe(testCase.expectedSource); expect(match.matchKey).toBe(testCase.expectedMatchKey); }); } it("matches normalized keys when normalizeKey is provided", () => { const entries = { "My Team": { allow: true } }; const match = resolveChannelEntryMatchWithFallback({ entries, keys: ["my-team"], normalizeKey: normalizeChannelSlug, }); expect(match.entry).toBe(entries["My Team"]); expect(match.matchSource).toBe("direct"); expect(match.matchKey).toBe("My Team"); }); }); describe("applyChannelMatchMeta", () => { it("copies match metadata onto resolved configs", () => { const base: { matchKey?: string; matchSource?: ChannelMatchSource } = {}; const resolved = applyChannelMatchMeta(base, { matchKey: "general", matchSource: "direct" }); expect(resolved.matchKey).toBe("general"); expect(resolved.matchSource).toBe("direct"); }); }); describe("resolveChannelMatchConfig", () => { it("returns null when no entry is matched", () => { const resolved = resolveChannelMatchConfig({ matchKey: "x" }, () => { const out: { matchKey?: string; matchSource?: ChannelMatchSource } = {}; return out; }); expect(resolved).toBeNull(); }); it("resolves entry and applies match metadata", () => { const resolved = resolveChannelMatchConfig( { entry: { allow: true }, matchKey: "*", matchSource: "wildcard" }, () => { const out: { matchKey?: string; matchSource?: ChannelMatchSource } = {}; return out; }, ); expect(resolved?.matchKey).toBe("*"); expect(resolved?.matchSource).toBe("wildcard"); }); }); describe("validateSenderIdentity", () => { it("allows direct messages without sender fields", () => { const ctx: MsgContext = { ChatType: "direct" }; expect(validateSenderIdentity(ctx)).toEqual([]); }); it("requires some sender identity for non-direct chats", () => { const ctx: MsgContext = { ChatType: "group" }; expect(validateSenderIdentity(ctx)).toContain( "missing sender identity (SenderId/SenderName/SenderUsername/SenderE164)", ); }); it("validates SenderE164 and SenderUsername shape", () => { const ctx: MsgContext = { ChatType: "group", SenderE164: "123", SenderUsername: "@ada lovelace", }; expect(validateSenderIdentity(ctx)).toEqual([ "invalid SenderE164: 123", 'SenderUsername should not include "@": @ada lovelace', "SenderUsername should not include whitespace: @ada lovelace", ]); }); }); describe("resolveNestedAllowlistDecision", () => { const cases = [ { name: "allows when outer allowlist is disabled", value: { outerConfigured: false, outerMatched: false, innerConfigured: false, innerMatched: false, }, expected: true, }, { name: "blocks when outer allowlist is configured but missing match", value: { outerConfigured: true, outerMatched: false, innerConfigured: false, innerMatched: false, }, expected: false, }, { name: "requires inner match when inner allowlist is configured", value: { outerConfigured: true, outerMatched: true, innerConfigured: true, innerMatched: false, }, expected: false, }, { name: "allows when both outer and inner allowlists match", value: { outerConfigured: true, outerMatched: true, innerConfigured: true, innerMatched: true, }, expected: true, }, ] as const; for (const testCase of cases) { it(testCase.name, () => { expect(resolveNestedAllowlistDecision(testCase.value)).toBe(testCase.expected); }); } });