feat: persist settings and enrich conversation context

This commit is contained in:
jjbrunton committed 2026-08-08 18:18:28 +01:00
1 parent a36723a325
commit 2c100d72b6
84 files changed
+2248 -644

No files matched your search

+30
View File
@@ -0,0 +1,30 @@
import { describe, expect, it } from 'vitest';
import { ActivityLogRepository } from '../src/database/repositories.js';
import { ActivityLog } from '../src/web/activity-log.js';
import { createTestDb } from './testUtils.js';
describe('activity origin context', () => {
it('persists and hydrates the source post title, URL, and image', () => {
const db = createTestDb();
const repository = new ActivityLogRepository(db);
const activity = new ActivityLog(repository);
activity.push(
'sent',
'Sent message to u/alice (r/example)',
'alice',
'https://i.redd.it/source.jpg',
{
postTitle: 'Source title',
postUrl: 'https://www.reddit.com/r/example/comments/source/source_title/',
},
);
expect(new ActivityLog(repository).recent(1)).toEqual([expect.objectContaining({
imageUrl: 'https://i.redd.it/source.jpg',
postTitle: 'Source title',
postUrl: 'https://www.reddit.com/r/example/comments/source/source_title/',
})]);
db.close();
});
});
+22 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it, vi } from 'vitest';
import type { BrowserContext } from 'playwright';
import type { BrowserContext, Page } from 'playwright';
import { BrowserClient, type BrowserRuntime } from '../src/browser/client.js';
import type { Logger } from '../src/utils/logger.js';
import { getTestConfig } from './testUtils.js';
@@ -35,4 +35,25 @@ describe('BrowserClient', () => {
expect(client.getSessionState()).toBe('unknown');
expect(JSON.stringify(logger)).not.toContain('secret');
});
it('reads the username from Reddit\'s nested me.json account response', async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ kind: 't2', data: { name: 'bot-account' } }),
});
vi.stubGlobal('fetch', fetchMock);
const page = {
evaluate: async (pageFunction: () => Promise<string | null>) => pageFunction(),
} as unknown as Page;
const context = { pages: () => [page] } as unknown as BrowserContext;
const runtime: BrowserRuntime = { launchPersistentContext: vi.fn().mockResolvedValue(context) };
const client = new BrowserClient(getTestConfig(), createLogger(), runtime);
await expect(client.getAuthenticatedUsername()).resolves.toBe('bot-account');
expect(fetchMock).toHaveBeenCalledWith(
'https://www.reddit.com/api/me.json',
{ credentials: 'include' },
);
vi.unstubAllGlobals();
});
});
+72 -1
View File
@@ -34,8 +34,79 @@ describe('ChatService account authentication gate', () => {
auth,
);
await service.refreshTopChats();
await expect(service.refreshTopChats()).rejects.toThrow(
'Matrix authentication could not be restored',
);
expect(matrix.sync).not.toHaveBeenCalled();
});
it('coalesces concurrent delta refresh requests before they reach Matrix', async () => {
const logger = createMockLogger();
const matrix = new MatrixClient({
homeserver: 'https://matrix.example.test',
accessToken: 'verified',
userId: '@t2_account:reddit.com',
capturedAt: 1,
}, logger, { minRequestIntervalMs: 0 });
let finishSync!: (value: { next_batch: string }) => void;
matrix.sync = vi.fn(() => new Promise((resolve) => {
finishSync = resolve;
})) as typeof matrix.sync;
const cache = new ChatCache(
logger,
path.join(os.tmpdir(), `chat-auth-${Date.now()}-${Math.random()}.json`),
);
const service = new ChatService(
matrix,
logger,
undefined,
undefined,
undefined,
undefined,
cache,
undefined,
undefined,
{ isFullyAuthenticated: () => true, invalidate: vi.fn() },
);
const first = service.refreshTopChats();
const second = service.refreshTopChats();
await vi.waitFor(() => expect(matrix.sync).toHaveBeenCalledOnce());
finishSync({ next_batch: 's1' });
await expect(Promise.all([first, second])).resolves.toEqual([undefined, undefined]);
expect(matrix.sync).toHaveBeenCalledOnce();
});
it('reports failed sync termination to progress listeners', async () => {
const logger = createMockLogger();
const matrix = new MatrixClient({
homeserver: 'https://matrix.example.test',
accessToken: 'unverified',
userId: '@t2_account:reddit.com',
capturedAt: 1,
}, logger, { minRequestIntervalMs: 0 });
const service = new ChatService(
matrix,
logger,
undefined,
undefined,
undefined,
undefined,
new ChatCache(logger, path.join(os.tmpdir(), `chat-auth-${Date.now()}-${Math.random()}.json`)),
undefined,
undefined,
{ isFullyAuthenticated: () => false, invalidate: vi.fn() },
);
const listener = vi.fn();
service.setProgressListener(listener);
await expect(service.refreshTopChats()).rejects.toThrow();
expect(listener).toHaveBeenLastCalledWith(null, {
status: 'failed',
error: 'Matrix authentication could not be restored',
});
});
});
@@ -0,0 +1,197 @@
import fs from 'fs/promises';
import os from 'os';
import path from 'path';
import { describe, expect, it, vi } from 'vitest';
import { ChatCache } from '../src/services/chat/cache.js';
import { MatrixClient } from '../src/services/chat/matrix/client.js';
import type { MatrixMessagesResponse } from '../src/services/chat/matrix/types.js';
import { ChatService } from '../src/services/chat/service.js';
import { createMockLogger } from './mockLogger.js';
import { authenticatedAccountAuth, createTestDb, createTestRepositories } from './testUtils.js';
const credentials = {
homeserver: 'https://matrix.example.test',
accessToken: 'token',
userId: '@me:reddit.com',
capturedAt: Date.now(),
};
describe('ChatService progressive full backfill', () => {
it('records replies for performance only when the room has source context', async () => {
const logger = createMockLogger();
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'chat-performance-context-'));
const db = createTestDb();
const repos = createTestRepositories(db);
try {
repos.postsRepo.insert({
id: 'post-alice',
subreddit: 'example',
author: 'alice',
title: 'Source post',
imageUrl: null,
processedAt: 1_000,
});
repos.messagesRepo.insert({
postId: 'post-alice',
recipient: 'alice',
subject: 'alice',
message: 'Hello Alice',
sentAt: Date.now(),
strategyId: 'direct',
chatRoomId: '!alice:reddit.com',
});
const matrix = new MatrixClient(credentials, logger, { minRequestIntervalMs: 0 });
matrix.sync = vi.fn(async () => ({ next_batch: 's1', rooms: { join: {} } })) as typeof matrix.sync;
matrix.getJoinedRooms = vi.fn(async () => ['!alice:reddit.com', '!bob:reddit.com']);
matrix.getRoomMessages = vi.fn(async (roomId: string) => ({
chunk: [
{
event_id: `$reply-${roomId}`,
type: 'm.room.message',
sender: roomId === '!alice:reddit.com' ? '@t2_alice:reddit.com' : '@t2_bob:reddit.com',
origin_server_ts: 2_000,
content: { body: 'Hi there' },
},
{
event_id: `$first-${roomId}`,
type: 'm.room.message',
sender: '@me:reddit.com',
origin_server_ts: 1_000,
content: { body: roomId === '!alice:reddit.com' ? 'Hello Alice' : 'Hello Bob' },
},
],
start: 'start',
}));
const service = new ChatService(
matrix,
logger,
repos.successfulMessagesRepo,
undefined,
undefined,
{ resolveT2Ids: vi.fn(async () => ({ t2_alice: 'alice', t2_bob: 'bob' })) },
new ChatCache(logger, path.join(tmpDir, 'chat-cache.json')),
repos.messagesRepo,
undefined,
authenticatedAccountAuth,
);
await service.fetchAllMessages();
expect(repos.messagesRepo.getByChatRoomId('!alice:reddit.com')?.gotResponse).toBe(true);
expect(repos.messagesRepo.getByChatRoomId('!bob:reddit.com')).toBeUndefined();
expect(repos.messagesRepo.getResponseRateTrend()).toEqual([
expect.objectContaining({ totalSent: 1, totalResponses: 1, responseRate: 100 }),
]);
expect(repos.successfulMessagesRepo.getRecent()).toEqual([
expect.objectContaining({
originalMessage: 'Hello Alice',
postTitle: 'Source post',
subreddit: 'example',
}),
]);
} finally {
db.close();
await fs.rm(tmpDir, { recursive: true, force: true });
}
});
it('publishes room summaries and recent timeline messages before deep history finishes', async () => {
const logger = createMockLogger();
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'chat-progressive-backfill-'));
let finishHistory!: (response: MatrixMessagesResponse) => void;
const pendingHistory = new Promise<MatrixMessagesResponse>((resolve) => {
finishHistory = resolve;
});
let finishUsernameResolution!: (usernames: Record<string, string>) => void;
const pendingUsernames = new Promise<Record<string, string>>((resolve) => {
finishUsernameResolution = resolve;
});
try {
const matrix = new MatrixClient(credentials, logger, { minRequestIntervalMs: 0 });
matrix.sync = vi.fn(async () => ({
next_batch: 's1',
rooms: {
join: {
'!alice:reddit.com': {
summary: { 'm.heroes': ['@t2_alice:reddit.com'] },
timeline: {
events: [
{
event_id: '$first',
type: 'm.room.message',
sender: '@me:reddit.com',
origin_server_ts: 1_000,
content: { body: 'Hello Alice' },
},
{
event_id: '$reply',
type: 'm.room.message',
sender: '@t2_alice:reddit.com',
origin_server_ts: 2_000,
content: { body: 'Hi there' },
},
],
},
},
},
},
})) as typeof matrix.sync;
matrix.getJoinedRooms = vi.fn(async () => ['!alice:reddit.com']);
matrix.getRoomMessages = vi.fn(() => pendingHistory);
const usernameResolver = {
resolveT2Ids: vi.fn(() => pendingUsernames),
};
const cachePath = path.join(tmpDir, 'chat-cache.json');
const service = new ChatService(
matrix,
logger,
undefined,
undefined,
undefined,
usernameResolver,
new ChatCache(logger, cachePath),
undefined,
undefined,
authenticatedAccountAuth,
);
const backfill = service.fetchAllMessages();
await vi.waitFor(() => expect(usernameResolver.resolveT2Ids).toHaveBeenCalledOnce());
const visibleChats = await service.getCachedConversations();
expect(visibleChats).toHaveLength(1);
expect(visibleChats[0]).toMatchObject({
roomId: '!alice:reddit.com',
username: 't2_alice',
lastMessageSnippet: 'Hi there',
messages: [
{ text: 'Hello Alice', isSelf: true },
{ text: 'Hi there', isSelf: false },
],
});
expect(usernameResolver.resolveT2Ids).toHaveBeenCalledWith(['t2_alice']);
const persisted = JSON.parse(await fs.readFile(cachePath, 'utf-8')) as { chats: unknown[] };
expect(persisted.chats).toHaveLength(1);
finishUsernameResolution({ t2_alice: 'alice' });
await vi.waitFor(() => expect(matrix.getRoomMessages).toHaveBeenCalledOnce());
await vi.waitFor(async () => {
const enrichedChats = await service.getCachedConversations();
expect(enrichedChats[0].username).toBe('alice');
});
finishHistory({ chunk: [], start: 'start' });
await backfill;
} finally {
finishUsernameResolution({});
finishHistory({ chunk: [], start: 'start' });
await fs.rm(tmpDir, { recursive: true, force: true });
}
});
});
@@ -70,6 +70,73 @@ describe('ChatService username persistence', () => {
expect(svc.resolveSenderDisplay('@t2_unknown:reddit.com')).toBe('t2_unknown');
});
it('starts a conversation with the t2 ID supplied by the Reddit listing', async () => {
const logger = createMockLogger();
const matrix = buildMatrix();
matrix.sendInitialDirectMessage = vi.fn().mockResolvedValue({
roomId: '!room:reddit.com',
eventId: '$event',
});
const usernameResolver = {
resolveT2Ids: vi.fn().mockResolvedValue({}),
resolveUsernameToT2: vi.fn().mockRejectedValue(new Error('profile lookup returned 403')),
};
const svc = new ChatService(
matrix,
logger,
undefined, undefined, undefined, usernameResolver,
tmpCache(logger),
undefined,
undefined,
authenticatedAccountAuth,
);
const chat = await svc.startConversation('alice', 'hello', 't2_abc123');
expect(chat?.username).toBe('alice');
expect(usernameResolver.resolveUsernameToT2).not.toHaveBeenCalled();
expect(matrix.sendInitialDirectMessage).toHaveBeenCalledWith(
'@t2_abc123:reddit.com',
'hello',
);
});
it('uses the durable username mapping before a live profile lookup', async () => {
const db = new Database(':memory:');
runMigrations(db);
const repo = new RedditUsersRepository(db);
repo.upsert({ t2Id: 't2_cached', username: 'alice' });
const logger = createMockLogger();
const matrix = buildMatrix();
matrix.sendInitialDirectMessage = vi.fn().mockResolvedValue({
roomId: '!room:reddit.com',
eventId: '$event',
});
const usernameResolver = {
resolveT2Ids: vi.fn().mockResolvedValue({}),
resolveUsernameToT2: vi.fn().mockRejectedValue(new Error('profile lookup returned 403')),
};
const svc = new ChatService(
matrix,
logger,
undefined, undefined, undefined, usernameResolver,
tmpCache(logger),
undefined,
repo,
authenticatedAccountAuth,
);
const chat = await svc.startConversation('ALICE', 'hello');
expect(chat?.username).toBe('ALICE');
expect(usernameResolver.resolveUsernameToT2).not.toHaveBeenCalled();
expect(matrix.sendInitialDirectMessage).toHaveBeenCalledWith(
'@t2_cached:reddit.com',
'hello',
);
db.close();
});
it('upgrades cached chats with t2_ usernames from the db map on loadCache', async () => {
const db = new Database(':memory:');
runMigrations(db);
+78 -113
View File
@@ -1,128 +1,93 @@
import fs from 'fs';
import os from 'os';
import path from 'path';
import YAML from 'yaml';
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { persistSubreddits } from '../src/config/persistence.js';
import Database from 'better-sqlite3';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { ConfigStore } from '../src/config/persistence.js';
import { loadConfig } from '../src/config/index.js';
import { runMigrations } from '../src/database/client.js';
import { getTestConfig } from './testUtils.js';
const SAMPLE = `# top-level note that must survive
reddit:
# weights bias picking
subreddits:
- { name: AverageNudes, weight: 4, tone: 'direct' } # validation seekers
- { name: gonewild40plus, weight: 4, tone: 'gentle' } # mature
- { name: normalnudes, weight: 3 } # real bodies
- { name: tightandtoned, weight: 1 }
pollIntervalMs: 30000
userAgent: "ua"
`;
describe('persistSubreddits', () => {
let tmpDir: string;
let configPath: string;
describe('ConfigStore', () => {
let db: Database.Database;
let store: ConfigStore;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'rb-config-'));
configPath = path.join(tmpDir, 'config.yaml');
fs.writeFileSync(configPath, SAMPLE, 'utf-8');
db = new Database(':memory:');
runMigrations(db);
store = new ConfigStore(db);
});
afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});
afterEach(() => db.close());
it('mutates an existing row in place and preserves its inline comment', () => {
persistSubreddits(
[
{ name: 'AverageNudes', type: 'image', weight: 4, tone: 'playful' },
{ name: 'gonewild40plus', type: 'image', weight: 4, tone: 'gentle' },
{ name: 'normalnudes', type: 'image', weight: 3, tone: 'default' },
{ name: 'tightandtoned', type: 'image', weight: 1, tone: 'default' },
],
configPath,
);
const out = fs.readFileSync(configPath, 'utf-8');
expect(out).toMatch(/tone:\s*['"]?playful['"]?/);
expect(out).toContain('# validation seekers');
expect(out).toContain('# top-level note that must survive');
expect(out).toContain('# weights bias picking');
expect(out).toContain('# mature');
expect(out).toContain('pollIntervalMs: 30000');
});
it('drops removed rows (and their attached comments)', () => {
persistSubreddits(
[
{ name: 'AverageNudes', type: 'image', weight: 4, tone: 'direct' },
{ name: 'tightandtoned', type: 'image', weight: 1, tone: 'default' },
],
configPath,
);
const out = fs.readFileSync(configPath, 'utf-8');
expect(out).not.toContain('gonewild40plus');
expect(out).not.toContain('normalnudes');
expect(out).not.toContain('# mature');
expect(out).toContain('AverageNudes');
expect(out).toContain('tightandtoned');
expect(out).toContain('# validation seekers');
});
it('appends new rows in caller order', () => {
persistSubreddits(
[
{ name: 'AverageNudes', type: 'image', weight: 4, tone: 'direct' },
{ name: 'gonewild40plus', type: 'image', weight: 4, tone: 'gentle' },
{ name: 'normalnudes', type: 'image', weight: 3, tone: 'default' },
{ name: 'tightandtoned', type: 'image', weight: 1, tone: 'default' },
{ name: 'NewSub', type: 'chat', weight: 2, tone: 'playful' },
],
configPath,
);
const parsed = YAML.parse(fs.readFileSync(configPath, 'utf-8'));
const subs = parsed.reddit.subreddits;
expect(subs[subs.length - 1]).toMatchObject({
name: 'NewSub',
type: 'chat',
weight: 2,
tone: 'playful',
it('round-trips application configuration through SQLite', () => {
const config = getTestConfig({
rateLimit: { minDelayBetweenMessagesMs: 42, maxMessagesPerHour: 7, jitterMs: 3 },
});
store.save(config);
const loaded = store.load({
OPENROUTER_API_KEY: 'environment-key',
WEB_DASHBOARD_TOKEN: 'environment-dashboard-token',
DATABASE_PATH: './data/from-environment.db',
});
expect(loaded?.rateLimit).toEqual(config.rateLimit);
expect(loaded?.openrouter.apiKey).toBe('environment-key');
expect(loaded?.web.authToken).toBe('environment-dashboard-token');
expect(loaded?.database.path).toBe('./data/from-environment.db');
});
it("omits 'default' tone and 'image' type on write to match on-disk style", () => {
persistSubreddits(
[{ name: 'OnlyOne', type: 'image', weight: 1, tone: 'default' }],
configPath,
);
const out = fs.readFileSync(configPath, 'utf-8');
expect(out).not.toMatch(/tone:\s*default/);
expect(out).not.toMatch(/type:\s*image/);
it('never stores secrets in the database record', () => {
const config = getTestConfig();
config.openrouter.apiKey = 'database-must-not-contain-this';
config.web.authToken = 'also-secret';
store.save(config);
const row = db.prepare('SELECT config_json FROM app_config WHERE id = 1').get() as { config_json: string };
expect(row.config_json).not.toContain('database-must-not-contain-this');
expect(row.config_json).not.toContain('also-secret');
expect(JSON.parse(row.config_json)).not.toHaveProperty('database');
});
it('atomic write — tmp file not left behind on success', () => {
persistSubreddits(
[{ name: 'OnlyOne', type: 'image', weight: 1, tone: 'direct' }],
configPath,
);
expect(fs.existsSync(configPath + '.tmp')).toBe(false);
it('updates the singleton record instead of appending versions', () => {
store.save(getTestConfig());
store.save(getTestConfig({ browser: { sessionDir: './data/browser-session', dryRun: true } }));
expect(db.prepare('SELECT COUNT(*) AS count FROM app_config').get()).toEqual({ count: 1 });
expect(store.load({
OPENROUTER_API_KEY: 'key',
WEB_DASHBOARD_TOKEN: 'dashboard-token',
})?.browser.dryRun).toBe(true);
});
it('reorders rows according to input order', () => {
persistSubreddits(
[
{ name: 'tightandtoned', type: 'image', weight: 1, tone: 'default' },
{ name: 'AverageNudes', type: 'image', weight: 4, tone: 'direct' },
{ name: 'gonewild40plus', type: 'image', weight: 4, tone: 'gentle' },
{ name: 'normalnudes', type: 'image', weight: 3, tone: 'default' },
],
configPath,
);
const parsed = YAML.parse(fs.readFileSync(configPath, 'utf-8'));
expect(parsed.reddit.subreddits.map((s: { name: string }) => s.name)).toEqual([
'tightandtoned',
'AverageNudes',
'gonewild40plus',
'normalnudes',
]);
it('imports legacy YAML once and then starts without the file', () => {
const originalDirectory = process.cwd();
const tempDirectory = fs.mkdtempSync(path.join(os.tmpdir(), 'redditbot-config-import-'));
const previousApiKey = process.env.OPENROUTER_API_KEY;
const previousDashboardToken = process.env.WEB_DASHBOARD_TOKEN;
try {
process.chdir(tempDirectory);
process.env.OPENROUTER_API_KEY = 'environment-key';
process.env.WEB_DASHBOARD_TOKEN = 'environment-dashboard-token';
fs.writeFileSync('config.yaml', `
reddit:
subreddits: [example]
openrouter:
imageAnalysisPrompt: analyze
messageGenerationPrompt: compose
`);
expect(loadConfig(store).reddit.subreddits[0].name).toBe('example');
fs.rmSync('config.yaml');
expect(loadConfig(store).reddit.subreddits[0].name).toBe('example');
} finally {
process.chdir(originalDirectory);
fs.rmSync(tempDirectory, { recursive: true, force: true });
if (previousApiKey === undefined) delete process.env.OPENROUTER_API_KEY;
else process.env.OPENROUTER_API_KEY = previousApiKey;
if (previousDashboardToken === undefined) delete process.env.WEB_DASHBOARD_TOKEN;
else process.env.WEB_DASHBOARD_TOKEN = previousDashboardToken;
}
});
});
+47
View File
@@ -171,6 +171,7 @@ describe('dashboard foundation', () => {
expect(screen.getByText('Dry run')).toBeInTheDocument();
expect(await screen.findByRole('list', { name: 'Recent sent messages' })).toHaveTextContent('A recent opener');
expect(screen.getByRole('list', { name: 'Recent sent messages' })).toHaveTextContent('Responded');
expect(screen.getByRole('link', { name: 'u/alice' })).toHaveAttribute('href', 'https://www.reddit.com/u/alice');
});
it('keeps stats visible when timers fail and retries only the failed panel', async () => {
@@ -200,6 +201,52 @@ describe('dashboard foundation', () => {
expect(timerRequests).toBe(2);
});
it('makes usernames in recent activity clickable', async () => {
installSuccessfulApi({
recentActivity: [{
timestamp: Date.now(),
type: 'response',
message: 'Reply from u/alice',
recipient: 'alice',
}],
});
renderDashboard();
const activity = await screen.findByRole('heading', { name: 'Recent activity' });
const panel = activity.closest('section');
expect(panel).not.toBeNull();
expect(within(panel!).getByRole('link', { name: 'u/alice' })).toHaveAttribute(
'href',
'https://www.reddit.com/u/alice',
);
});
it('opens saved source-post context from a sent activity entry', async () => {
installSuccessfulApi({
recentActivity: [{
timestamp: Date.now(),
type: 'sent',
message: 'Sent message to u/alice (r/example)',
recipient: 'alice',
imageUrl: 'https://i.redd.it/source.jpg',
postTitle: 'The source post title',
postUrl: 'https://www.reddit.com/r/example/comments/source/the_source_post_title/',
}],
});
const user = userEvent.setup();
renderDashboard();
await user.click(await screen.findByRole('button', { name: 'Sent message to u/alice (r/example)' }));
const dialog = screen.getByRole('dialog', { name: 'The source post title' });
expect(within(dialog).getByRole('img', { name: 'Source post: The source post title' })).toHaveAttribute(
'src', 'https://i.redd.it/source.jpg',
);
expect(within(dialog).getByRole('link', { name: 'Open Reddit post' })).toHaveAttribute(
'href', 'https://www.reddit.com/r/example/comments/source/the_source_post_title/',
);
});
it('styles the recent-messages retry control when its request fails', async () => {
installSuccessfulApi();
server.use(http.get('/api/messages', () => HttpResponse.json({ error: 'Messages unavailable' }, { status: 503 })));
+56
View File
@@ -15,6 +15,7 @@ interface ChatFixtureOptions {
unread?: boolean;
isFromUs?: boolean;
lastMessageSnippet?: string;
hasReceivedImages?: boolean;
}
function chatFixture(options: ChatFixtureOptions) {
@@ -27,6 +28,7 @@ function chatFixture(options: ChatFixtureOptions) {
lastMessageSnippet: options.lastMessageSnippet ?? `Latest from ${options.username}`,
isFromUs: options.isFromUs ?? false,
unread: options.unread ?? false,
...(options.hasReceivedImages === undefined ? {} : { hasReceivedImages: options.hasReceivedImages }),
};
}
@@ -45,6 +47,7 @@ function detailFixture(chat = alice) {
subreddit: 'casualconversation',
title: 'Looking for a chat',
url: 'https://reddit.com/r/casualconversation/comments/post-1',
imageUrl: 'https://i.redd.it/source-post.jpg',
sentAt: 1_785_999_800_000,
},
syncInProgress: null,
@@ -123,8 +126,59 @@ describe('ConversationsPage', () => {
await user.click(screen.getByRole('button', { name: /u\/alice/ }));
expect(await screen.findByRole('heading', { name: 'u/alice' })).toBeInTheDocument();
expect(screen.getAllByRole('link', { name: 'u/alice' })).not.toHaveLength(0);
for (const profileLink of screen.getAllByRole('link', { name: 'u/alice' })) {
expect(profileLink).toHaveAttribute('href', 'https://www.reddit.com/u/alice');
}
expect(screen.getByText('Good to hear from you')).toBeInTheDocument();
expect(screen.getByRole('link', { name: 'Looking for a chat' })).toHaveAttribute('href', expect.stringContaining('post-1'));
expect(screen.getByRole('img', { name: 'Source post: Looking for a chat' })).toHaveAttribute(
'src',
'https://i.redd.it/source-post.jpg',
);
});
it('marks and filters conversations with received images and renders the images in chat', async () => {
const imageChat = chatFixture({
username: 'alice',
roomId: 'room-alice',
hasReceivedImages: true,
lastMessageSnippet: '[Image]',
});
server.use(
http.get('/api/conversations', () => HttpResponse.json([imageChat, bob])),
http.get('/api/conversations/room-alice', () => HttpResponse.json({
...detailFixture(imageChat),
messages: [
{
sender: 'alice',
text: '[Image]',
timestamp: 1_786_000_000_000,
isSelf: false,
media: [{
originalUrl: 'mxc://reddit.com/image-one',
localPath: 'received image.jpg',
fileName: 'received image.jpg',
}],
},
],
})),
);
const user = userEvent.setup();
renderConversations();
const aliceItem = await screen.findByRole('button', { name: /u\/alice/ });
expect(screen.getByText('Image received')).toBeInTheDocument();
await user.selectOptions(screen.getByRole('combobox', { name: 'Filter conversations' }), 'received-images');
expect(screen.getByRole('button', { name: /u\/alice/ })).toBeInTheDocument();
expect(screen.queryByRole('button', { name: /u\/bob/ })).not.toBeInTheDocument();
await user.click(screen.getByRole('button', { name: /u\/alice/ }));
expect(await screen.findByRole('img', { name: 'Image received from u/alice' })).toHaveAttribute(
'src',
'/media/received%20image.jpg',
);
});
it('turns a suggestion into an editable draft and confirms sending without losing failed input', async () => {
@@ -230,6 +284,8 @@ describe('ConversationsPage', () => {
);
await user.click(screen.getByRole('button', { name: 'Start conversation' }));
expect(await screen.findByRole('status', { name: 'Conversation started' })).toHaveTextContent('Conversation started with u/charlie');
expect(within(screen.getByRole('status', { name: 'Conversation started' })).getByRole('link', { name: 'u/charlie' }))
.toHaveAttribute('href', 'https://www.reddit.com/u/charlie');
expect(username).toHaveValue('');
expect(message).toHaveValue('');
expect(startRequests).toBe(2);
+3
View File
@@ -89,6 +89,9 @@ describe('media page', () => {
const aliceImage = await screen.findByRole('img', { name: 'Media from alice' });
const charlieImage = screen.getByRole('img', { name: 'Media from charlie' });
for (const profileLink of screen.getAllByRole('link', { name: 'alice' })) {
expect(profileLink).toHaveAttribute('href', 'https://www.reddit.com/u/alice');
}
expect(aliceImage).toHaveClass('is-blurred');
expect(charlieImage).toHaveClass('is-blurred');
expect(credentials.every((value) => value === 'same-origin')).toBe(true);
@@ -124,7 +124,9 @@ describe('OperationsPage', () => {
expect(await screen.findByRole('heading', { name: 'Operations' })).toBeInTheDocument();
const activityRegion = screen.getByRole('region', { name: 'Activity' });
expect(await within(activityRegion).findByText('Reply from u/alex')).toBeInTheDocument();
expect(await within(activityRegion).findByRole('link', { name: 'u/alex' })).toBeInTheDocument();
expect(within(activityRegion).getByText((_content, element) => element?.tagName === 'STRONG' && element.textContent === 'Reply from u/alex')).toBeInTheDocument();
expect(within(activityRegion).getByRole('link', { name: 'u/alex' })).toHaveAttribute('href', 'https://www.reddit.com/u/alex');
const jobsRegion = screen.getByRole('region', { name: 'Sync jobs' });
expect(await within(jobsRegion).findByText('4 of 10')).toBeInTheDocument();
for (const name of ['Sync latest chats', 'Full cache sync', 'Fetch all chats']) {
+10
View File
@@ -83,6 +83,16 @@ describe('dashboard realtime client', () => {
type: 'sync.progress',
payload: { syncId: 'sync-1', kind: 'media', completed: 2, total: 5 },
}),
).toEqual([
['operations', 'sync-status'],
]);
expect(
queryKeysForDashboardEvent({
id: 'completed',
occurredAt: '2026-08-08T12:00:01.000Z',
type: 'sync.completed',
payload: { syncId: 'sync-1', kind: 'conversations', processed: 5 },
}),
).toEqual([
['operations', 'sync-status'],
['conversations'],
@@ -0,0 +1,48 @@
// @vitest-environment jsdom
import '@testing-library/jest-dom/vitest';
import { render, screen } from '@testing-library/react';
import { describe, expect, it } from 'vitest';
import { RedditUsernameLink } from '../../apps/dashboard/src/components/reddit-username-link.js';
import { RedditUserText } from '../../apps/dashboard/src/components/reddit-user-text.js';
describe('RedditUsernameLink', () => {
it('normalizes a username and opens its Reddit profile in a new tab', () => {
render(<RedditUsernameLink username="u/example user" />);
expect(screen.getByRole('link', { name: 'u/example user' })).toHaveAttribute(
'href',
'https://www.reddit.com/u/example%20user',
);
expect(screen.getByRole('link', { name: 'u/example user' })).toHaveAttribute('target', '_blank');
expect(screen.getByRole('link', { name: 'u/example user' })).toHaveAttribute('rel', 'noreferrer');
});
it('can preserve an existing unprefixed display label', () => {
render(<RedditUsernameLink showPrefix={false} username="alice" />);
expect(screen.getByRole('link', { name: 'alice' })).toHaveAttribute(
'href',
'https://www.reddit.com/u/alice',
);
});
it('never exposes or links an internal t2 identity', () => {
const view = render(<RedditUsernameLink username="@t2_private123:reddit.com" />);
expect(view.container).not.toHaveTextContent('t2_private123');
expect(view.container.querySelector('a')).toBeNull();
expect(view.container).toHaveTextContent('Unknown Reddit user');
});
it('links username mentions embedded in activity text and redacts raw ids', () => {
render(<RedditUserText>Reply from u/alice after syncing t2_private123</RedditUserText>);
expect(screen.getByRole('link', { name: 'u/alice' })).toHaveAttribute(
'href',
'https://www.reddit.com/u/alice',
);
expect(screen.queryByText(/t2_private123/)).not.toBeInTheDocument();
expect(screen.getByText(/after syncing Reddit user/)).toBeInTheDocument();
});
});
+2 -2
View File
@@ -32,10 +32,10 @@ describe('route-friendly filters', () => {
it('hydrates and writes conversation search and status filters', async () => {
server.use(http.get('/api/conversations', () => HttpResponse.json([])));
const user = userEvent.setup();
renderRoute('/conversations?q=alice&filter=unread', <ConversationRoute />);
renderRoute('/conversations?q=alice&filter=received-images', <ConversationRoute />);
const search = await screen.findByRole('searchbox', { name: 'Search conversations' });
expect(search).toHaveValue('alice');
expect(screen.getByRole('combobox', { name: 'Filter conversations' })).toHaveValue('unread');
expect(screen.getByRole('combobox', { name: 'Filter conversations' })).toHaveValue('received-images');
await user.clear(search);
await user.type(search, 'bob');
expect(screen.getByLabelText('Current URL')).toHaveTextContent('q=bob');
+24
View File
@@ -16,6 +16,7 @@ const config = {
reddit: { prioritizeRecentPosts: false, recentWindowMinutes: 60 },
subreddits: [{ name: 'casualconversation', type: 'chat', weight: 2, tone: 'default' }],
tones: ['default', 'dominant', 'submissive', 'playful', 'direct', 'gentle', 'kink-friendly', 'teasing'],
advanced: { openrouter: { chatModel: 'example/chat-model' } },
};
const server = setupServer();
@@ -228,6 +229,25 @@ describe('settings and access', () => {
expect(updateRequests).toBe(1);
});
it('validates advanced application JSON before saving', async () => {
installConfigApi();
let updateRequests = 0;
server.use(http.put('/api/config', () => {
updateRequests += 1;
return HttpResponse.json({ success: true, config, subreddits: config.subreddits });
}));
const user = userEvent.setup();
renderSettings('/settings?section=behaviour');
const editor = await screen.findByRole('textbox', { name: 'Advanced configuration JSON' });
await user.clear(editor);
await user.type(editor, 'invalid');
await user.click(screen.getByRole('button', { name: 'Save settings' }));
expect(screen.getByText('Advanced configuration is not valid JSON.')).toBeInTheDocument();
expect(updateRequests).toBe(0);
});
it('confirms unusually large hourly limits', async () => {
installConfigApi();
let updateRequests = 0;
@@ -287,6 +307,10 @@ describe('settings and access', () => {
expect(importRequests).toBe(1);
expect(await screen.findByText('Authenticated')).toBeInTheDocument();
expect(screen.getByText(/u\/example-account/)).toBeInTheDocument();
expect(screen.getByRole('link', { name: 'u/example-account' })).toHaveAttribute(
'href',
'https://www.reddit.com/u/example-account',
);
await user.click(screen.getByRole('button', { name: 'Recheck session' }));
expect(statusRequests).toBeGreaterThanOrEqual(2);
+8 -2
View File
@@ -105,16 +105,18 @@ describe('Database Client', () => {
const tables = db
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'")
.all() as { name: string }[];
// processed_posts, sent_messages, successful_messages, activity_log, reddit_users, delivery_outbox, conversation_states, conversation_outcomes
expect(tables.length).toBe(8);
// Eight domain tables plus the singleton application configuration table.
expect(tables.length).toBe(9);
});
it('tracks schema version and migrates a legacy sent_messages table transactionally', () => {
db.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 ('post-1', 'test', 'author', 'title', NULL, NULL, NULL, 1, 0, NULL);
INSERT INTO sent_messages (post_id, recipient, subject, message, sent_at) VALUES ('post-1', 'recipient', 'subject', 'message', 2);
INSERT INTO activity_log (timestamp, type, message, recipient) VALUES (2, 'sent', 'Sent message to u/recipient (r/test)', 'recipient');
`);
runMigrations(db);
@@ -124,6 +126,10 @@ describe('Database Client', () => {
const postId = db.prepare('PRAGMA table_info(sent_messages)').all().find((column: { name: string }) => column.name === 'post_id') as { notnull: number };
expect(postId.notnull).toBe(0);
expect(db.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'delivery_outbox'").get()).toBeDefined();
expect(db.prepare('SELECT post_title, post_url FROM activity_log').get()).toEqual({
post_title: 'title',
post_url: 'https://www.reddit.com/r/test/comments/post-1/',
});
});
it('processed_posts has correct schema', () => {
+61 -2
View File
@@ -1,5 +1,9 @@
import { describe, it, expect } from 'vitest';
import { MatrixClient, MatrixRateLimitError } from '../src/services/chat/matrix/client.js';
import { describe, it, expect, vi } from 'vitest';
import {
MatrixAuthError,
MatrixClient,
MatrixRateLimitError,
} from '../src/services/chat/matrix/client.js';
import { createMockLogger } from './mockLogger.js';
const creds = {
@@ -10,6 +14,31 @@ const creds = {
};
describe('MatrixClient HTTP timeout', () => {
it('loads an encoded Matrix user profile with bearer authentication', async () => {
let requestedUrl = '';
let authorization = '';
const fetchImpl = ((url: string | URL | Request, init?: RequestInit) => {
requestedUrl = String(url);
authorization = new Headers(init?.headers).get('Authorization') ?? '';
return Promise.resolve(new Response(JSON.stringify({ displayname: 'MilkyBabe_10' }), {
status: 200,
headers: { 'content-type': 'application/json' },
}));
}) as typeof fetch;
const client = new MatrixClient(creds, createMockLogger(), {
minRequestIntervalMs: 0,
fetchImpl,
});
await expect(client.getUserProfile('@t2_26q364ua7n:reddit.com')).resolves.toEqual({
displayname: 'MilkyBabe_10',
});
expect(requestedUrl).toBe(
'https://matrix.example.test/_matrix/client/v3/profile/%40t2_26q364ua7n%3Areddit.com',
);
expect(authorization).toBe('Bearer tok');
});
it('aborts a hanging fetch and surfaces a timeout error rather than blocking forever', async () => {
// A fetch impl that never resolves on its own — only the AbortSignal can
// end it. If the client lacked an AbortSignal, this test would hang.
@@ -64,6 +93,36 @@ describe('MatrixClient HTTP timeout', () => {
}, 10_000);
});
describe('MatrixClient authentication classification', () => {
it('does not classify a room-level 403 as an expired account token', async () => {
const client = new MatrixClient(creds, createMockLogger(), {
minRequestIntervalMs: 0,
fetchImpl: vi.fn().mockResolvedValue(new Response('', { status: 403 })),
});
const error = await client.getRoomFullState('!left-room:example.test').catch((cause) => cause);
expect(error).toBeInstanceOf(Error);
expect(error).not.toBeInstanceOf(MatrixAuthError);
expect((error as Error).message).toMatch(/403/);
});
it('still classifies an explicit unknown-token 403 as authentication failure', async () => {
const client = new MatrixClient(creds, createMockLogger(), {
minRequestIntervalMs: 0,
fetchImpl: vi.fn().mockResolvedValue(new Response(JSON.stringify({
errcode: 'M_UNKNOWN_TOKEN',
error: 'Access token has expired',
}), {
status: 403,
headers: { 'content-type': 'application/json' },
})),
});
await expect(client.whoami()).rejects.toBeInstanceOf(MatrixAuthError);
});
});
describe('MatrixClient 429 handling', () => {
it('throws MatrixRateLimitError on a long retry_after rather than sleeping it out', async () => {
const fetchImpl = (() =>
@@ -0,0 +1,25 @@
import { describe, expect, it, vi } from 'vitest';
import { MatrixMediaDownloader } from '../src/services/chat/matrix/http-media-downloader.js';
import { createMockLogger } from './mockLogger.js';
describe('MatrixMediaDownloader', () => {
it('uses replacement Matrix credentials for subsequent media downloads', async () => {
const staleClient = { downloadMedia: vi.fn().mockRejectedValue(new Error('expired')) };
const freshClient = {
downloadMedia: vi.fn().mockResolvedValue({
bytes: Buffer.from('fresh-image'),
contentType: 'image/jpeg',
}),
};
const downloader = new MatrixMediaDownloader(staleClient as never, createMockLogger());
downloader.setMatrixClient(freshClient as never);
await expect(downloader.fetch('mxc://reddit.com/image')).resolves.toEqual({
buffer: Buffer.from('fresh-image'),
contentType: 'image/jpeg',
});
expect(staleClient.downloadMedia).not.toHaveBeenCalled();
expect(freshClient.downloadMedia).toHaveBeenCalledWith('mxc://reddit.com/image');
});
});
+50
View File
@@ -0,0 +1,50 @@
import { describe, expect, it, vi } from 'vitest';
import { MatrixUsernameResolver } from '../src/services/chat/matrix/username-resolver.js';
describe('MatrixUsernameResolver', () => {
it('resolves Reddit t2 IDs from authenticated Matrix profile display names', async () => {
const getUserProfile = vi.fn(async (mxid: string) => {
const profiles: Record<string, { displayname?: string }> = {
'@t2_26q364ua7n:reddit.com': { displayname: 'MilkyBabe_10' },
'@t2_j8e83d38:reddit.com': { displayname: 'IssueLesss' },
};
return profiles[mxid] ?? {};
});
const resolver = new MatrixUsernameResolver({ getUserProfile });
await expect(
resolver.resolveT2Ids(['t2_26q364ua7n', 'j8e83d38']),
).resolves.toEqual({
t2_26q364ua7n: 'MilkyBabe_10',
t2_j8e83d38: 'IssueLesss',
});
expect(getUserProfile).toHaveBeenCalledWith('@t2_26q364ua7n:reddit.com');
expect(getUserProfile).toHaveBeenCalledWith('@t2_j8e83d38:reddit.com');
});
it('ignores missing and t2-shaped display names without failing the batch', async () => {
const getUserProfile = vi.fn(async (mxid: string) => {
if (mxid === '@t2_missing:reddit.com') throw new Error('not found');
return { displayname: 't2_unresolved' };
});
const resolver = new MatrixUsernameResolver({ getUserProfile });
await expect(
resolver.resolveT2Ids(['t2_missing', 't2_unresolved']),
).resolves.toEqual({});
});
it('uses replacement Matrix credentials for subsequent profile requests', async () => {
const staleClient = { getUserProfile: vi.fn().mockRejectedValue(new Error('expired')) };
const freshClient = {
getUserProfile: vi.fn().mockResolvedValue({ displayname: 'alice' }),
};
const resolver = new MatrixUsernameResolver(staleClient);
resolver.setMatrixClient(freshClient);
await expect(resolver.resolveT2Ids(['t2_alice'])).resolves.toEqual({ t2_alice: 'alice' });
expect(staleClient.getUserProfile).not.toHaveBeenCalled();
expect(freshClient.getUserProfile).toHaveBeenCalledWith('@t2_alice:reddit.com');
});
});
+40
View File
@@ -1,5 +1,7 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import axios from 'axios';
import type { AxiosInstance } from 'axios';
import type { BrowserContext } from 'playwright';
import { RedditApiClient } from '../src/reddit/api.js';
import type { RedditPost } from '../src/reddit/api.js';
import { createMockLogger } from '../tests/mockLogger.js';
@@ -394,6 +396,44 @@ describe('RedditApiClient', () => {
});
});
describe('resolveUsernameToT2', () => {
it('uses the imported Reddit session and normalizes the account ID', async () => {
const mockAxios = {
get: vi.fn().mockResolvedValue({ data: { data: { id: 'abc123' } } }),
};
vi.mocked(axios.create).mockReturnValue(mockAxios as unknown as AxiosInstance);
const client2 = new RedditApiClient(getTestConfig(), logger);
const cookies = vi.fn().mockResolvedValue([
{ name: 'reddit_session', value: 'session-value' },
]);
client2.setBrowserContext({ cookies } as unknown as BrowserContext);
await expect(client2.resolveUsernameToT2('alice')).resolves.toBe('t2_abc123');
expect(mockAxios.get).toHaveBeenCalledWith('/user/alice/about.json', {
params: { raw_json: 1 },
headers: { Cookie: 'reddit_session=session-value' },
});
});
it('returns null only when Reddit confirms the user is missing', async () => {
const error = { isAxiosError: true, response: { status: 404 } };
const mockAxios = { get: vi.fn().mockRejectedValue(error) };
vi.mocked(axios.create).mockReturnValue(mockAxios as unknown as AxiosInstance);
const client2 = new RedditApiClient(getTestConfig(), logger);
await expect(client2.resolveUsernameToT2('missing')).resolves.toBeNull();
});
it('does not misclassify a 403 as an unknown user', async () => {
const error = { isAxiosError: true, response: { status: 403 } };
const mockAxios = { get: vi.fn().mockRejectedValue(error) };
vi.mocked(axios.create).mockReturnValue(mockAxios as unknown as AxiosInstance);
const client2 = new RedditApiClient(getTestConfig(), logger);
await expect(client2.resolveUsernameToT2('alice')).rejects.toBe(error);
});
});
describe('getNewPosts', () => {
it('returns empty array for 404 subreddit', async () => {
const error = { isAxiosError: true, response: { status: 404 } };
+8 -2
View File
@@ -27,6 +27,7 @@ describe('PostsRepository', () => {
subreddit: 'test',
author: 'user1',
title: 'Test Post',
postUrl: 'https://www.reddit.com/r/test/comments/post1/test_post/',
imageUrl: 'http://example.com/img.jpg',
processedAt: Date.now(),
});
@@ -37,6 +38,7 @@ describe('PostsRepository', () => {
expect(post!.subreddit).toBe('test');
expect(post!.author).toBe('user1');
expect(post!.title).toBe('Test Post');
expect(post!.postUrl).toBe('https://www.reddit.com/r/test/comments/post1/test_post/');
expect(post!.imageUrl).toBe('http://example.com/img.jpg');
expect(post!.messageSent).toBe(false);
expect(post!.imageAnalysis).toBeNull();
@@ -203,7 +205,7 @@ describe('MessagesRepository', () => {
expect(updated!.gotResponse).toBe(true);
});
it('chat-originated rows appear in getRecent and contribute to stats', () => {
it('keeps unassociated chat history out of performance trends', () => {
messagesRepo.insertChat({
recipient: 'bob',
subject: 'bob',
@@ -213,6 +215,7 @@ describe('MessagesRepository', () => {
});
const recent = messagesRepo.getRecent(10);
expect(recent.some((m) => m.chatRoomId === '!r1:reddit.com')).toBe(true);
expect(messagesRepo.getResponseRateTrend()).toEqual([]);
});
it('inserts and retrieves a message', () => {
@@ -305,19 +308,22 @@ describe('MessagesRepository', () => {
subreddit: 'gonewild',
author: 'alice',
title: 'Second post',
postUrl: 'https://www.reddit.com/r/gonewild/comments/post-b/second_post/',
imageUrl: null,
processedAt: Date.now(),
});
const base = Date.now();
messagesRepo.insert({ postId: 'post-a', recipient: 'alice', subject: 's', message: 'm', sentAt: base });
messagesRepo.insert({ postId: 'post-b', recipient: 'alice', subject: 's', message: 'm', sentAt: base + 1000 });
messagesRepo.insert({ postId: 'post-b', recipient: 'alice', subject: 's', message: 'm', sentAt: base + 1000, chatRoomId: 'room-b' });
const latest = messagesRepo.getLatestByRecipient('alice');
expect(latest).toBeDefined();
expect(latest!.postId).toBe('post-b');
expect(latest!.subreddit).toBe('gonewild');
expect(latest!.postTitle).toBe('Second post');
expect(latest!.postUrl).toBe('https://www.reddit.com/r/gonewild/comments/post-b/second_post/');
expect(messagesRepo.getByChatRoomId('room-b')).toMatchObject({ postId: 'post-b', postTitle: 'Second post' });
expect(messagesRepo.getLatestByRecipient('ALICE')).toBeDefined();
expect(messagesRepo.getLatestByRecipient('nobody')).toBeUndefined();
+33
View File
@@ -161,6 +161,39 @@ describe('ResponseCheckerService', () => {
expect(messagesRepo.markChecked).toHaveBeenCalled();
});
it('does not track an unassociated reply as performance', async () => {
const message: SentMessageRecord = {
id: 1,
postId: null,
recipient: 'user1',
subject: 'Hello',
message: 'Hey!',
sentAt: Date.now() - 2 * 3600000,
gotResponse: false,
responseCheckedAt: null,
strategyId: null,
chatRoomId: '!room:reddit.com',
};
messagesRepo = createMockMessagesRepo([message]);
checker = new ResponseCheckerService(page, messagesRepo, successfulMessagesRepo, postsRepo, logger);
const mockItem = {
$: vi.fn(async (selector: string) => selector.includes('author') || selector.includes('sender')
? { textContent: vi.fn(async () => 'user1') }
: { textContent: vi.fn(async () => 'Hi there') }),
evaluate: vi.fn(async () => true),
};
vi.mocked(page.$$).mockResolvedValue([mockItem as any]);
const results = await checker.checkForResponses();
expect(results).toEqual([
expect.objectContaining({ messageId: 1, gotResponse: true }),
]);
expect(messagesRepo.markGotResponse).not.toHaveBeenCalled();
expect(messagesRepo.markChecked).toHaveBeenCalledWith(1);
expect(successfulMessagesRepo.insert).not.toHaveBeenCalled();
});
it('stores successful messages for learning', async () => {
const post: ProcessedPostRecord = {
id: 'post1',
+180
View File
@@ -0,0 +1,180 @@
import express from 'express';
import type { AddressInfo } from 'node:net';
import { afterEach, describe, expect, it, vi } from 'vitest';
import type { WebContext } from '../../src/web/context.js';
import { createConversationsRouter } from '../../src/web/routes/conversations.js';
const servers: Array<ReturnType<express.Express['listen']>> = [];
async function request(app: express.Express, path: string): Promise<Response> {
const server = app.listen(0);
servers.push(server);
await new Promise<void>((resolve) => server.once('listening', resolve));
const { port } = server.address() as AddressInfo;
return fetch(`http://127.0.0.1:${port}${path}`);
}
afterEach(async () => {
await Promise.all(servers.splice(0).map((server) => new Promise<void>((resolve) => server.close(() => resolve()))));
});
describe('conversations route', () => {
it('reports an unavailable chat service instead of presenting it as an empty inbox', async () => {
const context = {
logger: { error: vi.fn() },
services: {},
} as unknown as WebContext;
const app = express().use('/api', createConversationsRouter(context));
const response = await request(app, '/api/conversations');
expect(response.status).toBe(503);
expect(await response.json()).toEqual({ error: 'Chat service not initialized' });
});
it('reports a cache read failure instead of presenting it as an empty inbox', async () => {
const logger = { error: vi.fn() };
const context = {
logger,
services: {
chat: {
getCachedConversations: vi.fn().mockRejectedValue(new Error('cache unavailable')),
},
},
} as unknown as WebContext;
const app = express().use('/api', createConversationsRouter(context));
const response = await request(app, '/api/conversations');
expect(response.status).toBe(500);
expect(await response.json()).toEqual({ error: 'Failed to fetch conversations' });
expect(logger.error).toHaveBeenCalledWith('Failed to fetch conversations:', expect.any(Error));
});
it('marks conversations that have received images in the durable media index', async () => {
const conversation = {
username: 'alice',
roomName: 'alice',
roomId: 'room-alice',
lastMessageTime: 1_786_000_000_000,
lastMessageSnippet: '[Image]',
isFromUs: false,
unread: true,
};
const context = {
logger: { error: vi.fn() },
services: {
chat: { getCachedConversations: vi.fn().mockResolvedValue([conversation]) },
media: {
getMediaItems: vi.fn().mockResolvedValue([
{
id: 'image-one',
chatUsername: 'alice',
chatRoomId: 'room-alice',
sender: 'alice',
timestamp: 1_786_000_000_000,
originalUrl: 'mxc://reddit.com/image-one',
fileName: 'image-one.jpg',
relativePath: 'image-one.jpg',
createdAt: 1_786_000_000_100,
},
]),
},
},
} as unknown as WebContext;
const app = express().use('/api', createConversationsRouter(context));
const legacyResponse = await request(app, '/api/conversations');
const response = await request(app, '/api/conversations?includeImageMetadata=true');
expect(legacyResponse.status).toBe(200);
expect(await legacyResponse.json()).toEqual([conversation]);
expect(response.status).toBe(200);
expect(await response.json()).toEqual([{ ...conversation, hasReceivedImages: true }]);
});
it('serves conversation details without starting a delta sync', async () => {
const conversation = {
username: 'alice',
roomName: 'alice',
roomId: 'room-alice',
lastMessageTime: 1_786_000_000_000,
lastMessageSnippet: 'Hello',
isFromUs: false,
unread: false,
messages: [{
sender: 'alice',
text: 'Hello',
timestamp: 1_786_000_000_000,
isSelf: false,
}],
};
const refreshTopChats = vi.fn();
const loadConversationDetails = vi.fn();
const context = {
logger: { error: vi.fn(), warn: vi.fn() },
repos: {
messages: { getLatestByRecipient: vi.fn().mockReturnValue(undefined) },
},
services: {
chat: {
getSyncStatus: vi.fn().mockReturnValue({
syncing: false,
syncingKind: null,
progress: null,
}),
getCachedConversations: vi.fn().mockResolvedValue([conversation]),
refreshTopChats,
loadConversationDetails,
saveCacheExternal: vi.fn(),
},
},
} as unknown as WebContext;
const app = express().use('/api', createConversationsRouter(context));
const response = await request(app, '/api/conversations/room-alice');
expect(response.status).toBe(200);
expect((await response.json()).username).toBe('alice');
expect(refreshTopChats).not.toHaveBeenCalled();
expect(loadConversationDetails).not.toHaveBeenCalled();
});
it('attaches the exact room origin with its saved URL, title, and image', async () => {
const conversation = {
username: 'alice', roomName: 'alice', roomId: 'room-alice',
lastMessageTime: 10, lastMessageSnippet: 'Hello', isFromUs: true, unread: false,
messages: [{ sender: 'me', text: 'Hello', timestamp: 10, isSelf: true }],
};
const roomOrigin = {
id: 7, postId: 'post-exact', recipient: 'alice', subject: 'alice', message: 'Hello',
sentAt: 9, gotResponse: false, responseCheckedAt: null, strategyId: null,
chatRoomId: 'room-alice', subreddit: 'example', postTitle: 'Exact source post',
postUrl: 'https://www.reddit.com/r/example/comments/post-exact/exact_source_post/',
postImageUrl: 'https://i.redd.it/exact-source.jpg',
};
const latestRecipientOrigin = { ...roomOrigin, postId: 'wrong-latest-post', postTitle: 'Wrong post' };
const context = {
logger: { error: vi.fn(), warn: vi.fn() },
repos: { messages: {
getByChatRoomId: vi.fn().mockReturnValue(roomOrigin),
getLatestByRecipient: vi.fn().mockReturnValue(latestRecipientOrigin),
} },
services: { chat: {
getSyncStatus: vi.fn().mockReturnValue({ syncing: false, syncingKind: null, progress: null }),
getCachedConversations: vi.fn().mockResolvedValue([conversation]),
loadConversationDetails: vi.fn(), saveCacheExternal: vi.fn(),
} },
} as unknown as WebContext;
const app = express().use('/api', createConversationsRouter(context));
const response = await request(app, '/api/conversations/room-alice');
expect(response.status).toBe(200);
expect((await response.json()).triggeringPost).toEqual({
id: 'post-exact', subreddit: 'example', title: 'Exact source post',
url: roomOrigin.postUrl, imageUrl: roomOrigin.postImageUrl, sentAt: 9,
});
expect(context.repos.messages.getLatestByRecipient).not.toHaveBeenCalled();
});
});
+66
View File
@@ -0,0 +1,66 @@
import express from 'express';
import type { AddressInfo } from 'node:net';
import { afterEach, describe, expect, it, vi } from 'vitest';
import type { WebContext } from '../../src/web/context.js';
import { createMediaRouter } from '../../src/web/routes/media.js';
const servers: Array<ReturnType<express.Express['listen']>> = [];
async function request(app: express.Express, path: string): Promise<Response> {
const server = app.listen(0);
servers.push(server);
await new Promise<void>((resolve) => server.once('listening', resolve));
const { port } = server.address() as AddressInfo;
return fetch(`http://127.0.0.1:${port}${path}`);
}
afterEach(async () => {
await Promise.all(servers.splice(0).map((server) => new Promise<void>((resolve) => server.close(() => resolve()))));
});
describe('media route username presentation', () => {
it('upgrades historical t2 fields through the chat username map', async () => {
const context = {
logger: { error: vi.fn() },
services: {
media: { getMediaItems: vi.fn().mockResolvedValue([{
id: 'one', chatUsername: 't2_known', chatRoomId: 'room-one', sender: 't2_known',
timestamp: 1, originalUrl: 'mxc://one', fileName: 'one.jpg', relativePath: 'one.jpg', createdAt: 2,
}]) },
chat: {
getCachedConversations: vi.fn().mockResolvedValue([]),
resolveSenderDisplay: vi.fn((identity: string) => identity === 't2_known' ? 'alice' : identity),
},
},
} as unknown as WebContext;
const app = express().use('/api', createMediaRouter(context));
const response = await request(app, '/api/media');
expect(response.status).toBe(200);
expect(await response.json()).toEqual([expect.objectContaining({ chatUsername: 'alice', sender: 'alice' })]);
});
it('uses a safe fallback when no username mapping exists', async () => {
const context = {
logger: { error: vi.fn() },
services: {
media: { getMediaItems: vi.fn().mockResolvedValue([{
id: 'one', chatUsername: 't2_missing', chatRoomId: 'room-one', sender: '@t2_missing:reddit.com',
timestamp: 1, originalUrl: 'mxc://one', fileName: 'one.jpg', relativePath: 'one.jpg', createdAt: 2,
}]) },
chat: {
getCachedConversations: vi.fn().mockResolvedValue([]),
resolveSenderDisplay: vi.fn((identity: string) => identity),
},
},
} as unknown as WebContext;
const app = express().use('/api', createMediaRouter(context));
const response = await request(app, '/api/media');
const body = await response.json();
expect(JSON.stringify(body)).not.toContain('t2_missing');
expect(body[0]).toMatchObject({ chatUsername: null, sender: 'Unknown Reddit user' });
});
});
+41
View File
@@ -0,0 +1,41 @@
import { describe, expect, it } from 'vitest';
import { presentActivityItem, resolveRedditUsername } from '../../src/web/reddit-identities.js';
const resolver = {
resolveSenderDisplay(identity: string) {
return identity.replace(/^@?t2_known(?::reddit\.com)?$/, 'alice');
},
};
describe('Reddit identity presentation', () => {
it('resolves internal ids and rejects unresolved ids', () => {
expect(resolveRedditUsername('@t2_known:reddit.com', resolver)).toBe('alice');
expect(resolveRedditUsername('t2_missing', resolver)).toBeNull();
});
it('upgrades both the activity recipient and its persisted message text', () => {
expect(presentActivityItem({
timestamp: 1,
type: 'response',
message: 'Reply from u/t2_known',
recipient: 't2_known',
}, resolver)).toEqual({
timestamp: 1,
type: 'response',
message: 'Reply from u/alice',
recipient: 'alice',
});
});
it('redacts unresolved ids instead of exposing them', () => {
const item = presentActivityItem({
timestamp: 1,
type: 'response',
message: 'Reply from @t2_missing:reddit.com',
recipient: 't2_missing',
}, resolver);
expect(item.message).toBe('Reply from Reddit user');
expect(item.recipient).toBeUndefined();
});
});