import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import Database from 'better-sqlite3'; import { createDatabase, runMigrations } from '../src/database/client.js'; import { getTestConfig } from '../tests/testUtils.js'; describe('Database Client', () => { describe('createDatabase', () => { it('creates an in-memory database', () => { const config = getTestConfig({ database: { path: ':memory:' } }); const db = createDatabase(config); expect(db).toBeDefined(); db.close(); }); it('enables WAL mode', () => { const config = getTestConfig({ database: { path: ':memory:' } }); const db = createDatabase(config); const journalMode = db.pragma('journal_mode', { simple: true }) as string; // SQLite uses an in-memory journal for the special :memory: database; // file-backed databases use WAL in production. expect(['wal', 'memory']).toContain(journalMode); db.close(); }); it('enables foreign keys', () => { const config = getTestConfig({ database: { path: ':memory:' } }); const db = createDatabase(config); const fk = db.pragma('foreign_keys', { simple: true }) as number; expect(fk).toBe(1); db.close(); }); }); describe('runMigrations', () => { let db: Database.Database; beforeEach(() => { db = new Database(':memory:'); db.pragma('foreign_keys = ON'); }); afterEach(() => { db.close(); }); it('creates processed_posts table', () => { runMigrations(db); const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table'").all() as { name: string }[]; const tableNames = tables.map(t => t.name); expect(tableNames).toContain('processed_posts'); }); it('creates sent_messages table', () => { runMigrations(db); const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table'").all() as { name: string }[]; const tableNames = tables.map(t => t.name); expect(tableNames).toContain('sent_messages'); }); it('creates successful_messages table', () => { runMigrations(db); const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table'").all() as { name: string }[]; const tableNames = tables.map(t => t.name); expect(tableNames).toContain('successful_messages'); }); it('creates indexes', () => { runMigrations(db); const indexes = db.prepare("SELECT name FROM sqlite_master WHERE type='index'").all() as { name: string }[]; const indexNames = indexes.map(i => i.name); expect(indexNames).toContain('idx_posts_subreddit'); expect(indexNames).toContain('idx_posts_processed_at'); expect(indexNames).toContain('idx_posts_message_sent'); expect(indexNames).toContain('idx_sent_messages_response'); expect(indexNames).toContain('idx_sent_messages_recipient'); }); it('adds got_response column', () => { runMigrations(db); const columns = db.prepare('PRAGMA table_info(sent_messages)').all() as { name: string }[]; const columnNames = columns.map(c => c.name); expect(columnNames).toContain('got_response'); expect(columnNames).toContain('response_checked_at'); }); it('adds strategy_id column', () => { runMigrations(db); const columns = db.prepare('PRAGMA table_info(sent_messages)').all() as { name: string }[]; const columnNames = columns.map(c => c.name); expect(columnNames).toContain('strategy_id'); }); it('adds subreddit column to successful_messages', () => { runMigrations(db); const columns = db.prepare('PRAGMA table_info(successful_messages)').all() as { name: string }[]; const columnNames = columns.map(c => c.name); expect(columnNames).toContain('subreddit'); }); it('is idempotent (safe to run multiple times)', () => { runMigrations(db); runMigrations(db); runMigrations(db); const tables = db .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'") .all() as { name: string }[]; // processed_posts, sent_messages, successful_messages, activity_log, reddit_users, delivery_outbox, conversation_states, conversation_outcomes expect(tables.length).toBe(8); }); it('tracks schema version and migrates a legacy sent_messages table transactionally', () => { db.exec(` CREATE TABLE processed_posts (id TEXT PRIMARY KEY, subreddit TEXT NOT NULL, author TEXT NOT NULL, title TEXT NOT NULL, image_url TEXT, image_analysis TEXT, generated_message TEXT, processed_at INTEGER NOT NULL, message_sent INTEGER DEFAULT 0, error TEXT); CREATE TABLE sent_messages (id INTEGER PRIMARY KEY AUTOINCREMENT, post_id TEXT NOT NULL, recipient TEXT NOT NULL, subject TEXT NOT NULL, message TEXT NOT NULL, sent_at INTEGER NOT NULL); INSERT INTO processed_posts VALUES ('post-1', 'test', 'author', 'title', NULL, NULL, NULL, 1, 0, NULL); INSERT INTO sent_messages (post_id, recipient, subject, message, sent_at) VALUES ('post-1', 'recipient', 'subject', 'message', 2); `); runMigrations(db); expect(db.pragma('user_version', { simple: true })).toBeGreaterThan(0); expect(db.prepare('SELECT post_id FROM sent_messages').get()).toEqual({ post_id: 'post-1' }); const postId = db.prepare('PRAGMA table_info(sent_messages)').all().find((column: { name: string }) => column.name === 'post_id') as { notnull: number }; expect(postId.notnull).toBe(0); expect(db.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'delivery_outbox'").get()).toBeDefined(); }); it('processed_posts has correct schema', () => { runMigrations(db); const columns = db.prepare('PRAGMA table_info(processed_posts)').all() as { name: string; notnull: number }[]; const columnMap = new Map(columns.map(c => [c.name, c])); expect(columnMap.get('id')?.notnull).toBe(0); // TEXT is not NOT NULL by default expect(columnMap.get('subreddit')?.notnull).toBe(1); expect(columnMap.get('author')?.notnull).toBe(1); expect(columnMap.get('title')?.notnull).toBe(1); expect(columnMap.get('processed_at')?.notnull).toBe(1); expect(columnMap.get('message_sent')).toBeDefined(); expect(columnMap.get('error')).toBeDefined(); }); it('sent_messages has correct schema', () => { runMigrations(db); const columns = db.prepare('PRAGMA table_info(sent_messages)').all() as { name: string; notnull: number }[]; const columnMap = new Map(columns.map(c => [c.name, c])); expect(columnMap.get('recipient')?.notnull).toBe(1); expect(columnMap.get('subject')?.notnull).toBe(1); expect(columnMap.get('message')?.notnull).toBe(1); expect(columnMap.get('sent_at')?.notnull).toBe(1); expect(columnMap.get('post_id')?.notnull).toBe(0); // nullable — chat rows have no post expect(columnMap.get('chat_room_id')).toBeDefined(); }); it('successful_messages has correct schema', () => { runMigrations(db); const columns = db.prepare('PRAGMA table_info(successful_messages)').all() as { name: string; notnull: number }[]; const columnMap = new Map(columns.map(c => [c.name, c])); expect(columnMap.get('original_message')?.notnull).toBe(1); expect(columnMap.get('created_at')?.notnull).toBe(1); expect(columnMap.get('sent_message_id')?.notnull).toBe(0); // nullable }); }); });