323 lines
15 KiB
TypeScript
323 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('durably pauses a suspended account without destroying its session or history', () => {
|
|
addAccount('t2_alice', 'alice-bot', 100);
|
|
|
|
expect(accounts.markSuspended('t2_alice', 200)).toBe(true);
|
|
expect(accounts.get('t2_alice')).toMatchObject({
|
|
status: 'suspended', isActive: false, updatedAt: 200,
|
|
cookiesJson: expect.stringContaining('alice-bot-secret'),
|
|
});
|
|
expect(accounts.getActive()).toEqual([]);
|
|
});
|
|
|
|
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');
|
|
});
|
|
});
|