import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import Database from 'better-sqlite3'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { AccountContentRepository, RedditAccountsRepository, } from '../src/database/account-repositories.js'; import { LEGACY_UNASSIGNED_ACCOUNT_ID, runMigrations } from '../src/database/client.js'; import { importLegacyAccountContent } from '../src/database/legacy-content-import.js'; describe('multi-account database ownership', () => { let db: Database.Database; let accounts: RedditAccountsRepository; let content: AccountContentRepository; beforeEach(() => { db = new Database(':memory:'); db.pragma('foreign_keys = ON'); runMigrations(db); accounts = new RedditAccountsRepository(db); content = new AccountContentRepository(db); }); afterEach(() => db.close()); function addAccount(accountId: string, username: string, now: number) { return accounts.upsertVerified({ accountId, redditUsername: username, matrixUserId: `@${accountId}:reddit.com`, cookiesJson: JSON.stringify([{ name: 'reddit_session', value: `${username}-secret` }]), matrixCredentialsJson: JSON.stringify({ accessToken: `${username}-matrix-secret` }), now, }); } it('stores private verified session material without exposing it in public listings', () => { const publicAccount = addAccount('t2_alice', 'alice-bot', 100); expect(publicAccount).toMatchObject({ accountId: 't2_alice', redditUsername: 'alice-bot', status: 'verified', isActive: true, createdAt: 100, updatedAt: 100, lastVerifiedAt: 100, lastImportedAt: 100, }); expect(publicAccount).not.toHaveProperty('cookiesJson'); expect(publicAccount).not.toHaveProperty('matrixCredentialsJson'); expect(accounts.listPublic().find((item) => item.accountId === 't2_alice')) .not.toHaveProperty('cookiesJson'); expect(accounts.get('t2_alice')).toMatchObject({ cookiesJson: expect.stringContaining('alice-bot-secret'), matrixCredentialsJson: expect.stringContaining('alice-bot-matrix-secret'), }); }); it('re-imports one account in place and supports multiple active accounts', () => { addAccount('t2_alice', 'alice-bot', 100); addAccount('t2_bob', 'bob-bot', 110); addAccount('t2_alice', 'alice-renamed', 120); expect(accounts.getActive().map((account) => account.accountId)).toEqual(['t2_alice', 't2_bob']); expect(accounts.get('t2_alice')).toMatchObject({ redditUsername: 'alice-renamed', createdAt: 100, updatedAt: 120, lastImportedAt: 120, }); expect(accounts.setActive('t2_bob', false, 130)).toBe(true); expect(accounts.getActive().map((account) => account.accountId)).toEqual(['t2_alice']); expect(accounts.get('t2_bob')).toMatchObject({ status: 'disabled', isActive: false, updatedAt: 130 }); }); it('partitions conversations, sync state and media even when remote IDs match', () => { addAccount('t2_alice', 'alice-bot', 100); addAccount('t2_bob', 'bob-bot', 110); for (const [accountId, username, updatedAt] of [ ['t2_alice', 'recipient-a', 200], ['t2_bob', 'recipient-b', 300], ] as const) { content.upsertConversation({ accountId, conversationId: 'shared-room', username, roomName: username, payloadJson: JSON.stringify({ username }), updatedAt, }); content.saveSyncState({ accountId, nextBatch: `cursor-${accountId}`, lastFullSyncAt: updatedAt, lastDeltaSyncAt: updatedAt + 1, cooldownJson: JSON.stringify({ until: updatedAt + 100 }), updatedAt, }); content.upsertMedia({ accountId, mediaId: 'shared-media', chatUsername: username, chatRoomId: 'shared-room', sender: username, timestamp: updatedAt, originalUrl: 'mxc://reddit.com/shared', fileName: `${accountId}.jpg`, relativePath: `${accountId}/shared.jpg`, createdAt: updatedAt, }); } expect(content.getConversation('t2_alice', 'shared-room')?.username).toBe('recipient-a'); expect(content.getConversation('t2_bob', 'shared-room')?.username).toBe('recipient-b'); expect(content.getSyncState('t2_alice')?.nextBatch).toBe('cursor-t2_alice'); expect(content.getSyncState('t2_bob')?.nextBatch).toBe('cursor-t2_bob'); expect(content.getMedia('t2_alice', 'shared-media')?.fileName).toBe('t2_alice.jpg'); expect(content.getMedia('t2_bob', 'shared-media')?.fileName).toBe('t2_bob.jpg'); }); it('enforces account ownership foreign keys', () => { expect(() => content.upsertConversation({ accountId: 'missing', conversationId: 'room', username: 'someone', roomName: null, payloadJson: '{}', updatedAt: 1, })).toThrow(/FOREIGN KEY/); expect(db.pragma('foreign_key_check')).toEqual([]); }); it('scopes legacy account-produced table uniqueness by account', () => { addAccount('t2_alice', 'alice-bot', 100); addAccount('t2_bob', 'bob-bot', 110); const insertPost = db.prepare(` INSERT INTO processed_posts (account_id, id, subreddit, author, title, processed_at) VALUES (?, 'same-post', 'test', 'recipient', 'title', 1) `); insertPost.run('t2_alice'); insertPost.run('t2_bob'); const insertOutbox = db.prepare(` INSERT INTO delivery_outbox ( account_id, post_id, recipient, subject, message, idempotency_key, status, created_at, updated_at ) VALUES (?, 'same-post', 'recipient', 'subject', 'message', 'same-key', 'prepared', 1, 1) `); insertOutbox.run('t2_alice'); insertOutbox.run('t2_bob'); expect(() => insertOutbox.run('t2_alice')).toThrow(/UNIQUE/); expect(db.prepare('SELECT COUNT(*) count FROM delivery_outbox').get()).toEqual({ count: 2 }); }); it('migrates existing rows to the explicit legacy account without guessing ownership', () => { const legacyDb = new Database(':memory:'); legacyDb.pragma('foreign_keys = ON'); legacyDb.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 ('legacy-post', 'test', 'author', 'title', NULL, NULL, NULL, 1, 1, NULL); INSERT INTO sent_messages (post_id, recipient, subject, message, sent_at) VALUES ('legacy-post', 'recipient', 'subject', 'message', 2); INSERT INTO activity_log (timestamp, type, message, recipient) VALUES (2, 'sent', 'legacy activity', 'recipient'); `); runMigrations(legacyDb); expect(legacyDb.prepare('SELECT account_id, id FROM processed_posts').get()).toEqual({ account_id: LEGACY_UNASSIGNED_ACCOUNT_ID, id: 'legacy-post', }); expect(legacyDb.prepare('SELECT account_id FROM sent_messages').get()).toEqual({ account_id: LEGACY_UNASSIGNED_ACCOUNT_ID, }); expect(legacyDb.prepare('SELECT account_id FROM activity_log').get()).toEqual({ account_id: LEGACY_UNASSIGNED_ACCOUNT_ID, }); expect(legacyDb.prepare('SELECT status, is_active FROM reddit_accounts WHERE account_id = ?') .get(LEGACY_UNASSIGNED_ACCOUNT_ID)).toEqual({ status: 'legacy-unassigned', is_active: 0 }); expect(legacyDb.pragma('foreign_key_check')).toEqual([]); legacyDb.close(); }); it('imports legacy chat and media JSON once as explicitly unassigned content', () => { const dataDirectory = fs.mkdtempSync(path.join(os.tmpdir(), 'redditbot-legacy-content-')); try { fs.mkdirSync(path.join(dataDirectory, 'media')); fs.writeFileSync(path.join(dataDirectory, 'chat-cache.json'), JSON.stringify({ chats: [{ username: 'alice', roomName: 'Alice', roomId: 'room-alice', lastMessageTime: 1 }], nextBatch: 'cursor-one', lastFullSyncAt: 10, lastDeltaSyncAt: 11, cooldown: null, })); fs.writeFileSync(path.join(dataDirectory, 'media', 'index.json'), JSON.stringify({ items: [{ id: 'media-one', chatUsername: 'alice', chatRoomId: 'room-alice', sender: 'alice', timestamp: 12, originalUrl: 'mxc://one', fileName: 'one.jpg', relativePath: 'one.jpg', createdAt: 13 }], })); expect(importLegacyAccountContent(db, dataDirectory)).toEqual({ conversations: 1, media: 1, skipped: false }); expect(content.listConversations(LEGACY_UNASSIGNED_ACCOUNT_ID)).toHaveLength(1); expect(content.listMedia(LEGACY_UNASSIGNED_ACCOUNT_ID)).toHaveLength(1); expect(content.getSyncState(LEGACY_UNASSIGNED_ACCOUNT_ID)?.nextBatch).toBe('cursor-one'); expect(importLegacyAccountContent(db, dataDirectory).skipped).toBe(true); } finally { fs.rmSync(dataDirectory, { recursive: true, force: true }); } }); });