256 lines
11 KiB
TypeScript
256 lines
11 KiB
TypeScript
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');
|
|
expect(columnNames).toContain('got_image');
|
|
});
|
|
|
|
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 }[];
|
|
// Existing domain/config tables plus account, conversation, sync and media ownership stores.
|
|
expect(tables.length).toBe(20);
|
|
expect(tables.map((table) => table.name)).toEqual(expect.arrayContaining([
|
|
'candidate_queue',
|
|
'runtime_state',
|
|
'media_library_assets',
|
|
'media_library_sends',
|
|
]));
|
|
});
|
|
|
|
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);
|
|
CREATE TABLE activity_log (id INTEGER PRIMARY KEY AUTOINCREMENT, timestamp INTEGER NOT NULL, type TEXT NOT NULL, message TEXT NOT NULL, recipient TEXT, image_url TEXT);
|
|
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);
|
|
INSERT INTO activity_log (timestamp, type, message, recipient) VALUES (2, 'sent', 'Sent message to u/recipient (r/test)', 'recipient');
|
|
`);
|
|
|
|
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();
|
|
expect(db.prepare('SELECT post_title, post_url FROM activity_log').get()).toEqual({
|
|
post_title: 'title',
|
|
post_url: 'https://www.reddit.com/r/test/comments/post-1/',
|
|
});
|
|
});
|
|
|
|
it('backfills global recipient suppression from sent messages and known conversations', () => {
|
|
runMigrations(db);
|
|
db.prepare(`
|
|
INSERT INTO reddit_conversations (
|
|
account_id, conversation_id, username, room_name, payload_json, updated_at
|
|
) VALUES (?, 'old-room', 'Old_Contact', 'Old Contact', '{}', 20)
|
|
`).run('__legacy_unassigned__');
|
|
db.prepare(`
|
|
INSERT INTO processed_posts (account_id, id, subreddit, author, title, processed_at)
|
|
VALUES (?, 'old-post', 'test', 'sent-contact', 'title', 10)
|
|
`).run('__legacy_unassigned__');
|
|
db.prepare(`
|
|
INSERT INTO sent_messages (
|
|
account_id, post_id, recipient, subject, message, sent_at
|
|
) VALUES (?, 'old-post', 'Sent_Contact', 'subject', 'message', 11)
|
|
`).run('__legacy_unassigned__');
|
|
|
|
const latestVersion = db.pragma('user_version', { simple: true }) as number;
|
|
db.exec(`
|
|
DELETE FROM recipient_suppressions;
|
|
DROP TABLE conversation_automation_claims;
|
|
DROP TABLE ai_usage;
|
|
`);
|
|
db.pragma(`user_version = ${latestVersion - 7}`);
|
|
runMigrations(db);
|
|
|
|
expect(db.prepare('SELECT recipient, reason FROM recipient_suppressions ORDER BY recipient COLLATE NOCASE').all())
|
|
.toEqual([
|
|
{ recipient: 'Old_Contact', reason: 'historical-conversation' },
|
|
{ recipient: 'Sent_Contact', reason: 'sent-message-history' },
|
|
]);
|
|
});
|
|
|
|
it('preserves pending reply claims when adding auditable superseded status', () => {
|
|
runMigrations(db);
|
|
const latestVersion = db.pragma('user_version', { simple: true }) as number;
|
|
db.exec(`
|
|
DROP TABLE conversation_automation_claims;
|
|
CREATE TABLE conversation_automation_claims (
|
|
account_id TEXT NOT NULL,
|
|
conversation_id TEXT NOT NULL,
|
|
incoming_key TEXT NOT NULL,
|
|
status TEXT NOT NULL CHECK(status IN ('pending', 'sent')),
|
|
claimed_at INTEGER NOT NULL,
|
|
sent_at INTEGER,
|
|
reply_text TEXT,
|
|
incoming_at INTEGER,
|
|
last_attempt_at INTEGER,
|
|
attempt_count INTEGER NOT NULL DEFAULT 0,
|
|
PRIMARY KEY (account_id, conversation_id, incoming_key),
|
|
FOREIGN KEY (account_id) REFERENCES reddit_accounts(account_id) ON DELETE CASCADE
|
|
);
|
|
INSERT INTO conversation_automation_claims (
|
|
account_id, conversation_id, incoming_key, status, claimed_at,
|
|
reply_text, incoming_at, last_attempt_at, attempt_count
|
|
) VALUES (
|
|
'__legacy_unassigned__', 'room', 'event', 'pending', 10,
|
|
'durable reply', 5, 11, 1
|
|
);
|
|
`);
|
|
db.pragma(`user_version = ${latestVersion - 2}`);
|
|
|
|
runMigrations(db);
|
|
|
|
expect(db.prepare(`
|
|
SELECT status, reply_text, incoming_at, last_attempt_at, attempt_count, superseded_at
|
|
FROM conversation_automation_claims
|
|
`).get()).toEqual({
|
|
status: 'pending', reply_text: 'durable reply', incoming_at: 5,
|
|
last_attempt_at: 11, attempt_count: 1, superseded_at: null,
|
|
});
|
|
});
|
|
|
|
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('account_id')?.notnull).toBe(1);
|
|
expect(columnMap.get('id')?.notnull).toBe(1); // Composite account-scoped primary key
|
|
expect(columnMap.get('subreddit')?.notnull).toBe(1);
|
|
expect(columnMap.get('author')?.notnull).toBe(1);
|
|
expect(columnMap.get('title')?.notnull).toBe(1);
|
|
expect(columnMap.get('post_created_at')).toBeDefined();
|
|
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();
|
|
expect(columnMap.get('got_image')).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
|
|
});
|
|
});
|
|
});
|