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)); }); });