import { describe, expect, it } from "vitest"; import { ACCOUNTS_MAX, clampIntervalMs, clampPosition, clampTitle, DEFAULT_INTERVAL_MS, defaultIntervalMs, isKeywordLike, parseRefreshPolicyClamped, parseStreamColumnSpec, QUERY_MAX, STREAM_COLUMN_TITLE_MAX, StreamColumnsValidationError, } from "./stream-columns.js"; const rejects = (fn: () => unknown): void => { expect(fn).toThrow(StreamColumnsValidationError); }; describe("column type dispatch", () => { it("covers exactly the four persisted types", () => { expect( parseStreamColumnSpec("platform_feed", { connectionId: "c1", kinds: ["home"] }), ).toEqual({ connectionId: "c1", kinds: ["home"] }); expect( parseStreamColumnSpec("saved_search", { query: "product", platforms: ["bluesky"] }), ).toEqual({ query: "product", platforms: ["bluesky"], matchMode: "any" }); expect( parseStreamColumnSpec("keyword", { query: "acampada26", platforms: ["bluesky", "mastodon"] }), ).toEqual({ query: "acampada26", platforms: ["bluesky", "mastodon"], matchMode: "any" }); expect( parseStreamColumnSpec("account_list", { accounts: [{ provider: "mastodon", handle: "@user@mastodon.social" }], }), ).toEqual({ accounts: [{ provider: "mastodon", handle: "@user@mastodon.social" }] }); }); it("treats saved_search as keyword-shaped (shared engine, ยง1.1)", () => { const good = { query: "t", platforms: ["bluesky"] }; expect(parseStreamColumnSpec("saved_search", good)).toEqual( parseStreamColumnSpec("keyword", good), ); rejects(() => parseStreamColumnSpec("saved_search", { platforms: ["bluesky"] })); }); it("isKeywordLike groups the shared keyword engine", () => { expect(isKeywordLike("keyword")).toBe(true); expect(isKeywordLike("saved_search")).toBe(true); expect(isKeywordLike("platform_feed")).toBe(false); expect(isKeywordLike("account_list")).toBe(false); }); }); describe("platform_feed spec", () => { it("defaults kinds to [\"home\"] when omitted or empty is rejected", () => { expect(parseStreamColumnSpec("platform_feed", { connectionId: "c1" })) .toEqual({ connectionId: "c1", kinds: ["home"] }); rejects(() => parseStreamColumnSpec("platform_feed", { connectionId: "c1", kinds: [] })); }); it("accepts home+mentions in any order, deduped", () => { expect(parseStreamColumnSpec("platform_feed", { connectionId: "c1", kinds: ["mentions", "home", "mentions"] })) .toEqual({ connectionId: "c1", kinds: ["mentions", "home"] }); }); it("rejects bad kinds and connection ids", () => { rejects(() => parseStreamColumnSpec("platform_feed", { connectionId: "c1", kinds: ["home", "list"] })); rejects(() => parseStreamColumnSpec("platform_feed", { connectionId: "c1", kinds: "home" })); rejects(() => parseStreamColumnSpec("platform_feed", { connectionId: "", kinds: ["home"] })); rejects(() => parseStreamColumnSpec("platform_feed", { connectionId: 42, kinds: ["home"] })); rejects(() => parseStreamColumnSpec("platform_feed", {})); rejects(() => parseStreamColumnSpec("platform_feed", null)); rejects(() => parseStreamColumnSpec("platform_feed", "c1")); rejects(() => parseStreamColumnSpec("platform_feed", [])); }); }); describe("keyword / saved_search spec", () => { it("trims the query and rejects empty-after-trim", () => { expect(parseStreamColumnSpec("keyword", { query: " acampada26 ", platforms: ["bluesky"] }).query) .toBe("acampada26"); rejects(() => parseStreamColumnSpec("keyword", { query: " ", platforms: ["bluesky"] })); rejects(() => parseStreamColumnSpec("keyword", { query: "", platforms: ["bluesky"] })); rejects(() => parseStreamColumnSpec("keyword", { platforms: ["bluesky"] })); rejects(() => parseStreamColumnSpec("keyword", { query: 7, platforms: ["bluesky"] })); }); it("enforces the 1..200 query window and control-char ban", () => { const edge = "x".repeat(QUERY_MAX); expect(parseStreamColumnSpec("keyword", { query: ` ${edge} `, platforms: ["bluesky"] }).query) .toBe(edge); // exactly 200 after trim passes rejects(() => parseStreamColumnSpec("keyword", { query: `${edge}x`, platforms: ["bluesky"] })); rejects(() => parseStreamColumnSpec("keyword", { query: "a\u0000b", platforms: ["bluesky"] })); rejects(() => parseStreamColumnSpec("keyword", { query: "a\nb", platforms: ["bluesky"] })); rejects(() => parseStreamColumnSpec("keyword", { query: "a\u007fb", platforms: ["bluesky"] })); }); it("dedupes platforms and rejects empty/invalid lists", () => { expect(parseStreamColumnSpec("keyword", { query: "q", platforms: ["bluesky", "bluesky", "youtube"] }).platforms) .toEqual(["bluesky", "youtube"]); rejects(() => parseStreamColumnSpec("keyword", { query: "q", platforms: [] })); rejects(() => parseStreamColumnSpec("keyword", { query: "q" })); rejects(() => parseStreamColumnSpec("keyword", { query: "q", platforms: ["bluesky", ""] })); rejects(() => parseStreamColumnSpec("keyword", { query: "q", platforms: [42] })); }); it("allows only the reserved matchMode \"any\"", () => { expect(parseStreamColumnSpec("keyword", { query: "q", platforms: ["bluesky"], matchMode: "any" }).matchMode) .toBe("any"); rejects(() => parseStreamColumnSpec("keyword", { query: "q", platforms: ["bluesky"], matchMode: "all" })); rejects(() => parseStreamColumnSpec("keyword", { query: "q", platforms: ["bluesky"], matchMode: null })); }); }); describe("account_list spec", () => { it("trims handles and accepts the documented shapes", () => { expect( parseStreamColumnSpec("account_list", { accounts: [ { provider: "mastodon", handle: " @user@mastodon.social " }, { provider: "bluesky", handle: "@user.bsky.social" }, { provider: "youtube", handle: "UCxxxxxxxxxxxxxxxxxxxxxx" }, ], }).accounts, ).toEqual([ { provider: "mastodon", handle: "@user@mastodon.social" }, { provider: "bluesky", handle: "@user.bsky.social" }, { provider: "youtube", handle: "UCxxxxxxxxxxxxxxxxxxxxxx" }, ]); }); it("enforces the 1..50 window", () => { const account = { provider: "mastodon", handle: "@user" }; expect(parseStreamColumnSpec("account_list", { accounts: [account] }).accounts).toHaveLength(1); expect( parseStreamColumnSpec("account_list", { accounts: Array.from({ length: ACCOUNTS_MAX }, () => account) }).accounts, ).toHaveLength(ACCOUNTS_MAX); rejects(() => parseStreamColumnSpec("account_list", { accounts: [] })); rejects(() => parseStreamColumnSpec("account_list", { accounts: Array.from({ length: ACCOUNTS_MAX + 1 }, () => account), }), ); }); it("rejects malformed entries", () => { rejects(() => parseStreamColumnSpec("account_list", { accounts: [{ provider: "mastodon" }] })); rejects(() => parseStreamColumnSpec("account_list", { accounts: [{ handle: "@user" }] })); rejects(() => parseStreamColumnSpec("account_list", { accounts: [{ provider: "", handle: "@u" }] })); rejects(() => parseStreamColumnSpec("account_list", { accounts: [{ provider: "mastodon", handle: " " }] })); rejects(() => parseStreamColumnSpec("account_list", { accounts: [{ provider: "mastodon", handle: "a\u0000b" }] }), ); rejects(() => parseStreamColumnSpec("account_list", { accounts: ["@user"] })); rejects(() => parseStreamColumnSpec("account_list", { accounts: {} })); }); }); describe("refresh_policy", () => { it("defaults to {}", () => { expect(parseRefreshPolicyClamped()).toEqual({}); expect(parseRefreshPolicyClamped({})).toEqual({}); expect(parseRefreshPolicyClamped(undefined)).toEqual({}); expect(parseRefreshPolicyClamped(null)).toEqual({}); }); it("keeps valid interval/paused values", () => { expect(parseRefreshPolicyClamped({ intervalMs: 300_000, paused: true })) .toEqual({ intervalMs: 300_000, paused: true }); expect(parseRefreshPolicyClamped({ paused: false })).toEqual({ paused: false }); }); it("clamps intervalMs into 60_000..3_600_000", () => { expect(parseRefreshPolicyClamped({ intervalMs: 0 }).intervalMs).toBe(60_000); expect(parseRefreshPolicyClamped({ intervalMs: -5_000 }).intervalMs).toBe(60_000); expect(parseRefreshPolicyClamped({ intervalMs: 59_999 }).intervalMs).toBe(60_000); expect(parseRefreshPolicyClamped({ intervalMs: 10 }).intervalMs).toBe(60_000); expect(parseRefreshPolicyClamped({ intervalMs: 3_600_000 }).intervalMs).toBe(3_600_000); expect(parseRefreshPolicyClamped({ intervalMs: 10_000_000 }).intervalMs).toBe(3_600_000); expect(parseRefreshPolicyClamped({ intervalMs: 90_500 }).intervalMs).toBe(90_500); expect(parseRefreshPolicyClamped({ intervalMs: 60_000.4 }).intervalMs).toBe(60_000); expect(parseRefreshPolicyClamped({ intervalMs: Number.NaN }).intervalMs).toBe(60_000); expect(parseRefreshPolicyClamped({ intervalMs: Number.POSITIVE_INFINITY }).intervalMs).toBe(3_600_000); expect(parseRefreshPolicyClamped({ intervalMs: Number.NEGATIVE_INFINITY }).intervalMs).toBe(60_000); }); it("rejects malformed policies", () => { rejects(() => parseRefreshPolicyClamped({ intervalMs: "60000" })); rejects(() => parseRefreshPolicyClamped({ paused: "yes" })); rejects(() => parseRefreshPolicyClamped([1, 2])); rejects(() => parseRefreshPolicyClamped("paused")); }); it("exposes the per-type default cadences", () => { expect(DEFAULT_INTERVAL_MS).toEqual({ platform_feed: 60_000, keyword: 120_000, account_list: 180_000, saved_search: 300_000, }); for (const type of ["platform_feed", "keyword", "account_list", "saved_search"] as const) { expect(defaultIntervalMs(type)).toBe(DEFAULT_INTERVAL_MS[type]); expect(defaultIntervalMs(type)).toBeGreaterThanOrEqual(clampIntervalMs(60_000)); } }); }); describe("shared clamps", () => { it("clampTitle trims and caps at 80 chars (mirrors the DB CHECK)", () => { expect(clampTitle(" Movement voices ")).toBe("Movement voices"); expect(clampTitle("x".repeat(STREAM_COLUMN_TITLE_MAX))).toHaveLength(STREAM_COLUMN_TITLE_MAX); expect(clampTitle(` ${"y".repeat(STREAM_COLUMN_TITLE_MAX + 20)} `)).toHaveLength(STREAM_COLUMN_TITLE_MAX); expect(clampTitle("").length).toBe(0); }); it("clampPosition yields finite non-negative integers", () => { expect(clampPosition(0)).toBe(0); expect(clampPosition(7.9)).toBe(8); expect(clampPosition(-3)).toBe(0); expect(clampPosition(Number.NaN)).toBe(0); expect(clampPosition(Number.POSITIVE_INFINITY)).toBe(2_147_483_647); expect(clampPosition(2_147_483_648)).toBe(2_147_483_647); }); });