Files
RedditBot/tests/account-repositories.test.ts
T
Jamie BruntonandClaude Opus 5 142378df33 feat: show the conversation evidence behind each learning trajectory
Reviewers were asked to include or exclude learning trajectories while
only seeing metadata — credit class, confidence, status. The decisions
and their redacted context turns were already captured and stored, but
no endpoint returned them, so the evidence was unreachable from the UI.

Add GET /api/learning/trajectories/:id and an expandable row on the
learning page that fetches on demand.

Also widen SentMessageSchema to accept matrixEventId, imageOutcomeCredit
and imageOutcomeAt. The learning loop added these to SentMessageRecord
and they flow out of /api/messages, but the strict contract rejected
them, breaking the dashboard.

Enable generation with shadow mode off so eligible examples reach reply
prompts.

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

289 lines
14 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('removes an account with everything it owns and leaves other accounts intact', () => {
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.delete('t2_alice')).toBe(true);
expect(accounts.get('t2_alice')).toBeNull();
expect(accounts.listPublic().map((account) => account.accountId)).toContain('t2_bob');
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: 0 });
expect({ table, ...db.prepare(`SELECT COUNT(*) count FROM ${table} WHERE account_id = ?`).get('t2_bob') as object })
.toEqual({ table, count: 1 });
}
expect(db.pragma('foreign_key_check')).toEqual([]);
});
it('reports an unknown account as not removed and rejects a blank id', () => {
addAccount('t2_alice', 'alice-bot', 100);
expect(accounts.delete('t2_missing')).toBe(false);
expect(() => accounts.delete(' ')).toThrow(/accountId/);
expect(accounts.get('t2_alice')).not.toBeNull();
});
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');
});
});