- Add storage module with SQLite persistence via better-sqlite3 - Add deduplication module for feed item dedup - Add infrastructure directory for deployment config - Add .env.example for environment variables - Update dependencies: kysely, better-sqlite3, pg
431 lines
13 KiB
TypeScript
431 lines
13 KiB
TypeScript
import { describe, it, expect, beforeEach, afterAll } from 'vitest';
|
|
import BetterSqlite3 from 'better-sqlite3';
|
|
import { Kysely, SqliteDialect } from 'kysely';
|
|
import { SqlStorage } from './storage.js';
|
|
import { migrate, reset } from '../../infrastructure/db/schema.js';
|
|
import type { Database } from '../../infrastructure/db/database.js';
|
|
import type { FeedItem } from '../../interfaces/feed.types.js';
|
|
|
|
describe('SqlStorage', () => {
|
|
let sqliteDb: BetterSqlite3.Database;
|
|
let db: Kysely<Database>;
|
|
let storage: SqlStorage;
|
|
|
|
beforeEach(async () => {
|
|
// Create in-memory database for each test
|
|
sqliteDb = new BetterSqlite3(':memory:');
|
|
sqliteDb.pragma('journal_mode = WAL');
|
|
|
|
db = new Kysely<Database>({
|
|
dialect: new SqliteDialect({
|
|
database: sqliteDb,
|
|
}),
|
|
});
|
|
|
|
// Reset and migrate
|
|
await reset(db);
|
|
await migrate(db);
|
|
|
|
storage = new SqlStorage(db);
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await db.destroy();
|
|
});
|
|
|
|
describe('save', () => {
|
|
it('saves a single item', async () => {
|
|
const item: FeedItem = {
|
|
id: 'abc123',
|
|
source: 'https://example.com/feed.xml',
|
|
title: 'Test Article',
|
|
url: 'https://example.com/article',
|
|
publishedAt: new Date('2024-09-06T09:00:00Z'),
|
|
summary: 'A summary',
|
|
content: 'Full content',
|
|
};
|
|
|
|
await storage.save([item]);
|
|
|
|
const recent = await storage.getRecent(1);
|
|
expect(recent).toHaveLength(1);
|
|
expect(recent[0].id).toBe('abc123');
|
|
expect(recent[0].title).toBe('Test Article');
|
|
});
|
|
|
|
it('saves multiple items in batch', async () => {
|
|
const items: FeedItem[] = [
|
|
{
|
|
id: 'item1',
|
|
source: 'https://example.com/feed.xml',
|
|
title: 'Article 1',
|
|
url: 'https://example.com/1',
|
|
publishedAt: new Date('2024-09-06T10:00:00Z'),
|
|
},
|
|
{
|
|
id: 'item2',
|
|
source: 'https://example.com/feed.xml',
|
|
title: 'Article 2',
|
|
url: 'https://example.com/2',
|
|
publishedAt: new Date('2024-09-06T09:00:00Z'),
|
|
},
|
|
];
|
|
|
|
await storage.save(items);
|
|
|
|
const recent = await storage.getRecent(10);
|
|
expect(recent).toHaveLength(2);
|
|
});
|
|
|
|
it('handles empty array gracefully', async () => {
|
|
await storage.save([]);
|
|
const recent = await storage.getRecent(10);
|
|
expect(recent).toHaveLength(0);
|
|
});
|
|
|
|
it('updates existing item on duplicate id (upsert)', async () => {
|
|
const item: FeedItem = {
|
|
id: 'same-id',
|
|
source: 'https://example.com/feed.xml',
|
|
title: 'Original Title',
|
|
url: 'https://example.com/article',
|
|
publishedAt: new Date('2024-09-06T09:00:00Z'),
|
|
summary: 'Original summary',
|
|
};
|
|
|
|
await storage.save([item]);
|
|
|
|
// Update with new title and content
|
|
const updatedItem: FeedItem = {
|
|
...item,
|
|
title: 'Updated Title',
|
|
content: 'Updated content',
|
|
summary: 'Updated summary',
|
|
};
|
|
|
|
await storage.save([updatedItem]);
|
|
|
|
const recent = await storage.getRecent(1);
|
|
expect(recent).toHaveLength(1);
|
|
expect(recent[0].title).toBe('Updated Title');
|
|
expect(recent[0].content).toBe('Updated content');
|
|
});
|
|
|
|
it('preserves other fields during upsert', async () => {
|
|
const item: FeedItem = {
|
|
id: 'preserved-id',
|
|
source: 'https://example.com/feed.xml',
|
|
title: 'Title',
|
|
url: 'https://example.com/article',
|
|
publishedAt: new Date('2024-09-06T09:00:00Z'),
|
|
summary: 'Summary',
|
|
content: 'Content',
|
|
};
|
|
|
|
await storage.save([item]);
|
|
|
|
// Update only title
|
|
const updatedItem: FeedItem = {
|
|
...item,
|
|
title: 'New Title',
|
|
};
|
|
|
|
await storage.save([updatedItem]);
|
|
|
|
const recent = await storage.getRecent(1);
|
|
expect(recent[0].url).toBe('https://example.com/article'); // unchanged
|
|
expect(recent[0].publishedAt).toEqual(new Date('2024-09-06T09:00:00Z')); // unchanged
|
|
});
|
|
});
|
|
|
|
describe('getRecent', () => {
|
|
it('returns items ordered by published date desc', async () => {
|
|
const items: FeedItem[] = [
|
|
{
|
|
id: 'old',
|
|
source: 'https://example.com/feed.xml',
|
|
title: 'Old Article',
|
|
url: 'https://example.com/old',
|
|
publishedAt: new Date('2024-09-05T09:00:00Z'),
|
|
},
|
|
{
|
|
id: 'new',
|
|
source: 'https://example.com/feed.xml',
|
|
title: 'New Article',
|
|
url: 'https://example.com/new',
|
|
publishedAt: new Date('2024-09-06T09:00:00Z'),
|
|
},
|
|
];
|
|
|
|
await storage.save(items);
|
|
|
|
const recent = await storage.getRecent(10);
|
|
expect(recent[0].title).toBe('New Article');
|
|
expect(recent[1].title).toBe('Old Article');
|
|
});
|
|
|
|
it('respects limit parameter', async () => {
|
|
const items: FeedItem[] = Array.from({ length: 5 }, (_, i) => ({
|
|
id: `item${i}`,
|
|
source: 'https://example.com/feed.xml',
|
|
title: `Article ${i}`,
|
|
url: `https://example.com/${i}`,
|
|
publishedAt: new Date(`2024-09-0${i + 1}T09:00:00Z`),
|
|
}));
|
|
|
|
await storage.save(items);
|
|
|
|
const recent = await storage.getRecent(2);
|
|
expect(recent).toHaveLength(2);
|
|
});
|
|
|
|
it('returns empty array when no items', async () => {
|
|
const recent = await storage.getRecent(10);
|
|
expect(recent).toHaveLength(0);
|
|
});
|
|
});
|
|
|
|
describe('getBySource', () => {
|
|
it('returns items filtered by source', async () => {
|
|
const items: FeedItem[] = [
|
|
{
|
|
id: 'source1-item',
|
|
source: 'https://source1.com/feed.xml',
|
|
title: 'Source 1 Article',
|
|
url: 'https://source1.com/article',
|
|
publishedAt: new Date('2024-09-06T09:00:00Z'),
|
|
},
|
|
{
|
|
id: 'source2-item',
|
|
source: 'https://source2.com/feed.xml',
|
|
title: 'Source 2 Article',
|
|
url: 'https://source2.com/article',
|
|
publishedAt: new Date('2024-09-06T10:00:00Z'),
|
|
},
|
|
];
|
|
|
|
await storage.save(items);
|
|
|
|
const source1Items = await storage.getBySource('https://source1.com/feed.xml', 10);
|
|
expect(source1Items).toHaveLength(1);
|
|
expect(source1Items[0].title).toBe('Source 1 Article');
|
|
});
|
|
|
|
it('respects limit parameter', async () => {
|
|
const items: FeedItem[] = Array.from({ length: 5 }, (_, i) => ({
|
|
id: `item${i}`,
|
|
source: 'https://example.com/feed.xml',
|
|
title: `Article ${i}`,
|
|
url: `https://example.com/${i}`,
|
|
publishedAt: new Date(`2024-09-0${i + 1}T09:00:00Z`),
|
|
}));
|
|
|
|
await storage.save(items);
|
|
|
|
const sourceItems = await storage.getBySource('https://example.com/feed.xml', 2);
|
|
expect(sourceItems).toHaveLength(2);
|
|
});
|
|
|
|
it('returns empty array when no matching source', async () => {
|
|
const items: FeedItem[] = [
|
|
{
|
|
id: 'item1',
|
|
source: 'https://example.com/feed.xml',
|
|
title: 'Article',
|
|
url: 'https://example.com/article',
|
|
publishedAt: new Date('2024-09-06T09:00:00Z'),
|
|
},
|
|
];
|
|
|
|
await storage.save(items);
|
|
|
|
const result = await storage.getBySource('https://other.com/feed.xml', 10);
|
|
expect(result).toHaveLength(0);
|
|
});
|
|
});
|
|
|
|
describe('search', () => {
|
|
it('finds items by title', async () => {
|
|
const items: FeedItem[] = [
|
|
{
|
|
id: 'item1',
|
|
source: 'https://example.com/feed.xml',
|
|
title: 'JavaScript Tutorial',
|
|
url: 'https://example.com/js',
|
|
publishedAt: new Date('2024-09-06T09:00:00Z'),
|
|
},
|
|
{
|
|
id: 'item2',
|
|
source: 'https://example.com/feed.xml',
|
|
title: 'Python Guide',
|
|
url: 'https://example.com/py',
|
|
publishedAt: new Date('2024-09-06T10:00:00Z'),
|
|
},
|
|
];
|
|
|
|
await storage.save(items);
|
|
|
|
const results = await storage.search('JavaScript');
|
|
expect(results).toHaveLength(1);
|
|
expect(results[0].title).toBe('JavaScript Tutorial');
|
|
});
|
|
|
|
it('finds items by summary', async () => {
|
|
const items: FeedItem[] = [
|
|
{
|
|
id: 'item1',
|
|
source: 'https://example.com/feed.xml',
|
|
title: 'Article One',
|
|
url: 'https://example.com/1',
|
|
publishedAt: new Date('2024-09-06T09:00:00Z'),
|
|
summary: 'Learn about async programming',
|
|
},
|
|
{
|
|
id: 'item2',
|
|
source: 'https://example.com/feed.xml',
|
|
title: 'Article Two',
|
|
url: 'https://example.com/2',
|
|
publishedAt: new Date('2024-09-06T10:00:00Z'),
|
|
summary: 'Introduction to types',
|
|
},
|
|
];
|
|
|
|
await storage.save(items);
|
|
|
|
const results = await storage.search('async');
|
|
expect(results).toHaveLength(1);
|
|
expect(results[0].title).toBe('Article One');
|
|
});
|
|
|
|
it('finds items by content', async () => {
|
|
const items: FeedItem[] = [
|
|
{
|
|
id: 'item1',
|
|
source: 'https://example.com/feed.xml',
|
|
title: 'Article One',
|
|
url: 'https://example.com/1',
|
|
publishedAt: new Date('2024-09-06T09:00:00Z'),
|
|
content: 'Detailed explanation of closures',
|
|
},
|
|
{
|
|
id: 'item2',
|
|
source: 'https://example.com/feed.xml',
|
|
title: 'Article Two',
|
|
url: 'https://example.com/2',
|
|
publishedAt: new Date('2024-09-06T10:00:00Z'),
|
|
content: 'Overview of interfaces',
|
|
},
|
|
];
|
|
|
|
await storage.save(items);
|
|
|
|
const results = await storage.search('closures');
|
|
expect(results).toHaveLength(1);
|
|
expect(results[0].title).toBe('Article One');
|
|
});
|
|
|
|
it('returns multiple matching items', async () => {
|
|
const items: FeedItem[] = [
|
|
{
|
|
id: 'item1',
|
|
source: 'https://example.com/feed.xml',
|
|
title: 'React Tutorial',
|
|
url: 'https://example.com/react',
|
|
publishedAt: new Date('2024-09-05T09:00:00Z'),
|
|
},
|
|
{
|
|
id: 'item2',
|
|
source: 'https://example.com/feed.xml',
|
|
title: 'Vue Guide',
|
|
url: 'https://example.com/vue',
|
|
publishedAt: new Date('2024-09-06T10:00:00Z'),
|
|
summary: 'A framework tutorial',
|
|
},
|
|
];
|
|
|
|
await storage.save(items);
|
|
|
|
const results = await storage.search('Tutorial');
|
|
expect(results).toHaveLength(2);
|
|
});
|
|
|
|
it('returns empty array when no matches', async () => {
|
|
const items: FeedItem[] = [
|
|
{
|
|
id: 'item1',
|
|
source: 'https://example.com/feed.xml',
|
|
title: 'Article',
|
|
url: 'https://example.com/article',
|
|
publishedAt: new Date('2024-09-06T09:00:00Z'),
|
|
},
|
|
];
|
|
|
|
await storage.save(items);
|
|
|
|
const results = await storage.search('nonexistent');
|
|
expect(results).toHaveLength(0);
|
|
});
|
|
|
|
it('performs case-sensitive search (as per SQL LIKE)', async () => {
|
|
const items: FeedItem[] = [
|
|
{
|
|
id: 'item1',
|
|
source: 'https://example.com/feed.xml',
|
|
title: 'JavaScript',
|
|
url: 'https://example.com/js',
|
|
publishedAt: new Date('2024-09-06T09:00:00Z'),
|
|
},
|
|
];
|
|
|
|
await storage.save(items);
|
|
|
|
// SQLite LIKE is case-insensitive by default for ASCII
|
|
const results = await storage.search('javascript');
|
|
expect(results).toHaveLength(1);
|
|
});
|
|
});
|
|
|
|
describe('FeedItem properties preservation', () => {
|
|
it('preserves all FeedItem fields', async () => {
|
|
const item: FeedItem = {
|
|
id: 'full-item',
|
|
source: 'https://example.com/feed.xml',
|
|
title: 'Complete Article',
|
|
url: 'https://example.com/complete',
|
|
publishedAt: new Date('2024-09-06T09:30:00Z'),
|
|
summary: 'A brief summary',
|
|
content: 'The full article content here',
|
|
};
|
|
|
|
await storage.save([item]);
|
|
|
|
const recent = await storage.getRecent(1);
|
|
const retrieved = recent[0];
|
|
|
|
expect(retrieved.id).toBe(item.id);
|
|
expect(retrieved.source).toBe(item.source);
|
|
expect(retrieved.title).toBe(item.title);
|
|
expect(retrieved.url).toBe(item.url);
|
|
expect(retrieved.publishedAt).toEqual(item.publishedAt);
|
|
expect(retrieved.summary).toBe(item.summary);
|
|
expect(retrieved.content).toBe(item.content);
|
|
});
|
|
|
|
it('handles items without optional fields', async () => {
|
|
const item: FeedItem = {
|
|
id: 'minimal-item',
|
|
source: 'https://example.com/feed.xml',
|
|
title: 'Minimal Article',
|
|
url: 'https://example.com/minimal',
|
|
publishedAt: new Date('2024-09-06T09:00:00Z'),
|
|
};
|
|
|
|
await storage.save([item]);
|
|
|
|
const recent = await storage.getRecent(1);
|
|
expect(recent[0].summary).toBeUndefined();
|
|
expect(recent[0].content).toBeUndefined();
|
|
});
|
|
});
|
|
});
|