import { describe, expect, mock, test } from "bun:test";
import { MemoryPluginPersistence as MemoryPersistence } from "../../../../../test-support/plugin-persistence";
import { createRssNewsCapability, RSS_FEED_CACHE_POLICY, RSS_FEED_CACHE_VERSION } from "./source";
import type { RssFeedConfig } from "./parser";
const FEED: RssFeedConfig = {
id: "example-feed",
url: "https://example.com/rss.xml",
name: "Example",
category: "general",
authority: 80,
enabled: true,
};
const RSS_FIXTURE = `-
Breaking: NVIDIA rallies on AI demand
https://example.com/nvda
${new Date().toUTCString()}
NVIDIA shares moved higher.
`;
describe("createRssNewsCapability", () => {
test("does not serve unclassified RSS stories as top news", async () => {
const fetchText = mock(async () => ({
ok: true,
text: async () => RSS_FIXTURE,
}));
const source = createRssNewsCapability([FEED], { fetchText });
expect(source.provider.supports?.({ feed: "top" })).toBe(false);
expect(await source.provider.fetchNews({ feed: "top" })).toEqual([]);
expect(fetchText).not.toHaveBeenCalled();
});
test("caches fetched feed items with feed authority scoring", async () => {
const persistence = new MemoryPersistence();
const fetchText = mock(async () => ({
ok: true,
text: async () => RSS_FIXTURE,
}));
const source = createRssNewsCapability([FEED], { persistence, fetchText });
const items = await source.provider.fetchNews({ scope: "global" });
expect(fetchText).toHaveBeenCalledTimes(1);
expect(items).toHaveLength(1);
expect(items[0]!.importance).toBeGreaterThanOrEqual(FEED.authority);
expect(items[0]!.isBreaking).toBe(true);
expect(source.provider.getCachedNews?.({ scope: "global" })).toHaveLength(1);
});
test("uses fresh plugin cache without refetching", async () => {
const persistence = new MemoryPersistence();
const source = createRssNewsCapability([FEED], {
persistence,
fetchText: async () => {
throw new Error("should not fetch");
},
});
persistence.setResource("rss-feed", FEED.id, {
items: [{
id: "cached",
title: "Cached headline",
url: "https://example.com/cached",
source: FEED.name,
publishedAt: new Date().toISOString(),
categories: ["general"],
tickers: [],
importance: 60,
isBreaking: false,
}],
}, {
sourceKey: FEED.url,
schemaVersion: RSS_FEED_CACHE_VERSION,
cachePolicy: RSS_FEED_CACHE_POLICY,
});
const items = await source.provider.fetchNews({ scope: "global" });
expect(items).toHaveLength(1);
expect(items[0]!.title).toBe("Cached headline");
});
test("falls back to stale plugin cache when refresh fails", async () => {
const persistence = new MemoryPersistence();
const stalePolicy = { staleMs: -1, expireMs: 60_000 };
persistence.setResource("rss-feed", FEED.id, {
items: [{
id: "stale",
title: "Stale headline",
url: "https://example.com/stale",
source: FEED.name,
publishedAt: new Date().toISOString(),
categories: ["general"],
tickers: [],
importance: 50,
isBreaking: false,
}],
}, {
sourceKey: FEED.url,
schemaVersion: RSS_FEED_CACHE_VERSION,
cachePolicy: stalePolicy,
});
const source = createRssNewsCapability([FEED], {
persistence,
fetchText: async () => {
throw new Error("network down");
},
});
const items = await source.provider.fetchNews({ scope: "global" });
expect(items).toHaveLength(1);
expect(items[0]!.title).toBe("Stale headline");
});
});
test("partial feed failure preserves successful and stale articles, reports the gap, and recovers", async () => {
const persistence = new MemoryPersistence();
const other = { ...FEED, id: "other", url: "https://other.example.com/rss" };
let broken = false;
const source = createRssNewsCapability([FEED, other], { persistence, fetchText: async (url) => ({
ok: true,
text: async () => broken && url === FEED.url ? "
Proxy error" : RSS_FIXTURE.replace("nvda", `${url === FEED.url ? "nvda" : "other"}`),
}) });
await source.provider.fetchNewsPage!({ feed: "latest" });
const cached = persistence.getResource<{ items: unknown[] }>("rss-feed", FEED.id, { sourceKey: FEED.url })!;
persistence.seedResource("rss-feed", FEED.id, cached.value, { sourceKey: FEED.url, schemaVersion: RSS_FEED_CACHE_VERSION, stale: true });
broken = true;
const failed = await source.provider.fetchNewsPage!({ feed: "latest" });
expect(failed.articles).toHaveLength(2);
expect(failed.error).toBe("1 of 2 RSS feeds unavailable.");
expect(persistence.getResource("rss-feed", FEED.id, { sourceKey: FEED.url })!.stale).toBe(true);
broken = false;
expect((await source.provider.fetchNewsPage!({ feed: "latest" })).error).toBeNull();
expect(persistence.getResource("rss-feed", FEED.id, { sourceKey: FEED.url })!.stale).toBe(false);
});
test("legacy parser cache is refetched and valid empty feeds are cached without claiming pagination", async () => {
const persistence = new MemoryPersistence();
persistence.setResource("rss-feed", FEED.id, { items: [{ id: "old", title: "Old self link", url: "https://example.com/api", source: FEED.name, publishedAt: new Date().toISOString() }] }, { sourceKey: FEED.url, cachePolicy: RSS_FEED_CACHE_POLICY });
const fetchText = mock(async () => ({ ok: true, text: async () => '' }));
const source = createRssNewsCapability([FEED], { persistence, fetchText });
expect(source.provider.getCachedNews!({ feed: "latest" })).toEqual([]);
expect((await source.provider.fetchNewsPage!({ feed: "latest" })).articles).toEqual([]);
expect((await source.provider.fetchNewsPage!({ feed: "latest" })).error).toBeNull();
expect(fetchText).toHaveBeenCalledTimes(1);
expect(source.provider.supports!({ feed: "latest", cursor: "next" })).toBe(false);
expect((await source.provider.fetchNewsPage!({ feed: "latest", cursor: "next" })).articles).toEqual([]);
expect(fetchText).toHaveBeenCalledTimes(1);
});