Add FeedOrchestrator that coordinates fetch→parse→dedup→store pipeline: - FeedSource type for managing RSS/Atom feed configurations - Feed source CRUD operations in IStorage interface - Database schema migration for feed_sources table - Exponential backoff retry with configurable delays - Per-feed poll intervals with health tracking - Concurrency-limited parallel feed processing - ProcessResult and FeedHealth interfaces for status monitoring Files added: - orchestrator/orchestrator.ts - main orchestrator class - orchestrator/scheduler.ts - backoff calculation utilities - orchestrator/index.ts - module exports - orchestrator/orchestrator.test.ts - comprehensive test suite Files modified: - interfaces/feed.types.ts - add FeedSource type - interfaces/storage.interface.ts - extend with feed source methods - infrastructure/db/database.ts - add FeedSourceTable interface - infrastructure/db/schema.ts - add feed_sources table migration - modules/storage/storage.ts - implement feed source CRUD - modules/storage/storage.test.ts - add feed source tests
208 lines
6.8 KiB
TypeScript
208 lines
6.8 KiB
TypeScript
import { describe, it, expect } from 'vitest';
|
|
import { Formatter } from './formatter.js';
|
|
import { TerminalFormatter } from './terminal.formatter.js';
|
|
import { JsonFormatter } from './json.formatter.js';
|
|
import type { FeedItem } from '../../interfaces/feed.types.js';
|
|
|
|
describe('Formatter', () => {
|
|
const formatter = new Formatter();
|
|
|
|
const createMockItem = (id: string, overrides: Partial<FeedItem> = {}): FeedItem => ({
|
|
id,
|
|
source: 'https://example.com/feed',
|
|
title: 'Test Article',
|
|
url: 'https://example.com/article/1',
|
|
publishedAt: new Date('2026-05-05T10:30:00Z'),
|
|
content: undefined,
|
|
summary: undefined,
|
|
...overrides
|
|
});
|
|
|
|
describe('format with terminal format', () => {
|
|
it('should format empty array', async () => {
|
|
const result = await formatter.format([], 'terminal');
|
|
expect(result).toContain('No items to display');
|
|
});
|
|
|
|
it('should format single item', async () => {
|
|
const item = createMockItem('item-1', {
|
|
title: 'My Article',
|
|
source: 'https://myblog.com/feed'
|
|
});
|
|
const result = await formatter.format([item], 'terminal');
|
|
expect(result).toContain('1.');
|
|
expect(result).toContain('My Article');
|
|
expect(result).toContain('https://myblog.com/feed');
|
|
expect(result).toContain('https://example.com/article/1');
|
|
});
|
|
|
|
it('should format multiple items', async () => {
|
|
const items = [
|
|
createMockItem('item-1', { title: 'Article 1' }),
|
|
createMockItem('item-2', { title: 'Article 2' })
|
|
];
|
|
const result = await formatter.format(items, 'terminal');
|
|
expect(result).toContain('Found 2 items');
|
|
expect(result).toContain('1.');
|
|
expect(result).toContain('2.');
|
|
expect(result).toContain('Article 1');
|
|
expect(result).toContain('Article 2');
|
|
});
|
|
|
|
it('should include summary when present', async () => {
|
|
const item = createMockItem('item-1', {
|
|
summary: 'This is a test summary that might be truncated'
|
|
});
|
|
const result = await formatter.format([item], 'terminal');
|
|
expect(result).toContain('This is a test summary');
|
|
});
|
|
});
|
|
|
|
describe('format with json format', () => {
|
|
it('should format empty array', async () => {
|
|
const result = await formatter.format([], 'json');
|
|
expect(JSON.parse(result)).toEqual([]);
|
|
});
|
|
|
|
it('should format single item', async () => {
|
|
const item = createMockItem('item-1', {
|
|
title: 'My Article'
|
|
});
|
|
const result = await formatter.format([item], 'json');
|
|
const parsed = JSON.parse(result);
|
|
expect(parsed).toHaveLength(1);
|
|
expect(parsed[0].id).toBe('item-1');
|
|
expect(parsed[0].title).toBe('My Article');
|
|
expect(parsed[0].publishedAt).toBe('2026-05-05T10:30:00.000Z');
|
|
});
|
|
|
|
it('should format multiple items', async () => {
|
|
const items = [
|
|
createMockItem('item-1', { title: 'Article 1' }),
|
|
createMockItem('item-2', { title: 'Article 2' })
|
|
];
|
|
const result = await formatter.format(items, 'json');
|
|
const parsed = JSON.parse(result);
|
|
expect(parsed).toHaveLength(2);
|
|
expect(parsed[0].title).toBe('Article 1');
|
|
expect(parsed[1].title).toBe('Article 2');
|
|
});
|
|
|
|
it('should include optional content when present', async () => {
|
|
const item = createMockItem('item-1', {
|
|
content: 'Full article content here'
|
|
});
|
|
const result = await formatter.format([item], 'json');
|
|
const parsed = JSON.parse(result);
|
|
expect(parsed[0].content).toBe('Full article content here');
|
|
});
|
|
|
|
it('should include optional summary when present', async () => {
|
|
const item = createMockItem('item-1', {
|
|
summary: 'Article summary'
|
|
});
|
|
const result = await formatter.format([item], 'json');
|
|
const parsed = JSON.parse(result);
|
|
expect(parsed[0].summary).toBe('Article summary');
|
|
});
|
|
|
|
it('should not include undefined fields', async () => {
|
|
const item = createMockItem('item-1');
|
|
const result = await formatter.format([item], 'json');
|
|
const parsed = JSON.parse(result);
|
|
expect(parsed[0]).not.toHaveProperty('content');
|
|
expect(parsed[0]).not.toHaveProperty('summary');
|
|
});
|
|
|
|
it('should use ISO 8601 date format', async () => {
|
|
const item = createMockItem('item-1', {
|
|
publishedAt: new Date('2026-12-25T15:30:45.123Z')
|
|
});
|
|
const result = await formatter.format([item], 'json');
|
|
const parsed = JSON.parse(result);
|
|
expect(parsed[0].publishedAt).toBe('2026-12-25T15:30:45.123Z');
|
|
});
|
|
});
|
|
|
|
describe('error handling', () => {
|
|
it('should throw FormatterError for HTML format', async () => {
|
|
await expect(formatter.format([], 'html')).rejects.toMatchObject({
|
|
code: 'UNKNOWN',
|
|
message: 'HTML format not implemented'
|
|
});
|
|
});
|
|
|
|
it('should throw FormatterError for unknown format', async () => {
|
|
await expect(formatter.format([], 'unknown-format' as any)).rejects.toMatchObject({
|
|
code: 'SERIALIZE_ERROR'
|
|
});
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('TerminalFormatter', () => {
|
|
const terminalFormatter = new TerminalFormatter();
|
|
|
|
it('should truncate long summary text', () => {
|
|
const longSummary = 'a'.repeat(100);
|
|
const item: FeedItem = {
|
|
id: 'item-1',
|
|
source: 'https://example.com',
|
|
title: 'Test',
|
|
url: 'https://example.com/article',
|
|
publishedAt: new Date(),
|
|
summary: longSummary
|
|
};
|
|
const result = terminalFormatter.format([item]);
|
|
expect(result).toContain('...');
|
|
expect(result).not.toContain(longSummary);
|
|
});
|
|
|
|
it('should format date in readable format', () => {
|
|
const item: FeedItem = {
|
|
id: 'item-1',
|
|
source: 'https://example.com',
|
|
title: 'Test',
|
|
url: 'https://example.com/article',
|
|
publishedAt: new Date('2026-05-05T10:30:00Z')
|
|
};
|
|
const result = terminalFormatter.format([item]);
|
|
expect(result).toContain('May');
|
|
expect(result).toContain('2026');
|
|
});
|
|
});
|
|
|
|
describe('JsonFormatter', () => {
|
|
const jsonFormatter = new JsonFormatter();
|
|
|
|
it('should produce valid JSON', () => {
|
|
const items: FeedItem[] = [
|
|
{
|
|
id: 'test-1',
|
|
source: 'https://example.com',
|
|
title: 'Test Article',
|
|
url: 'https://example.com/1',
|
|
publishedAt: new Date()
|
|
}
|
|
];
|
|
const result = jsonFormatter.format(items);
|
|
expect(() => JSON.parse(result)).not.toThrow();
|
|
});
|
|
|
|
it('should convert Date to ISO string', () => {
|
|
const date = new Date('2026-01-15T08:00:00Z');
|
|
const items: FeedItem[] = [
|
|
{
|
|
id: 'test-1',
|
|
source: 'https://example.com',
|
|
title: 'Test',
|
|
url: 'https://example.com/1',
|
|
publishedAt: date
|
|
}
|
|
];
|
|
const result = jsonFormatter.format(items);
|
|
const parsed = JSON.parse(result);
|
|
expect(parsed[0].publishedAt).toBe('2026-01-15T08:00:00.000Z');
|
|
});
|
|
});
|