Files
RedditBot/tests/account-repositories.test.ts
T
Jamie BruntonandClaude Opus 5 a9cea7ed18 Retire removed accounts instead of deleting them
Removing an account had no path at all, so a dead session could only be
left running or hand-edited out of the database. The first cut deleted the
account and everything it owned, which threw away conversations and media
that are worth keeping.

Retire instead: keep the row and every row it produced, destroy the stored
cookies and Matrix token. Wiping the credentials is what makes sending and
syncing impossible rather than merely disabled — isEnabledAccount requires
a verified status, so no runtime ever starts, and no session survives for
anything to use. setActive, markLoginRequired and the background credential
refresh now skip retired accounts so none of them quietly revives one;
re-importing a session deliberately does.

Keeping the content visible needed more than keeping the rows. Conversations
and media are only served from running runtimes, so a retired account's
history would have disappeared from the dashboard regardless. Media already
reads the account content table for every listed account. Conversations now
have an archive path that reads the stored ActiveChat payloads directly.

Every write path refuses a retired conversation with a 409: send-reply,
both suggest endpoints, share-media, shareable-media and all read-state
writes. Lazy-loading detail is skipped too — the fallback chat service
belongs to a different account, so it would sync the wrong session and
write the wrong cache. Archived detail derives conversation state from the
transcript alone for the same reason.

Migration 32 rebuilds reddit_accounts to admit the new status, with foreign
keys off and a foreign_key_check before they are restored, as migration 11
does. Two migration tests rewound user_version by counting back from the
head, which silently breaks whenever a migration is appended; they now
target absolute versions.

Conversations never opened before removal keep only their summary. Message
history is lazy-loaded on first open and there is no session left to fetch
the rest with.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 08:58:22 +01:00

