feat: persist settings and enrich conversation context
This commit is contained in:
1 parent
a36723a325
commit
2c100d72b6
84 files changed
+2248
-644
No files matched your search
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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 })));
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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']) {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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 } };
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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' });
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user