Add #rss command family with LLM tags and summaries (v0.20.0)
CI / build + unit (pull_request) Successful in 1m15s
CI / build + unit (pull_request) Successful in 1m15s
#rss subcommands on the command channel: help / add <url> <name> / remove <name> / tags / sources / get <name|#tag>. Sources are stored in a new rss_sources table in the existing MESH_DB_PATH file (second node:sqlite connection, case-insensitive names); feeds parsed with fast-xml-parser (RSS 2.0 + Atom); AI tags and headline summaries via injected LlmService.chat() with bounded maxTokens (50/200). CommandService gains prefix registration so one handler owns the whole #rss family while #ping stays an exact match; wired in CommandModule's factory — still no constructor-level cycle, no forwardRef. One dead feed does not sink a tag summary.
This commit is contained in:
@@ -0,0 +1,80 @@
|
||||
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 } from 'vitest';
|
||||
import { RssStore } from './rss.store.js';
|
||||
|
||||
describe('RssStore (real SQLite file)', () => {
|
||||
let dir: string;
|
||||
let store: RssStore;
|
||||
|
||||
beforeEach(() => {
|
||||
dir = mkdtempSync(join(tmpdir(), 'mesh-bot-rss-'));
|
||||
const db = new DatabaseSync(join(dir, 'test.db'));
|
||||
store = new RssStore(db);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('adds and lists sources with tags round-tripped through the comma string', () => {
|
||||
store.add({
|
||||
name: 'Hacker News',
|
||||
url: 'https://news.ycombinator.com/rss',
|
||||
tags: ['tech', 'news'],
|
||||
});
|
||||
store.add({ name: 'LWN', url: 'https://lwn.net/headlines.rss', tags: [] });
|
||||
|
||||
expect(store.list()).toEqual([
|
||||
{
|
||||
name: 'Hacker News',
|
||||
url: 'https://news.ycombinator.com/rss',
|
||||
tags: ['tech', 'news'],
|
||||
createdAt: expect.any(Number),
|
||||
},
|
||||
{
|
||||
name: 'LWN',
|
||||
url: 'https://lwn.net/headlines.rss',
|
||||
tags: [],
|
||||
createdAt: expect.any(Number),
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('looks up and checks existence case-insensitively', () => {
|
||||
store.add({ name: 'My Feed', url: 'https://example.com/rss', tags: ['a'] });
|
||||
expect(store.get('my feed')?.url).toBe('https://example.com/rss');
|
||||
expect(store.get('MY FEED')).toBeDefined();
|
||||
expect(store.exists('mY fEeD')).toBe(true);
|
||||
expect(store.exists('other')).toBe(false);
|
||||
});
|
||||
|
||||
it('removes case-insensitively and reports whether a row went', () => {
|
||||
store.add({ name: 'Gone', url: 'https://example.com/gone', tags: [] });
|
||||
expect(store.remove('gone')).toBe(true);
|
||||
expect(store.list()).toEqual([]);
|
||||
expect(store.remove('gone')).toBe(false);
|
||||
});
|
||||
|
||||
it('collects all distinct tags across sources, sorted', () => {
|
||||
store.add({
|
||||
name: 'A',
|
||||
url: 'https://a.example/rss',
|
||||
tags: ['zeta', 'alpha'],
|
||||
});
|
||||
store.add({
|
||||
name: 'B',
|
||||
url: 'https://b.example/rss',
|
||||
tags: ['beta', 'alpha'],
|
||||
});
|
||||
expect(store.allTags()).toEqual(['alpha', 'beta', 'zeta']);
|
||||
});
|
||||
|
||||
it('persists across a second connection to the same file (the production shape)', () => {
|
||||
store.add({ name: 'Durable', url: 'https://d.example/rss', tags: ['x'] });
|
||||
const db2 = new DatabaseSync(join(dir, 'test.db'));
|
||||
expect(new RssStore(db2).get('durable')?.tags).toEqual(['x']);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user