312 lines
15 KiB
TypeScript

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,
RedditRecipientAccountStatusRepository,
} 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('stores recipient account state case-insensitively and allows later recovery', () => {
const statuses = new RedditRecipientAccountStatusRepository(db);
statuses.upsert('Alice', 'deleted', 100);
expect(statuses.get('alice')).toEqual({ username: 'Alice', status: 'deleted', checkedAt: 100 });
statuses.upsert('ALICE', 'active', 200);
expect(statuses.get('alice')).toEqual({ username: 'Alice', status: 'active', checkedAt: 200 });
});
it('updates matrix credentials in place without touching cookies or status', () => {
addAccount('t2_alice', 'alice-bot', 100);
const refreshed = JSON.stringify({ accessToken: 'refreshed-matrix-secret' });
expect(accounts.updateMatrixCredentials('t2_alice', refreshed, 200)).toBe(true);
expect(accounts.get('t2_alice')).toMatchObject({
matrixCredentialsJson: refreshed,
cookiesJson: expect.stringContaining('alice-bot-secret'),
status: 'verified',
updatedAt: 200,
lastVerifiedAt: 200,
});
expect(accounts.updateMatrixCredentials('t2_missing', refreshed, 200)).toBe(false);
});
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('retires an account while keeping every row it collected', () => {
addAccount('t2_alice', 'alice-bot', 100);
addAccount('t2_bob', 'bob-bot', 110);
for (const accountId of ['t2_alice', 't2_bob']) {
db.prepare(`
INSERT INTO processed_posts (account_id, id, subreddit, author, title, processed_at)
VALUES (?, 'post-one', 'test', 'recipient', 'title', 1)
`).run(accountId);
db.prepare(`
INSERT INTO sent_messages (account_id, post_id, recipient, subject, message, sent_at)
VALUES (?, 'post-one', 'recipient', 'subject', 'message', 2)
`).run(accountId);
db.prepare(`
INSERT INTO successful_messages (account_id, original_message, created_at)
VALUES (?, 'message', 3)
`).run(accountId);
db.prepare(`
INSERT INTO delivery_outbox (
account_id, post_id, recipient, subject, message, idempotency_key,
status, created_at, updated_at
) VALUES (?, 'post-one', 'recipient', 'subject', 'message', 'key', 'prepared', 1, 1)
`).run(accountId);
db.prepare(`
INSERT INTO activity_log (account_id, timestamp, type, message) VALUES (?, 4, 'sent', 'activity')
`).run(accountId);
db.prepare(`
INSERT INTO conversation_states (account_id, conversation_id, state_json, updated_at)
VALUES (?, 'room', '{}', 5)
`).run(accountId);
content.upsertConversation({
accountId, conversationId: 'room', username: 'recipient', roomName: 'Recipient',
payloadJson: '{}', updatedAt: 6,
});
content.saveSyncState({
accountId, nextBatch: 'cursor', lastFullSyncAt: 6, lastDeltaSyncAt: 6,
cooldownJson: null, updatedAt: 6,
});
}
expect(accounts.retire('t2_alice', 500)).toBe(true);
// The content the account collected is the point of retiring rather than
// deleting: every row it produced must survive untouched.
for (const table of [
'processed_posts', 'sent_messages', 'successful_messages', 'delivery_outbox',
'activity_log', 'conversation_states', 'reddit_conversations', 'reddit_account_sync_state',
]) {
expect({ table, ...db.prepare(`SELECT COUNT(*) count FROM ${table} WHERE account_id = ?`).get('t2_alice') as object })
.toEqual({ table, count: 1 });
}
expect(accounts.get('t2_alice')).toMatchObject({
status: 'removed', isActive: false, removedAt: 500,
});
// Credentials are destroyed, so no session survives to sync or send with.
expect(accounts.get('t2_alice')).toMatchObject({ cookiesJson: '[]', matrixCredentialsJson: '{}' });
expect(accounts.getActive().map((account) => account.accountId)).toEqual(['t2_bob']);
expect(db.pragma('foreign_key_check')).toEqual([]);
});
it('refuses to reactivate or refresh a retired account', () => {
addAccount('t2_alice', 'alice-bot', 100);
expect(accounts.retire('t2_alice', 500)).toBe(true);
expect(accounts.setActive('t2_alice', true, 600)).toBe(false);
expect(accounts.markLoginRequired('t2_alice', 600)).toBe(false);
expect(accounts.updateMatrixCredentials('t2_alice', JSON.stringify({ accessToken: 'new' }), 600)).toBe(false);
expect(accounts.get('t2_alice')).toMatchObject({ status: 'removed', matrixCredentialsJson: '{}' });
});
it('revives a retired account when a fresh session is imported for it', () => {
addAccount('t2_alice', 'alice-bot', 100);
accounts.retire('t2_alice', 500);
const revived = addAccount('t2_alice', 'alice-bot', 700);
expect(revived).toMatchObject({ status: 'verified', isActive: true, removedAt: null });
});
it('reports an unknown account as not retired and rejects a blank id', () => {
addAccount('t2_alice', 'alice-bot', 100);
expect(accounts.retire('t2_missing')).toBe(false);
expect(() => accounts.retire(' ')).toThrow(/accountId/);
expect(accounts.get('t2_alice')).toMatchObject({ status: 'verified' });
});
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 });
}
});
it('stores separate media occurrences that share one downloaded file', () => {
addAccount('t2_alice', 'alice-bot', 100);
const base = {
accountId: 't2_alice', chatUsername: 'sender', chatRoomId: 'room-one', sender: 'sender',
originalUrl: 'mxc://reddit.com/shared', fileName: 'shared.jpg', relativePath: 'shared.jpg',
createdAt: 10,
};
content.upsertMedia({ ...base, mediaId: 'event-one', timestamp: 1 });
content.upsertMedia({ ...base, mediaId: 'event-two', timestamp: 2 });
expect(content.listMedia('t2_alice')).toHaveLength(2);
expect(content.getMediaByOriginalUrl('t2_alice', base.originalUrl)?.mediaId).toBe('event-one');
});
});