import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { DatabaseSync } from 'node:sqlite';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { LlmService } from '../llm/llm.service.js';
import { RssStore } from './rss.store.js';
import { fitOneLine, parseSummaryItems, RssService } from './rss.service.js';
const FEED_A = `
- Alpha headline
- Beta headline
`;
const FEED_B = `
- Gamma headline
`;
function feedResponse(xml: string): Response {
return { ok: true, status: 200, text: async () => xml } as Response;
}
describe('RssService.handle', () => {
let dir: string;
let dbFile: string;
let llm: { chat: ReturnType };
let svc: RssService;
let fetchMock: ReturnType;
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), 'mesh-bot-rss-svc-'));
dbFile = join(dir, 'test.db');
llm = { chat: vi.fn() };
svc = new RssService(
llm as unknown as LlmService,
new DatabaseSync(dbFile),
);
fetchMock = vi.fn();
vi.stubGlobal('fetch', fetchMock);
});
afterEach(() => {
vi.unstubAllGlobals();
rmSync(dir, { recursive: true, force: true });
});
const ctx = (text: string) => ({ text });
// second connection — the same shape production uses for a separate table in one file
const stored = () => new RssStore(new DatabaseSync(dbFile));
it('replies with usage on bare #rss and on unknown subcommands', async () => {
const help = await svc.handle(ctx('#rss'));
expect(help).toContain('#rss add ');
expect(help).toContain('#rss get ');
expect(await svc.handle(ctx('#rss frobnicate'))).toBe(help);
});
it('add: validates the feed, stores source with LLM tags, replies added (tags: ...)', async () => {
fetchMock.mockResolvedValueOnce(feedResponse(FEED_A));
llm.chat.mockResolvedValueOnce('#AI, News');
const reply = await svc.handle(
ctx('#rss add https://a.example/rss my feed'),
);
expect(reply).toBe('added my feed (tags: ai, news)');
expect(stored().get('my feed')).toMatchObject({
url: 'https://a.example/rss',
tags: ['ai', 'news'],
});
const [messages, opts] = llm.chat.mock.calls[0];
expect(opts).toEqual({ maxTokens: 50 });
expect(messages[0].content).toContain('Alpha headline');
});
it('add: LLM failure still stores the source with empty tags and says so', async () => {
fetchMock.mockResolvedValueOnce(feedResponse(FEED_A));
llm.chat.mockRejectedValueOnce(new Error('llm down'));
const reply = await svc.handle(
ctx('#rss add https://a.example/rss myfeed'),
);
expect(reply).toBe('added myfeed (tag generation failed - no tags)');
expect(stored().get('myfeed')?.tags).toEqual([]);
});
it('add: rejects invalid urls without fetching or storing', async () => {
expect(await svc.handle(ctx('#rss add not a url x'))).toBe('invalid url');
expect(await svc.handle(ctx('#rss add ftp://a.example/rss x'))).toBe(
'invalid url',
);
expect(fetchMock).not.toHaveBeenCalled();
expect(stored().list()).toEqual([]);
});
it('add: rejects duplicate names case-insensitively without fetching', async () => {
stored().add({ name: 'MyFeed', url: 'https://old.example/rss', tags: [] });
const reply = await svc.handle(
ctx('#rss add https://a.example/rss myfeed'),
);
expect(reply).toBe('myfeed already exists - remove it first');
expect(fetchMock).not.toHaveBeenCalled();
expect(stored().get('myfeed')?.url).toBe('https://old.example/rss'); // untouched
});
it('add: reports non-2xx and unparseable feeds, stores nothing', async () => {
fetchMock.mockResolvedValueOnce({ ok: false, status: 404 } as Response);
expect(await svc.handle(ctx('#rss add https://a.example/rss x'))).toBe(
'feed fetch failed (HTTP 404)',
);
fetchMock.mockResolvedValueOnce(feedResponse('this is not xml'));
expect(await svc.handle(ctx('#rss add https://b.example/rss y'))).toBe(
'not a valid feed (no items)',
);
expect(stored().list()).toEqual([]);
});
it('remove: deletes case-insensitively, reports missing names', async () => {
stored().add({ name: 'Gone', url: 'https://g.example/rss', tags: [] });
expect(await svc.handle(ctx('#rss remove gone'))).toBe('removed gone');
expect(stored().list()).toEqual([]);
expect(await svc.handle(ctx('#rss remove nope'))).toBe(
'no source named nope',
);
});
it('tags: no tags yet when empty, distinct sorted list otherwise', async () => {
expect(await svc.handle(ctx('#rss tags'))).toBe('no tags yet');
stored().add({
name: 'A',
url: 'https://a.example/rss',
tags: ['zeta', 'alpha'],
});
stored().add({
name: 'B',
url: 'https://b.example/rss',
tags: ['beta', 'alpha'],
});
expect(await svc.handle(ctx('#rss tags'))).toBe('alpha, beta, zeta');
});
it('sources: no sources yet when empty, names otherwise', async () => {
expect(await svc.handle(ctx('#rss sources'))).toBe('no sources yet');
stored().add({ name: 'Bee', url: 'https://b.example/rss', tags: [] });
stored().add({ name: 'Aye', url: 'https://a.example/rss', tags: [] });
expect(await svc.handle(ctx('#rss sources'))).toBe('Aye, Bee');
});
it('get : summarizes via chat with a bounded maxTokens and replies with headered lines', async () => {
stored().add({ name: 'myfeed', url: 'https://a.example/rss', tags: [] });
fetchMock.mockResolvedValueOnce(feedResponse(FEED_A));
llm.chat.mockResolvedValueOnce(
'Chips: EU funds foundries.\nPark: Riverfront expansion approved.',
);
const reply = await svc.handle(ctx('#rss get myfeed'));
expect(reply).toEqual([
'Chips: EU funds foundries.',
'Park: Riverfront expansion approved.',
]);
const [messages, opts] = llm.chat.mock.calls[0];
// headroom for the server's reasoning tokens (they count against max_tokens) — see rss.service.ts
expect(opts).toEqual({ maxTokens: 2500 });
const prompt = messages[0].content;
expect(prompt).toContain('exactly 3-5 lines');
expect(prompt).toContain(': ');
expect(prompt).toContain('at most 20 characters');
expect(prompt).toContain('No intro, no outro, no numbering');
expect(prompt).toContain('Alpha headline');
expect(prompt).toContain('Beta headline');
});
it('get : caps items at 5', async () => {
stored().add({ name: 'myfeed', url: 'https://a.example/rss', tags: [] });
fetchMock.mockResolvedValueOnce(feedResponse(FEED_A));
const seven = ['a', 'b', 'c', 'd', 'e', 'f', 'g']
.map((c) => `${c}: item ${c}`)
.join('\n');
llm.chat.mockResolvedValueOnce(seven);
const reply = await svc.handle(ctx('#rss get myfeed'));
expect(reply).toEqual([
'a: item a',
'b: item b',
'c: item c',
'd: item d',
'e: item e',
]);
});
it('get : drops empty and separator-less lines, sends what remains when fewer than 3 are valid', async () => {
stored().add({ name: 'myfeed', url: 'https://a.example/rss', tags: [] });
fetchMock.mockResolvedValueOnce(feedResponse(FEED_A));
llm.chat.mockResolvedValueOnce(
'Chips: EU funds foundries.\n\nJust a sentence without a header\n \nPark: expansion approved.',
);
const reply = await svc.handle(ctx('#rss get myfeed'));
expect(reply).toEqual([
'Chips: EU funds foundries.',
'Park: expansion approved.',
]);
});
it('get : hard-cuts an over-length line at a word boundary so each item fits one packet', async () => {
stored().add({ name: 'myfeed', url: 'https://a.example/rss', tags: [] });
fetchMock.mockResolvedValueOnce(feedResponse(FEED_A));
const longLine = `Chips: ${'word '.repeat(40)}`; // 207 chars — over the per-line cap
llm.chat.mockResolvedValueOnce(`${longLine}\nPark: short line.`);
const reply = await svc.handle(ctx('#rss get myfeed'));
expect(Array.isArray(reply)).toBe(true);
const [cut, ok] = reply as string[];
expect(cut.length).toBeLessThanOrEqual(180);
expect(longLine.startsWith(cut)).toBe(true); // prefix — cut at a word boundary, not mid-word
expect(cut.endsWith(' ')).toBe(false);
expect(ok).toBe('Park: short line.'); // other lines untouched
});
it('get #tag: collects items across all sources with the tag; one dead feed does not sink it', async () => {
stored().add({ name: 'A', url: 'https://a.example/rss', tags: ['tech'] });
stored().add({ name: 'B', url: 'https://b.example/rss', tags: ['tech'] });
stored().add({ name: 'C', url: 'https://c.example/rss', tags: ['other'] }); // not matched by #tech
fetchMock
.mockResolvedValueOnce(feedResponse(FEED_A)) // A answers (Alpha, Beta)
.mockRejectedValueOnce(new Error('down')); // B is dead
llm.chat.mockResolvedValueOnce('Chips: only alpha survived.');
const reply = await svc.handle(ctx('#rss get #tech'));
// B's fetch rejected, yet the summary still went out from A's titles alone
expect(reply).toEqual(['Chips: only alpha survived.']);
expect(llm.chat).toHaveBeenCalledTimes(1);
const content = llm.chat.mock.calls[0][0][0].content;
expect(content).toContain('Alpha headline');
});
it('get #tag: merges titles from every source carrying the tag', async () => {
stored().add({ name: 'A', url: 'https://a.example/rss', tags: ['tech'] });
stored().add({ name: 'B', url: 'https://b.example/rss', tags: ['tech'] });
fetchMock
.mockResolvedValueOnce(feedResponse(FEED_A)) // A → Alpha, Beta
.mockResolvedValueOnce(feedResponse(FEED_B)); // B → Gamma
llm.chat.mockResolvedValueOnce(
'Alpha: first headline.\nGamma: second headline.',
);
const reply = await svc.handle(ctx('#rss get #tech'));
expect(reply).toEqual([
'Alpha: first headline.',
'Gamma: second headline.',
]);
const content = llm.chat.mock.calls[0][0][0].content;
expect(content).toContain('Alpha headline');
expect(content).toContain('Gamma headline'); // proves B's feed was collected too
});
it('get: unknown name and tag get distinct error replies', async () => {
expect(await svc.handle(ctx('#rss get nope'))).toBe('no source named nope');
stored().add({ name: 'A', url: 'https://a.example/rss', tags: ['tech'] });
expect(await svc.handle(ctx('#rss get #nothing'))).toBe(
'no sources with tag nothing',
);
});
});
describe('parseSummaryItems (per-line items for multi-message summaries)', () => {
it('keeps lines with a header separator, drops empty and separator-less ones', () => {
expect(
parseSummaryItems(
'Chips: EU funds foundries.\n\nNo header here\n \nPark: expansion approved.',
),
).toEqual(['Chips: EU funds foundries.', 'Park: expansion approved.']);
});
it('caps at 5 items', () => {
const seven = ['a', 'b', 'c', 'd', 'e', 'f', 'g']
.map((c) => `${c}: item`)
.join('\n');
expect(parseSummaryItems(seven)).toHaveLength(5);
expect(parseSummaryItems(seven)[4]).toBe('e: item');
});
it('returns [] when nothing is valid', () => {
expect(parseSummaryItems('no headers at all\n')).toEqual([]);
});
});
describe('fitOneLine (one mesh packet per line)', () => {
it('passes through lines that already fit, including exactly 185 chars', () => {
const short = 'Chips: EU funds foundries.';
expect(fitOneLine(short)).toBe(short);
const exact = 'x'.repeat(185);
expect(fitOneLine(exact)).toBe(exact);
});
it('cuts over-long lines at a word boundary near 180', () => {
const long = `Chips: ${'word '.repeat(40)}`; // 207 chars
const cut = fitOneLine(long);
expect(cut.length).toBeLessThanOrEqual(180);
expect(long.startsWith(cut)).toBe(true); // prefix — not mid-word
expect(cut.endsWith(' ')).toBe(false);
});
it('hard-cuts at 180 when there is no word boundary in reach', () => {
const long = 'w'.repeat(250);
expect(fitOneLine(long)).toBe('w'.repeat(180));
});
});