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/.
366 lines
23 KiB
TypeScript
366 lines
23 KiB
TypeScript
import { randomUUID } from "node:crypto";
|
|
import { describe, expect, it } from "vitest";
|
|
import { ConnectorRegistry } from "@tactical-media-broadcast-hub/connector-sdk";
|
|
import type { ConnectorConnection, FeedItem } from "@tactical-media-broadcast-hub/core";
|
|
import { createMockSocialConnector, createMockSocialState } from "../../../../packages/connector-mock-social/src/index.js";
|
|
import { DEFAULT_MEDIA_PROXY, parseProviderHtml, type CustomEmoji, type LinkPreview, type MediaProxyPolicy, type RichTextDocument } from "../../../../packages/safe-content/src/index.js";
|
|
import { astToPlainText, feedAuthorRef, feedItemMedia, normalizeFeedItem, type MediaRef, type NormalizedFeedItem } from "./feed-normalize.js";
|
|
import { FeedsService, feedCacheKey } from "./feeds.service.js";
|
|
import type { FeedDependencies } from "./feeds.dependencies.js";
|
|
import { MemoryFeedRedis } from "./testing.js";
|
|
|
|
/** Per-connector fixtures (design §3.2 provider table) + hostile-HTML cases. */
|
|
interface ProviderFixture {
|
|
provider: string;
|
|
contentKind?: "text" | "html";
|
|
instanceUrl?: string;
|
|
emojis?: readonly CustomEmoji[];
|
|
item: FeedItem;
|
|
url?: unknown;
|
|
expiresAt?: unknown;
|
|
linkPreview?: unknown;
|
|
}
|
|
|
|
const PROXY: MediaProxyPolicy = DEFAULT_MEDIA_PROXY;
|
|
const connection = (fields: Record<string, string> = {}): ConnectorConnection => ({
|
|
id: "conn-1", workspaceId: "ws-1", provider: "mastodon", channelId: "conn-1",
|
|
credentialId: { kind: "credential-ref", id: "cred-1" }, fields, createdAt: new Date(),
|
|
});
|
|
|
|
const mastodonStatus: FeedItem = {
|
|
id: "1001", provider: "mastodon", remoteId: "1001", authorHandle: "alice@foreso.social",
|
|
// The exact HTML shape Mastodon emits: p/br/a, mention + hashtag + invisible
|
|
// ellipsis link spans, HTML-escaped ampersands, raw emoji <img> tags.
|
|
text: '<p>A call for <span class="h-card"><a href="https://foreso.social/@bob" class="u-url mention">@<span>bob</span></a></span> & friends</p><p>#<a href="https://foreso.social/tags/acampada26" rel="tag">acampada26</a> kicks off <a href="https://example.org/manifesto" rel="nofollow noopener noreferrer noopener" target="_blank"><span class="invisible">https://</span><span class="ellipsis">example.org/manifes</span><span class="invisible">to</span></a> <img src="https://foreso.social/emoji/ok.png" alt=":ok:" draggable="false"><br>line two <b>bolded</b></p>',
|
|
mediaUrls: ["https://cdn.foreso.social/media/1234.png", "https://cdn.foreso.social/media/clip.mp4"],
|
|
createdAt: new Date("2026-10-06T12:00:00.000Z"),
|
|
metrics: { reblogs: 2, replies: 1, favourites: 5, broken: Number.NaN, infinite: Number.POSITIVE_INFINITY },
|
|
};
|
|
const xssStatus: FeedItem = {
|
|
id: "2002", provider: "mastodon", remoteId: "2002", authorHandle: "mallory@evil.example",
|
|
text: '<p>hi <script>alert("pwned")</script><img src=x onerror="alert(1)"><iframe srcdoc="<script>alert(2)</script>"></iframe><style>body{}</style><svg><circle/></svg><a href="javascript:alert(3)">click</a><a href="https://ok.example/pass">pass</a><a href="java\tscript:alert(4)">tabbed</a></p><p>after</p>',
|
|
mediaUrls: ["javascript:alert(5)", "data:text/html,evil", "//protocol-relative.example/x.png", "https://cdn.example/ok.png"],
|
|
createdAt: new Date("2026-10-06T12:01:00.000Z"),
|
|
metrics: {},
|
|
};
|
|
|
|
const fixturePlainText: ProviderFixture = {
|
|
// Plain-text provider source (Bluesky/Telegram-style): auto-linked, no HTML.
|
|
provider: "bluesky",
|
|
item: {
|
|
id: "1", provider: "bluesky", remoteId: "at://did:plc:abc/app.bsky.feed.post/1",
|
|
authorHandle: "acampada.bsky.social", mediaUrls: [], createdAt: new Date("2026-10-06T10:00:00.000Z"),
|
|
metrics: { likes: 3 },
|
|
text: "Line one\n\n hub https://github.com/eche/acampada26/blob/main/core/search.ts #acampada26 @alice@foreso.social",
|
|
},
|
|
url: "https://bsky.app/profile/acampada.bsky.social/post/1",
|
|
};
|
|
const fixtureMastodonHtml: ProviderFixture = {
|
|
// Mastodon status HTML: allowlist parser, mention + ellipsis link + emoji.
|
|
provider: "mastodon",
|
|
contentKind: "html",
|
|
emojis: [{ shortcode: "ok", url: "https://foreso.social/emoji/ok.png" }],
|
|
item: mastodonStatus,
|
|
url: "https://foreso.social/@alice/1001",
|
|
};
|
|
const fixtureHostileHtml: ProviderFixture = {
|
|
// Hostile HTML from a compromised/bogus federated peer.
|
|
provider: "mastodon",
|
|
contentKind: "html",
|
|
instanceUrl: "https://EVIL.example:443/",
|
|
item: xssStatus,
|
|
url: "javascript:alert(6)",
|
|
linkPreview: { url: "javascript:alert(7)", title: "javascript:alert(8)", image: "javascript:alert(9)" },
|
|
};
|
|
const fixtureMastodonLinkPreview: ProviderFixture = {
|
|
// Mastodon HTML + card (link-preview) payload as one source row.
|
|
provider: "mastodon",
|
|
contentKind: "html",
|
|
item: { ...mastodonStatus, id: "1002", remoteId: "1002", text: '<p>with a <span class="h-card"><a href="https://foreso.social/@bob" class="u-url mention">@<span>bob</span></a></span> card</p>', mediaUrls: [] },
|
|
url: "https://foreso.social/@alice/1002",
|
|
linkPreview: {
|
|
url: "https://example.org/manifesto", title: "Manifesto <scripts> & plans",
|
|
description: "A plan", siteName: "example dot org", image: "https://cdn.example.org/cover.png",
|
|
},
|
|
};
|
|
const fixtures: Record<"plainText" | "mastodonHtml" | "hostileHtml" | "mastodonLinkPreview", ProviderFixture> = {
|
|
plainText: fixturePlainText,
|
|
mastodonHtml: fixtureMastodonHtml,
|
|
hostileHtml: fixtureHostileHtml,
|
|
mastodonLinkPreview: fixtureMastodonLinkPreview,
|
|
};
|
|
|
|
/** No provider CDN URL outside the media-proxy query string, ever. */
|
|
function assertProxyOnly(media: MediaRef[]): void {
|
|
const serialized = JSON.stringify(media);
|
|
let index = serialized.indexOf("https://");
|
|
while (index !== -1) {
|
|
expect(serialized.slice(0, index)).toContain(`${PROXY.proxyPath}?url=`);
|
|
index = serialized.indexOf("https://", index + 1);
|
|
}
|
|
for (const ref of media) expect(ref.proxyUrl.startsWith(`${PROXY.proxyPath}?url=`)).toBe(true);
|
|
}
|
|
|
|
/** Raw-remnant scan applied to every fixture's AST, plain text and media. */
|
|
function assertZeroRawRemnants(ast: RichTextDocument, plainText: string, media: MediaRef[]): void {
|
|
const astJson = JSON.stringify(ast);
|
|
const hostile: RegExp[] = [
|
|
/<\s*(script|iframe|style|img|svg|math|template|form|object|embed|applet|frame)\b/i,
|
|
/<\s*\/\s*/, /<!--/, /&#/, /&|<|>|"|'| /i,
|
|
/javascript:/i, /vbscript:/i, /data:/i, /file:/i, /\/\/evil/, /\/\/protocol-relative/,
|
|
/on(?:error|load|click|mouseover)\b|on\w+\s*=/i, /srcdoc|src\s*=|href\s*=/i, /alert\s*\(/i,
|
|
];
|
|
for (const haystack of [astJson, plainText, JSON.stringify(media)]) {
|
|
for (const pattern of hostile) expect(haystack).not.toMatch(pattern);
|
|
}
|
|
expect(astJson).not.toContain("<p>");
|
|
expect(astJson).not.toContain("<b>");
|
|
assertProxyOnly(media);
|
|
}
|
|
|
|
function normalizeFixture(fixture: ProviderFixture) {
|
|
return normalizeFeedItem(fixture.item, { ...connection(fixture.instanceUrl ? { instanceUrl: fixture.instanceUrl } : {}), provider: fixture.provider }, {
|
|
contentKind: fixture.contentKind,
|
|
emojis: fixture.emojis,
|
|
url: fixture.url,
|
|
expiresAt: fixture.expiresAt,
|
|
linkPreview: fixture.linkPreview,
|
|
});
|
|
}
|
|
|
|
const flat = (ast: RichTextDocument): string[] =>
|
|
ast.children.map((paragraph) => paragraph.children.map((node) => node.type).join(","));
|
|
|
|
describe("feed normalization v2 (columns P0c)", () => {
|
|
it("keeps legacy /feeds fields byte-identical while adding the v2 fields additively", () => {
|
|
const normalized = normalizeFeedItem(mastodonStatus, connection(), { url: "https://foreso.social/@alice/1001" });
|
|
expect(normalized).not.toBeNull();
|
|
expect(normalized).toMatchObject({
|
|
id: "1001", remoteId: "1001", provider: "mastodon", connectionId: "conn-1",
|
|
authorHandle: "alice@foreso.social", text: mastodonStatus.text,
|
|
mediaUrls: mastodonStatus.mediaUrls,
|
|
metrics: { reblogs: 2, replies: 1, favourites: 5 }, // NaN/Infinity dropped
|
|
createdAt: new Date("2026-10-06T12:00:00.000Z"),
|
|
});
|
|
expect(Object.keys(normalized?.metrics ?? {})).toEqual(["reblogs", "replies", "favourites"]);
|
|
expect(normalized?.authorRef).toBe("mastodon:alice@foreso.social");
|
|
expect(normalized?.url).toBe("https://foreso.social/@alice/1001");
|
|
expect(normalized?.content?.type).toBe("doc");
|
|
expect(typeof normalized?.plainText).toBe("string");
|
|
expect(Array.isArray(normalized?.media)).toBe(true);
|
|
expect(normalized?.expiresAt).toBeNull();
|
|
});
|
|
|
|
it("normalizes the plain-text fixture with auto-linked http(s) URLs only", () => {
|
|
const normalized = normalizeFixture(fixturePlainText);
|
|
expect(normalized).not.toBeNull();
|
|
assertZeroRawRemnants(normalized?.content as RichTextDocument, normalized?.plainText as string, normalized?.media as MediaRef[]);
|
|
expect(flat(normalized?.content as RichTextDocument)).toEqual([
|
|
"text",
|
|
"text,link,text,hashtag,text,mention",
|
|
]);
|
|
const link = normalized?.content?.children[1]?.children.find((node) => node.type === "link");
|
|
expect(link?.type === "link" && link.href).toBe("https://github.com/eche/acampada26/blob/main/core/search.ts");
|
|
expect(link?.type === "link" && link.host).toBe("github.com");
|
|
expect(normalized?.plainText).toBe("Line one\n\n hub github.com/eche/acampada26/blob/main/core/search.ts #acampada26 @alice@foreso.social");
|
|
expect(normalized?.authorRef).toBe("bluesky:acampada.bsky.social");
|
|
expect(normalized?.url).toBe("https://bsky.app/profile/acampada.bsky.social/post/1");
|
|
expect(normalized?.media).toEqual([]);
|
|
expect(normalized?.expiresAt).toBeNull();
|
|
});
|
|
|
|
it("normalizes the Mastodon HTML fixture (mention, ellipsis link, emoji, breaks) with zero raw remnants", () => {
|
|
const normalized = normalizeFixture(fixtureMastodonHtml);
|
|
expect(normalized).not.toBeNull();
|
|
const ast = normalized?.content as RichTextDocument;
|
|
assertZeroRawRemnants(ast, normalized?.plainText as string, normalized?.media as MediaRef[]);
|
|
expect(ast.truncated).toBe(false);
|
|
// Invisible spans drop their URL scheme, the ellipsis span becomes "…",
|
|
// the mention qualifies its handle from the href host, the emoji <img>
|
|
// alt becomes a proxied emoji node, <b> unwraps to plain text.
|
|
expect(normalized?.plainText).toBe("A call for @bob@foreso.social & friends\n\n#acampada26 kicks off example.org/manifes… :ok:\nline two bolded");
|
|
expect(flat(ast)).toEqual([
|
|
"text,mention,text",
|
|
"text,link,text,link,text,emoji,linebreak,text",
|
|
]);
|
|
const mention = ast.children[0]?.children[1];
|
|
expect(mention).toEqual({ type: "mention", handle: "bob@foreso.social", href: "https://foreso.social/@bob" });
|
|
const link = ast.children[1]?.children.find((node) => node.type === "link" && node.href === "https://example.org/manifesto");
|
|
expect(link?.type === "link" && link.host).toBe("example.org");
|
|
expect(link?.type === "link" && link.children.map((child) => child.type === "text" ? child.text : null).join("")).toBe("example.org/manifes…");
|
|
const emoji = ast.children[1]?.children.find((node) => node.type === "emoji");
|
|
expect(emoji?.type === "emoji" && emoji.src).toBe(`${PROXY.proxyPath}?url=${encodeURIComponent("https://foreso.social/emoji/ok.png")}`);
|
|
// Attachments become proxy media refs; kind is sniffed from the extension.
|
|
expect(normalized?.media).toEqual([
|
|
{ kind: "image", proxyUrl: `${PROXY.proxyPath}?url=${encodeURIComponent("https://cdn.foreso.social/media/1234.png")}` },
|
|
{ kind: "video", proxyUrl: `${PROXY.proxyPath}?url=${encodeURIComponent("https://cdn.foreso.social/media/clip.mp4")}` },
|
|
]);
|
|
assertProxyOnly(normalized?.media as MediaRef[]);
|
|
});
|
|
|
|
it("normalizes the hostile-HTML fixture: scripts, iframes, handlers and unsafe schemes leave no remnants", () => {
|
|
const normalized = normalizeFixture(fixtureHostileHtml);
|
|
expect(normalized).not.toBeNull();
|
|
const ast = normalized?.content as RichTextDocument;
|
|
assertZeroRawRemnants(ast, normalized?.plainText as string, normalized?.media as MediaRef[]);
|
|
// Script/style/iframe/svg contents vanish entirely; img alt is empty; the
|
|
// javascript: link degrades to its inert label; the tab-split "java\tscript:"
|
|
// scheme is refused; only the one safe link survives as a real link node.
|
|
expect(flat(ast)).toEqual(["text,link,text", "text"]);
|
|
const onlyLink = ast.children[0]?.children[1];
|
|
expect(onlyLink).toEqual({
|
|
type: "link", href: "https://ok.example/pass", host: "ok.example",
|
|
children: [{ type: "text", text: "pass" }],
|
|
});
|
|
expect(normalized?.plainText).toBe("hi clickpasstabbed\n\nafter");
|
|
expect(normalized?.plainText).not.toMatch(/alert|pwned|onerror|javascript|srcdoc/i);
|
|
// Federated host appends to the author ref via instanceUrl (punycoded, port stripped).
|
|
expect(normalized?.authorRef).toBe("mastodon:mallory@evil.example");
|
|
// Permalink with an unsafe scheme is refused, not carried.
|
|
expect(normalized?.url).toBeNull();
|
|
// Hostile media URLs are dropped wholesale; only the safe https URL is proxied.
|
|
expect(normalized?.mediaUrls).toHaveLength(4); // legacy field stays as the source delivered it
|
|
expect(normalized?.media).toEqual([{ kind: "image", proxyUrl: `${PROXY.proxyPath}?url=${encodeURIComponent("https://cdn.example/ok.png")}` }]);
|
|
assertProxyOnly(normalized?.media as MediaRef[]);
|
|
// Hostile card: unsafe URL/title/image all rejected → no preview survives.
|
|
expect(normalized?.linkPreview).toBeUndefined();
|
|
});
|
|
|
|
it("sanitizes the Mastodon link-preview card payload (text fields, proxied image, safe href)", () => {
|
|
const normalized = normalizeFixture(fixtureMastodonLinkPreview);
|
|
expect(normalized).not.toBeNull();
|
|
const preview = normalized?.linkPreview as LinkPreview;
|
|
expect(preview).not.toBeNull();
|
|
expect(preview).toEqual({
|
|
type: "linkPreview", href: "https://example.org/manifesto", host: "example.org",
|
|
title: "Manifesto <scripts> & plans", description: "A plan", siteName: "example dot org",
|
|
image: `${PROXY.proxyPath}?url=${encodeURIComponent("https://cdn.example.org/cover.png")}`,
|
|
});
|
|
// OpenGraph fields are text: the entity-decoded title carries no live markup.
|
|
expect(preview.title).toContain("<scripts>");
|
|
expect(preview.title).not.toMatch(/<|&/);
|
|
assertZeroRawRemnants(normalized?.content as RichTextDocument, normalized?.plainText as string, normalized?.media as MediaRef[]);
|
|
});
|
|
|
|
it("round-trips every fixture AST through JSON (storage shape) with a stable plainText", () => {
|
|
for (const [name, fixture] of Object.entries(fixtures)) {
|
|
const normalized = normalizeFixture(fixture);
|
|
expect(normalized, name).not.toBeNull();
|
|
const ast = normalized?.content as RichTextDocument;
|
|
assertZeroRawRemnants(ast, normalized?.plainText as string, normalized?.media as MediaRef[]);
|
|
const roundTripped = JSON.parse(JSON.stringify(ast)) as RichTextDocument;
|
|
expect(roundTripped, name).toEqual(ast);
|
|
expect(astToPlainText(roundTripped), name).toBe(normalized?.plainText);
|
|
}
|
|
});
|
|
|
|
it("matches the reference parseProviderHtml derivation for the Mastodon fixture", () => {
|
|
const fixture = fixtureMastodonHtml;
|
|
const reference = parseProviderHtml(fixture.item.text, { emojis: fixture.emojis });
|
|
const normalized = normalizeFixture(fixture);
|
|
expect(normalized?.content).toEqual(reference);
|
|
expect(astToPlainText(reference)).toBe(normalized?.plainText);
|
|
});
|
|
|
|
it("builds canonical author refs: handle host wins, instanceUrl fills federated hosts, IDN punycodes", () => {
|
|
expect(feedAuthorRef("mastodon", "alice@foreso.social")).toBe("mastodon:alice@foreso.social");
|
|
expect(feedAuthorRef("mastodon", "alice", "https://fedoraproject.org/@alice")).toBe("mastodon:alice@fedoraproject.org");
|
|
expect(feedAuthorRef("mastodon", "alice", "https://bücher.example:8443/path")).toBe("mastodon:alice@xn--bcher-kva.example");
|
|
expect(feedAuthorRef("mastodon", "alice", "gopher://odd.example")).toBe("mastodon:alice");
|
|
expect(feedAuthorRef("mastodon", "@bob@", "https://ok.example")).toBeNull();
|
|
expect(feedAuthorRef("mastodon", "has space")).toBeNull();
|
|
expect(feedAuthorRef("mastodon", "")).toBeNull();
|
|
});
|
|
|
|
it("expires items per ToS: explicit Date, date-like string, and unparseable → null", () => {
|
|
const base = { ...mastodonStatus, mediaUrls: [] };
|
|
const at = normalizeFeedItem(base, connection(), { expiresAt: new Date("2026-11-05T00:00:00.000Z") });
|
|
expect(at?.expiresAt).toEqual(new Date("2026-11-05T00:00:00.000Z"));
|
|
const fromString = normalizeFeedItem(base, connection(), { expiresAt: "2026-11-05T00:00:00.000Z" });
|
|
expect(fromString?.expiresAt).toEqual(new Date("2026-11-05T00:00:00.000Z"));
|
|
expect(normalizeFeedItem(base, connection(), { expiresAt: "not-a-date" })?.expiresAt).toBeNull();
|
|
expect(normalizeFeedItem(base, connection())?.expiresAt).toBeNull();
|
|
});
|
|
|
|
it("drops items failing the legacy validation", () => {
|
|
expect(normalizeFeedItem({ ...mastodonStatus, remoteId: 42 as unknown as string }, connection())).toBeNull();
|
|
expect(normalizeFeedItem({ ...mastodonStatus, createdAt: new Date(Number.NaN) }, connection())).toBeNull();
|
|
expect(normalizeFeedItem({ ...mastodonStatus, authorHandle: 7 as unknown as string }, connection())).toBeNull();
|
|
expect(normalizeFeedItem(mastodonStatus, connection())).not.toBeNull();
|
|
});
|
|
|
|
it("proxies only safe media: scheme tricks, protocol-relative and oversized URLs are dropped", () => {
|
|
const media = feedItemMedia([
|
|
"https://cdn.example/a.png", "javascript:alert(1)", "data:text/html,x", "//rel.example/b.png",
|
|
"https://user:pass@cred.example/c.jpg", "not a url", "https://cdn.example/d.webm?v=2", 42,
|
|
]);
|
|
// Credential-bearing URLs are refused by the same safe-URL gate.
|
|
expect(media.map((ref) => ref.kind)).toEqual(["image", "video"]); // extension sniff
|
|
expect(media[1]?.proxyUrl).toBe(`${PROXY.proxyPath}?url=${encodeURIComponent("https://cdn.example/d.webm?v=2")}`);
|
|
assertProxyOnly(media);
|
|
expect(feedItemMedia("not-an-array")).toEqual([]);
|
|
});
|
|
|
|
it("serves legacy cache payloads (no AST) and v2 payloads through the unchanged /feeds shape", async () => {
|
|
const state = createMockSocialState();
|
|
state.feed = [];
|
|
const mock = createMockSocialConnector(state);
|
|
const registry = new ConnectorRegistry(); registry.register(mock);
|
|
const redis = new MemoryFeedRedis();
|
|
const connectionId = randomUUID();
|
|
const deps: FeedDependencies = {
|
|
redis, registry, workspaces: async () => ["ws-1"],
|
|
connections: async () => [{ id: connectionId, provider: "mock-social", channelId: connectionId, fields: {}, health: "healthy", credentialRotatedAt: null, recentPublishes: [], createdAt: new Date().toISOString() }],
|
|
connection: async (_ws, id) => ({ id, workspaceId: "ws-1", provider: "mock-social", channelId: id, credentialId: { kind: "credential-ref", id }, fields: {}, createdAt: new Date() }),
|
|
visible: async () => [connectionId], audit: async () => undefined, requestsPerSecond: () => 0.5, random: () => 0.5,
|
|
};
|
|
const service = new FeedsService(deps);
|
|
const session = { workspaceId: "ws-1", userId: randomUUID(), role: "Owner", authzEpoch: 1, issuedAt: 0, expiresAt: 9_999_999_999 };
|
|
const key = feedCacheKey("ws-1", connectionId);
|
|
const legacy = { id: "legacy", remoteId: "legacy", provider: "mock-social", connectionId, authorHandle: "old@social", text: "legacy item", mediaUrls: ["https://cdn.old.example/a.png"], createdAt: new Date("2026-10-01T00:00:00.000Z"), metrics: { likes: 1 } };
|
|
await redis.set(key, JSON.stringify({ items: [legacy], fetchedAt: new Date().toISOString(), handle: "old@social", displayName: "Old" }), { pxMs: 99_000 });
|
|
const served = await service.read(session);
|
|
expect(served.items).toHaveLength(1);
|
|
expect(served.items[0]).toMatchObject({ remoteId: "legacy", text: "legacy item", metrics: { likes: 1 } });
|
|
expect(served.items[0]?.createdAt).toBeInstanceOf(Date);
|
|
expect("content" in (served.items[0] as object)).toBe(false); // additive: absent, not null
|
|
expect("expiresAt" in (served.items[0] as object)).toBe(false);
|
|
// A v2 payload keeps the legacy fields and adds the new ones through the same response.
|
|
const normalized = normalizeFeedItem({ ...legacy, id: "v2", remoteId: "v2" }, { ...connection(), provider: "mock-social" }, { url: "https://old.example/legacy/2", expiresAt: new Date("2026-11-01T00:00:00.000Z") });
|
|
await redis.set(key, JSON.stringify({ items: [normalized], fetchedAt: new Date().toISOString(), handle: "old@social", displayName: "Old" }), { pxMs: 99_000 });
|
|
const v2Served = await service.read(session);
|
|
expect(v2Served.items[0]).toMatchObject({ remoteId: "v2", text: "legacy item", authorRef: "mock-social:old@social", url: "https://old.example/legacy/2" });
|
|
expect((v2Served.items[0] as { content?: { type?: string } }).content?.type).toBe("doc");
|
|
expect(v2Served.items[0]?.createdAt).toBeInstanceOf(Date);
|
|
expect((v2Served.items[0] as unknown as { expiresAt?: Date }).expiresAt).toEqual(new Date("2026-11-01T00:00:00.000Z"));
|
|
});
|
|
|
|
it("still polls and serves end-to-end: connector feed → v2 cache → /feeds response", async () => {
|
|
const state = createMockSocialState();
|
|
state.feed = [{
|
|
id: "e2e", remoteId: "e2e", provider: "mock-social", authorHandle: "carol@social",
|
|
text: "End to end #acampada", mediaUrls: ["https://cdn.social/e2e.png"],
|
|
createdAt: new Date("2026-10-06T09:00:00.000Z"), metrics: { likes: 2 },
|
|
}];
|
|
const mock = createMockSocialConnector(state);
|
|
const registry = new ConnectorRegistry(); registry.register(mock);
|
|
const deps: FeedDependencies = {
|
|
redis: new MemoryFeedRedis(), registry, workspaces: async () => ["ws-1"],
|
|
connections: async () => [{ id: "conn-e2e", provider: "mock-social", channelId: "conn-e2e", fields: {}, health: "healthy", credentialRotatedAt: null, recentPublishes: [], createdAt: new Date().toISOString() }],
|
|
connection: async (_ws, id) => ({ id, workspaceId: "ws-1", provider: "mock-social", channelId: id, credentialId: { kind: "credential-ref", id }, fields: {}, createdAt: new Date() }),
|
|
visible: async () => ["conn-e2e"], audit: async () => undefined, requestsPerSecond: () => 0.5, random: () => 0.5,
|
|
};
|
|
const service = new FeedsService(deps);
|
|
expect(await service.pollWorkspace("ws-1")).toEqual({ polled: 1, skipped: 0, failed: 0 });
|
|
const response = await service.read({ workspaceId: "ws-1", userId: randomUUID(), role: "Owner", authzEpoch: 1, issuedAt: 0, expiresAt: 9_999_999_999 });
|
|
expect(response.items).toHaveLength(1);
|
|
const item = response.items[0] as NormalizedFeedItem | undefined;
|
|
expect(item).toBeDefined();
|
|
expect(item).toMatchObject({ remoteId: "e2e", text: "End to end #acampada", authorRef: "mock-social:carol@social", metrics: { likes: 2 } });
|
|
expect(item?.content?.type).toBe("doc");
|
|
expect(item?.content?.children[0]?.children.map((node) => node.type)).toEqual(["text", "hashtag"]);
|
|
expect(item?.plainText).toBe("End to end #acampada");
|
|
expect(item?.media?.[0]?.proxyUrl).toBe(`${PROXY.proxyPath}?url=${encodeURIComponent("https://cdn.social/e2e.png")}`);
|
|
assertZeroRawRemnants(item?.content as RichTextDocument, item?.plainText as string, item?.media as MediaRef[]);
|
|
});
|
|
});
|