Missing conversations: recipient Reddit accounts that are deleted or suspended left conversations unaccounted for. A new RecipientAccountStatusService polls Reddit for the users behind cached conversations and records their account status durably, with progressive backfill and delta-media fixes in ChatService so history and images are no longer dropped mid-sync. Media: attachMedia now surfaces download failures as MediaAttachmentError so the sync cursor can be retried instead of silently losing an image, recognizes image bytes when Matrix returns a generic content type, and keeps separate gallery occurrences when the same asset appears in more than one event. Manual inbox tab: the conversations list now carries the reply-loop status, so the inbox can separate conversations the operator is driving by hand from the ones automation is still handling. A Manual filter pill and a per-row chip explain why each conversation was handed over. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
225 lines
11 KiB
TypeScript
225 lines
11 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('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');
|
|
});
|
|
});
|