Self-hosted multi-platform broadcast newsroom for activist collectives: publishing pipeline with group approval and DST-proof scheduling, TweetDeck-style column wall with hostile-content-safe rendering, live control room with Auto Director, bridged chat governance with polls and quorum, distributed sandboxed transcode workers, encrypted content-addressed archive, monthly analytics. MIT licensed. See README.md and docs/.
225 lines
10 KiB
TypeScript
225 lines
10 KiB
TypeScript
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);
|
|
});
|
|
});
|