Merge remote-tracking branch 'origin/main'
# Conflicts: # src/browser/client.ts # src/services/reddit-session-importer.ts # src/web/routes/conversations.ts
This commit is contained in:
commit
48492d5238
30 files changed
+1041
-181
No files matched your search
@@ -10,7 +10,7 @@ describe('AccountAuthContext', () => {
|
||||
const refreshMatrixCredentials = vi.fn();
|
||||
|
||||
await expect(authenticateCurrentAccount({
|
||||
getRedditUsername: vi.fn().mockResolvedValue(null),
|
||||
getRedditIdentity: vi.fn().mockResolvedValue(null),
|
||||
refreshMatrixCredentials,
|
||||
})).resolves.toBeNull();
|
||||
|
||||
@@ -20,7 +20,7 @@ describe('AccountAuthContext', () => {
|
||||
it('fails closed until Reddit and Matrix resolve to one authenticated account', async () => {
|
||||
const auth = new AccountAuthContext(createMockLogger());
|
||||
const authenticate = vi.fn().mockResolvedValue({
|
||||
accountId: '@t2_account:reddit.com',
|
||||
accountId: 't2_account',
|
||||
redditUsername: 'account',
|
||||
matrixUserId: '@t2_account:reddit.com',
|
||||
});
|
||||
@@ -34,7 +34,7 @@ describe('AccountAuthContext', () => {
|
||||
expect(auth.isFullyAuthenticated()).toBe(true);
|
||||
expect(auth.getSnapshot()).toMatchObject({
|
||||
status: 'authenticated',
|
||||
accountId: '@t2_account:reddit.com',
|
||||
accountId: 't2_account',
|
||||
});
|
||||
});
|
||||
|
||||
@@ -52,7 +52,7 @@ describe('AccountAuthContext', () => {
|
||||
const second = auth.ensureFullyAuthenticated();
|
||||
await vi.waitFor(() => expect(authenticate).toHaveBeenCalledOnce());
|
||||
resolveAuth?.({
|
||||
accountId: '@t2_account:reddit.com',
|
||||
accountId: 't2_account',
|
||||
redditUsername: 'account',
|
||||
matrixUserId: '@t2_account:reddit.com',
|
||||
});
|
||||
@@ -63,7 +63,7 @@ describe('AccountAuthContext', () => {
|
||||
it('invalidates the whole account context when either login layer expires', async () => {
|
||||
const auth = new AccountAuthContext(createMockLogger());
|
||||
auth.markAuthenticated({
|
||||
accountId: '@t2_account:reddit.com',
|
||||
accountId: 't2_account',
|
||||
redditUsername: 'account',
|
||||
matrixUserId: '@t2_account:reddit.com',
|
||||
});
|
||||
@@ -86,7 +86,7 @@ describe('AccountAuthContext', () => {
|
||||
now: () => now,
|
||||
});
|
||||
auth.markAuthenticated({
|
||||
accountId: '@t2_account:reddit.com',
|
||||
accountId: 't2_account',
|
||||
redditUsername: 'account',
|
||||
matrixUserId: '@t2_account:reddit.com',
|
||||
});
|
||||
@@ -115,4 +115,20 @@ describe('AccountAuthContext', () => {
|
||||
await auth.ensureFullyAuthenticated();
|
||||
expect(authenticate).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('fails closed when Reddit and Matrix belong to different accounts', async () => {
|
||||
const auth = new AccountAuthContext(createMockLogger());
|
||||
auth.setAuthenticator(vi.fn().mockResolvedValue({
|
||||
accountId: 't2_cookie',
|
||||
redditUsername: 'cookie-account',
|
||||
matrixUserId: '@t2_chat:reddit.com',
|
||||
}));
|
||||
|
||||
await expect(auth.ensureFullyAuthenticated()).resolves.toBe(false);
|
||||
expect(auth.getSnapshot()).toMatchObject({
|
||||
status: 'login-required',
|
||||
accountId: null,
|
||||
reason: expect.stringMatching(/does not match Matrix account/),
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -36,10 +36,10 @@ describe('BrowserClient', () => {
|
||||
expect(JSON.stringify(logger)).not.toContain('secret');
|
||||
});
|
||||
|
||||
it('reads the username from Reddit\'s nested me.json account response', async () => {
|
||||
it('reads the username and stable account ID from Reddit\'s nested me.json response', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ kind: 't2', data: { name: 'bot-account' } }),
|
||||
json: async () => ({ kind: 't2', data: { name: 'bot-account', id: 'abc123' } }),
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
const page = {
|
||||
@@ -50,6 +50,10 @@ describe('BrowserClient', () => {
|
||||
const client = new BrowserClient(getTestConfig(), createLogger(), runtime);
|
||||
|
||||
await expect(client.getAuthenticatedUsername()).resolves.toBe('bot-account');
|
||||
await expect(client.getAuthenticatedIdentity()).resolves.toEqual({
|
||||
username: 'bot-account',
|
||||
accountId: 't2_abc123',
|
||||
});
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'https://www.reddit.com/api/me.json',
|
||||
{ credentials: 'include' },
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import type { RedditUsersRepository } from '../src/database/repositories.js';
|
||||
import { ChatCache } from '../src/services/chat/cache.js';
|
||||
import { ChatService } from '../src/services/chat/service.js';
|
||||
import { MatrixClient } from '../src/services/chat/matrix/client.js';
|
||||
import { createMockLogger } from './mockLogger.js';
|
||||
import { authenticatedAccountAuth } from './testUtils.js';
|
||||
|
||||
const creds = {
|
||||
homeserver: 'https://matrix.example.test',
|
||||
accessToken: 'tok',
|
||||
userId: '@t2_me:reddit.com',
|
||||
capturedAt: Date.now(),
|
||||
};
|
||||
|
||||
describe('ChatService direct-room repair', () => {
|
||||
it('repairs cached outbound rooms during delta sync', async () => {
|
||||
const logger = createMockLogger();
|
||||
const matrix = new MatrixClient(creds, logger, {
|
||||
minRequestIntervalMs: 0,
|
||||
fetchImpl: vi.fn(),
|
||||
});
|
||||
matrix.sync = vi.fn().mockResolvedValue({ next_batch: 'next', rooms: { join: {} } });
|
||||
matrix.repairDirectRoomMappings = vi.fn().mockResolvedValue(1);
|
||||
|
||||
const users = {
|
||||
getAll: () => [{ t2Id: 't2_alice', username: 'alice' }],
|
||||
getByUsername: () => ({ t2Id: 't2_alice', username: 'alice' }),
|
||||
upsert: vi.fn(),
|
||||
} as unknown as RedditUsersRepository;
|
||||
|
||||
const cache = new ChatCache(
|
||||
logger,
|
||||
path.join(os.tmpdir(), `chat-direct-repair-${Date.now()}-${Math.random()}.json`),
|
||||
);
|
||||
cache.set([{
|
||||
username: 'alice',
|
||||
roomName: 'alice',
|
||||
lastMessageTime: Date.now(),
|
||||
lastMessageSnippet: 'hello',
|
||||
isFromUs: true,
|
||||
unread: false,
|
||||
url: 'https://chat.reddit.com/room/room',
|
||||
roomId: '!room:reddit.com',
|
||||
messages: [],
|
||||
}]);
|
||||
|
||||
const service = new ChatService(
|
||||
matrix,
|
||||
logger,
|
||||
undefined, undefined, undefined, undefined,
|
||||
cache,
|
||||
undefined,
|
||||
users,
|
||||
authenticatedAccountAuth,
|
||||
);
|
||||
|
||||
await service.refreshTopChats();
|
||||
|
||||
expect(matrix.repairDirectRoomMappings).toHaveBeenCalledWith([{
|
||||
recipientMxid: '@t2_alice:reddit.com',
|
||||
roomId: '!room:reddit.com',
|
||||
}]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,96 @@
|
||||
import fs from 'fs/promises';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { ChatCache } from '../src/services/chat/cache.js';
|
||||
import { MatrixClient } from '../src/services/chat/matrix/client.js';
|
||||
import { ChatService } from '../src/services/chat/service.js';
|
||||
import { createMockLogger } from './mockLogger.js';
|
||||
import { authenticatedAccountAuth } from './testUtils.js';
|
||||
|
||||
const temporaryDirectories: string[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(temporaryDirectories.splice(0).map((directory) =>
|
||||
fs.rm(directory, { recursive: true, force: true }),
|
||||
));
|
||||
});
|
||||
|
||||
describe('ChatService text delta sync', () => {
|
||||
it('merges a sent text event into conversation history without a media service', async () => {
|
||||
const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'chat-text-sync-'));
|
||||
temporaryDirectories.push(directory);
|
||||
const cacheFile = path.join(directory, 'chat-cache.json');
|
||||
const now = Date.now();
|
||||
await fs.writeFile(cacheFile, JSON.stringify({
|
||||
version: 1,
|
||||
savedAt: now,
|
||||
chats: [{
|
||||
username: 'alice',
|
||||
roomName: 'alice',
|
||||
roomId: '!alice:example.test',
|
||||
lastMessageTime: now - 1_000,
|
||||
lastMessageSnippet: 'hello',
|
||||
isFromUs: false,
|
||||
unread: false,
|
||||
messages: [{ sender: 'alice', text: 'hello', timestamp: now - 1_000, isSelf: false }],
|
||||
}],
|
||||
nextBatch: 's1',
|
||||
lastDeltaSyncAt: now,
|
||||
}), 'utf-8');
|
||||
|
||||
const logger = createMockLogger();
|
||||
const matrix = new MatrixClient({
|
||||
homeserver: 'https://matrix.example.test',
|
||||
accessToken: 'token',
|
||||
userId: '@me:example.test',
|
||||
capturedAt: now,
|
||||
}, logger, { minRequestIntervalMs: 0 });
|
||||
matrix.sync = vi.fn(async () => ({
|
||||
next_batch: 's2',
|
||||
rooms: {
|
||||
join: {
|
||||
'!alice:example.test': {
|
||||
timeline: {
|
||||
events: [{
|
||||
event_id: '$sent',
|
||||
type: 'm.room.message',
|
||||
sender: '@me:example.test',
|
||||
origin_server_ts: now,
|
||||
content: { msgtype: 'm.text', body: 'AI-assisted reply' },
|
||||
}],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})) as typeof matrix.sync;
|
||||
|
||||
const cache = new ChatCache(logger, cacheFile);
|
||||
const service = new ChatService(
|
||||
matrix,
|
||||
logger,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
cache,
|
||||
undefined,
|
||||
undefined,
|
||||
authenticatedAccountAuth,
|
||||
);
|
||||
await service.loadCache();
|
||||
|
||||
await service.refreshTopChats();
|
||||
|
||||
const [conversation] = await service.getCachedConversations();
|
||||
expect(conversation.messages).toEqual([
|
||||
{ sender: 'alice', text: 'hello', timestamp: now - 1_000, isSelf: false },
|
||||
{ sender: 'Me', text: 'AI-assisted reply', timestamp: now, isSelf: true },
|
||||
]);
|
||||
|
||||
const persisted = JSON.parse(await fs.readFile(cacheFile, 'utf-8')) as {
|
||||
chats: Array<{ messages: unknown[] }>;
|
||||
};
|
||||
expect(persisted.chats[0].messages).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
@@ -182,6 +182,14 @@ describe('ConversationsPage', () => {
|
||||
'src',
|
||||
'/media/received%20image.jpg',
|
||||
);
|
||||
expect(screen.getByRole('link', { name: 'View original image received from u/alice' })).toHaveAttribute(
|
||||
'href',
|
||||
'/media/received%20image.jpg',
|
||||
);
|
||||
expect(screen.getByRole('link', { name: 'View original image received from u/alice' })).toHaveAttribute(
|
||||
'target',
|
||||
'_blank',
|
||||
);
|
||||
});
|
||||
|
||||
it('turns a suggestion into an editable draft and confirms sending without losing failed input', async () => {
|
||||
|
||||
@@ -34,4 +34,37 @@ describe('LogBroadcaster realtime bridge', () => {
|
||||
expect.objectContaining({ timestamp: occurredAt.getTime() }),
|
||||
]);
|
||||
});
|
||||
|
||||
it('sanitizes circular network errors before buffering or publishing them', () => {
|
||||
const hub = new RealtimeEventHub({ capacity: 10 });
|
||||
const received: RealtimeEvent[] = [];
|
||||
hub.subscribe((event) => received.push(event));
|
||||
const broadcaster = new LogBroadcaster(hub);
|
||||
const socket: Record<string, unknown> = { constructorName: 'TLSSocket' };
|
||||
const parser: Record<string, unknown> = { socket };
|
||||
socket.parser = parser;
|
||||
const networkError = Object.assign(new Error('upstream request failed'), {
|
||||
code: 'ECONNRESET',
|
||||
request: socket,
|
||||
});
|
||||
let serializedRecent = '';
|
||||
|
||||
broadcaster.push('error', 'Matrix request failed', {
|
||||
error: networkError,
|
||||
socket,
|
||||
authorization: 'Bearer must-not-leak',
|
||||
});
|
||||
broadcaster.recent(
|
||||
{ query: {} } as never,
|
||||
{ json: (value: unknown) => { serializedRecent = JSON.stringify(value); } } as never,
|
||||
);
|
||||
|
||||
expect(() => JSON.stringify(received)).not.toThrow();
|
||||
expect(() => JSON.parse(serializedRecent)).not.toThrow();
|
||||
expect(serializedRecent).toContain('ECONNRESET');
|
||||
expect(serializedRecent).toContain('[Circular]');
|
||||
expect(serializedRecent).toContain('[Redacted]');
|
||||
expect(serializedRecent).not.toContain('must-not-leak');
|
||||
expect(serializedRecent).not.toContain('"request"');
|
||||
});
|
||||
});
|
||||
@@ -93,6 +93,65 @@ describe('MatrixClient HTTP timeout', () => {
|
||||
}, 10_000);
|
||||
});
|
||||
|
||||
describe('MatrixClient direct room registration', () => {
|
||||
it('creates rooms with the direct-message flag', async () => {
|
||||
let requestBody: unknown;
|
||||
const client = new MatrixClient(creds, createMockLogger(), {
|
||||
minRequestIntervalMs: 0,
|
||||
fetchImpl: vi.fn(async (_url, init) => {
|
||||
requestBody = JSON.parse(String(init?.body));
|
||||
return new Response(JSON.stringify({ room_id: '!new:example.test' }), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
});
|
||||
}),
|
||||
});
|
||||
|
||||
await expect(client.createDirectRoom('@them:example.test'))
|
||||
.resolves.toBe('!new:example.test');
|
||||
expect(requestBody).toEqual({
|
||||
preset: 'reddit_dm',
|
||||
invite: ['@them:example.test'],
|
||||
is_direct: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('additively repairs m.direct account data without removing existing rooms', async () => {
|
||||
const requests: Array<{ method: string; url: string; body?: unknown }> = [];
|
||||
const client = new MatrixClient(creds, createMockLogger(), {
|
||||
minRequestIntervalMs: 0,
|
||||
fetchImpl: vi.fn(async (url, init) => {
|
||||
requests.push({
|
||||
method: init?.method ?? 'GET',
|
||||
url: String(url),
|
||||
...(init?.body ? { body: JSON.parse(String(init.body)) } : {}),
|
||||
});
|
||||
if (init?.method === 'GET') {
|
||||
return new Response(JSON.stringify({
|
||||
'@existing:example.test': ['!old:example.test'],
|
||||
'@them:example.test': ['!already:example.test'],
|
||||
}), { status: 200, headers: { 'content-type': 'application/json' } });
|
||||
}
|
||||
return new Response('{}', { status: 200, headers: { 'content-type': 'application/json' } });
|
||||
}),
|
||||
});
|
||||
|
||||
await expect(client.repairDirectRoomMappings([
|
||||
{ recipientMxid: '@them:example.test', roomId: '!already:example.test' },
|
||||
{ recipientMxid: '@them:example.test', roomId: '!new:example.test' },
|
||||
])).resolves.toBe(1);
|
||||
|
||||
expect(requests).toHaveLength(2);
|
||||
expect(requests[1]).toMatchObject({
|
||||
method: 'PUT',
|
||||
body: {
|
||||
'@existing:example.test': ['!old:example.test'],
|
||||
'@them:example.test': ['!already:example.test', '!new:example.test'],
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('MatrixClient authentication classification', () => {
|
||||
it('does not classify a room-level 403 as an expired account token', async () => {
|
||||
const client = new MatrixClient(creds, createMockLogger(), {
|
||||
|
||||
@@ -105,6 +105,31 @@ describe('MonitorService', () => {
|
||||
expect(messagesRepo.hasSentTo).toHaveBeenCalledWith('already_msgd');
|
||||
});
|
||||
|
||||
it('records seller posts as processed and continues to a clean candidate', async () => {
|
||||
const sellerPost: RedditPost = {
|
||||
id: 't3_seller', subreddit: 'sub1', author: 'seller', title: 'My page',
|
||||
selftext: 'OnlyFans in bio', url: 'http://example.com/1.jpg', permalink: '/r/sub1/1', thumbnail: '',
|
||||
is_self: false, created_utc: 2, over_18: true, spoiler: false,
|
||||
};
|
||||
const cleanPost: RedditPost = {
|
||||
id: 't3_clean', subreddit: 'sub1', author: 'clean', title: 'Hello',
|
||||
url: 'http://example.com/2.jpg', permalink: '/r/sub1/2', thumbnail: '',
|
||||
is_self: false, created_utc: 1, over_18: true, spoiler: false,
|
||||
};
|
||||
const redditApi = createMockRedditApi([sellerPost, cleanPost]);
|
||||
vi.mocked(redditApi.isSellerProfile).mockImplementation(async (username) => username === 'seller');
|
||||
const postsRepo = createMockPostsRepo();
|
||||
const config = getTestConfig({ reddit: { subreddits: [{ name: 'sub1', type: 'image' }] } });
|
||||
monitor = new MonitorService(redditApi, postsRepo, createMockMessagesRepo(), config, logger, 0);
|
||||
|
||||
await expect(monitor.getUnprocessedPosts()).resolves.toEqual([cleanPost]);
|
||||
expect(redditApi.isSellerProfile).toHaveBeenCalledWith(
|
||||
'seller', config.reddit.sellerKeywords, config.reddit.sellerDomains, sellerPost,
|
||||
);
|
||||
expect(postsRepo.insert).toHaveBeenCalledWith(expect.objectContaining({ id: 't3_seller' }));
|
||||
expect(postsRepo.setError).toHaveBeenCalledWith('t3_seller', 'Seller signals detected');
|
||||
});
|
||||
|
||||
it('returns newest post when multiple valid posts exist', async () => {
|
||||
const post1: RedditPost = {
|
||||
id: 't3_1', subreddit: 'sub1', author: 'user1', title: 'Older',
|
||||
@@ -127,6 +152,40 @@ describe('MonitorService', () => {
|
||||
|
||||
expect(result.length).toBe(1);
|
||||
expect(result[0].id).toBe('t3_2');
|
||||
expect(redditApi.isSellerProfile).toHaveBeenCalledTimes(1);
|
||||
expect(redditApi.isSellerProfile).toHaveBeenCalledWith(
|
||||
'user2', config.reddit.sellerKeywords, config.reddit.sellerDomains, post2,
|
||||
);
|
||||
});
|
||||
|
||||
it('finishes the seller and gender checks for one candidate before moving to the next', async () => {
|
||||
const newer: RedditPost = {
|
||||
id: 't3_newer', subreddit: 'chat-sub', author: 'newer', title: '[30M] hello',
|
||||
url: '', permalink: '/r/chat-sub/newer', thumbnail: '', is_self: true,
|
||||
created_utc: 2, over_18: false, spoiler: false,
|
||||
};
|
||||
const eligible: RedditPost = {
|
||||
id: 't3_eligible', subreddit: 'chat-sub', author: 'eligible', title: '[25F] hello',
|
||||
url: '', permalink: '/r/chat-sub/eligible', thumbnail: '', is_self: true,
|
||||
created_utc: 1, over_18: false, spoiler: false,
|
||||
};
|
||||
const redditApi = createMockRedditApi([eligible, newer]);
|
||||
const config = getTestConfig({
|
||||
reddit: {
|
||||
subreddits: [{ name: 'chat-sub', type: 'chat' }],
|
||||
targetGenders: ['female'],
|
||||
},
|
||||
});
|
||||
const postsRepo = createMockPostsRepo();
|
||||
monitor = new MonitorService(
|
||||
redditApi, postsRepo, createMockMessagesRepo(), config, logger, 0,
|
||||
);
|
||||
|
||||
await expect(monitor.getUnprocessedPosts()).resolves.toEqual([eligible]);
|
||||
expect(vi.mocked(redditApi.isSellerProfile).mock.calls.map(([username]) => username))
|
||||
.toEqual(['newer', 'eligible']);
|
||||
expect(postsRepo.insert).toHaveBeenCalledWith(expect.objectContaining({ id: 't3_newer' }));
|
||||
expect(postsRepo.setError).toHaveBeenCalledWith('t3_newer', 'Gender tag "male" not targeted');
|
||||
});
|
||||
|
||||
it('tries next subreddit when first has no valid posts', async () => {
|
||||
|
||||
@@ -242,6 +242,38 @@ describe('RedditApiClient', () => {
|
||||
});
|
||||
|
||||
describe('isSellerProfile', () => {
|
||||
it('returns true for a seller signal in the current post without fetching the profile', async () => {
|
||||
const get = vi.fn();
|
||||
vi.mocked(axios.create).mockReturnValue({ get } as any);
|
||||
const client2 = new RedditApiClient(getTestConfig(), logger);
|
||||
const post = {
|
||||
id: 'post-1', name: 't3_post-1', subreddit: 'test', author: 'testuser',
|
||||
title: 'Come and subscribe', selftext: 'My page is at https://onlyfans.com/example',
|
||||
url: 'https://reddit.com/post-1', permalink: '/r/test/post-1', thumbnail: '',
|
||||
is_self: true, created_utc: 1, over_18: true, spoiler: false,
|
||||
};
|
||||
|
||||
await expect(client2.isSellerProfile('testuser', ['onlyfans'], ['onlyfans.com'], post))
|
||||
.resolves.toBe(true);
|
||||
expect(get).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('detects seller signals in recent profile activity when the bio is clean', async () => {
|
||||
const get = vi.fn()
|
||||
.mockResolvedValueOnce({ data: { data: { subreddit: { public_description: 'Just me' } } } })
|
||||
.mockResolvedValueOnce({
|
||||
data: { data: { children: [{ data: { title: 'New content', subreddit: 'OnlyFansPromotions' } }] } },
|
||||
});
|
||||
vi.mocked(axios.create).mockReturnValue({ get } as any);
|
||||
const client2 = new RedditApiClient(getTestConfig(), logger);
|
||||
|
||||
await expect(client2.isSellerProfile('testuser', ['onlyfans'])).resolves.toBe(true);
|
||||
expect(get).toHaveBeenNthCalledWith(2, '/user/testuser/overview.json', {
|
||||
params: { limit: 25, raw_json: 1 },
|
||||
headers: {},
|
||||
});
|
||||
});
|
||||
|
||||
it('returns true when seller keyword matches', async () => {
|
||||
const mockResponse = {
|
||||
data: {
|
||||
@@ -332,6 +364,18 @@ describe('RedditApiClient', () => {
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('propagates profile rate limits so the main loop backs off', async () => {
|
||||
const error = {
|
||||
isAxiosError: true,
|
||||
response: { status: 429, headers: { 'retry-after': '42' } },
|
||||
};
|
||||
vi.mocked(axios.create).mockReturnValue({ get: vi.fn().mockRejectedValue(error) } as any);
|
||||
|
||||
const client2 = new RedditApiClient(getTestConfig(), logger);
|
||||
await expect(client2.isSellerProfile('testuser', ['onlyfans']))
|
||||
.rejects.toMatchObject({ retryAfterMs: 42_000 });
|
||||
});
|
||||
|
||||
it('returns true when a domain in the bio matches sellerDomains', async () => {
|
||||
const mockResponse = {
|
||||
data: {
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { createSessionExportDownload } from '../extensions/reddit-session-exporter/session-export.js';
|
||||
import {
|
||||
assertMatchingSessionIdentity,
|
||||
createSessionExportDownload,
|
||||
} from '../extensions/reddit-session-exporter/session-export.js';
|
||||
|
||||
describe('createSessionExportDownload', () => {
|
||||
it('creates a JSON data URL that Chrome can download from a service worker', () => {
|
||||
@@ -49,3 +52,19 @@ describe('createSessionExportDownload', () => {
|
||||
expect(() => createSessionExportDownload([], null)).toThrow(/Reddit Chat/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe('assertMatchingSessionIdentity', () => {
|
||||
it('accepts a Matrix user bound to the current Reddit account ID', () => {
|
||||
expect(() => assertMatchingSessionIdentity(
|
||||
{ username: 'bot-account', accountId: 'bot' },
|
||||
'@t2_bot:reddit.com',
|
||||
)).not.toThrow();
|
||||
});
|
||||
|
||||
it('rejects a stale Matrix capture from another Reddit account', () => {
|
||||
expect(() => assertMatchingSessionIdentity(
|
||||
{ username: 'bot-account', accountId: 't2_bot' },
|
||||
'@t2_other:reddit.com',
|
||||
)).toThrow(/Reload Reddit Chat after switching accounts/);
|
||||
});
|
||||
});
|
||||
@@ -18,12 +18,12 @@ describe('RedditSessionImporter', () => {
|
||||
const browser = {
|
||||
importRedditCookies: vi.fn(),
|
||||
isSessionValid: vi.fn(async () => true),
|
||||
getAuthenticatedUsername: vi.fn(async () => 'bot-account'),
|
||||
getAuthenticatedIdentity: vi.fn(async () => ({ username: 'bot-account', accountId: 't2_bot' })),
|
||||
};
|
||||
const matrix = { whoami: vi.fn(async () => credentials.userId) };
|
||||
const chat = { setMatrixClient: vi.fn() };
|
||||
const save = vi.fn();
|
||||
const accountAuth = { markAuthenticated: vi.fn() };
|
||||
const accountAuth = { markAuthenticated: vi.fn(), invalidate: vi.fn() };
|
||||
const importer = new RedditSessionImporter(browser, chat as never, vi.fn(() => matrix as never), save, accountAuth as never);
|
||||
|
||||
await importer.import(cookies, credentials);
|
||||
@@ -33,20 +33,40 @@ describe('RedditSessionImporter', () => {
|
||||
expect(chat.setMatrixClient).toHaveBeenCalledWith(matrix);
|
||||
expect(save).toHaveBeenCalledWith(credentials);
|
||||
expect(accountAuth.markAuthenticated).toHaveBeenCalledWith({
|
||||
accountId: credentials.userId,
|
||||
accountId: 't2_bot',
|
||||
redditUsername: 'bot-account',
|
||||
matrixUserId: credentials.userId,
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects a dump whose Reddit cookies and Matrix token belong to different accounts', async () => {
|
||||
const browser = {
|
||||
importRedditCookies: vi.fn(),
|
||||
isSessionValid: vi.fn(async () => true),
|
||||
getAuthenticatedIdentity: vi.fn(async () => ({ username: 'other-account', accountId: 't2_other' })),
|
||||
};
|
||||
const matrix = { whoami: vi.fn(async () => credentials.userId) };
|
||||
const chat = { setMatrixClient: vi.fn() };
|
||||
const save = vi.fn();
|
||||
const accountAuth = { markAuthenticated: vi.fn(), invalidate: vi.fn() };
|
||||
const importer = new RedditSessionImporter(browser, chat as never, vi.fn(() => matrix as never), save, accountAuth as never);
|
||||
|
||||
await expect(importer.import(cookies, credentials)).rejects.toThrow(/does not match Matrix account/);
|
||||
|
||||
expect(accountAuth.invalidate).toHaveBeenCalledWith(expect.stringMatching(/does not match Matrix account/));
|
||||
expect(save).not.toHaveBeenCalled();
|
||||
expect(chat.setMatrixClient).not.toHaveBeenCalled();
|
||||
expect(accountAuth.markAuthenticated).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects non-Reddit Matrix homeservers without making a request', async () => {
|
||||
const createMatrix = vi.fn();
|
||||
const importer = new RedditSessionImporter(
|
||||
{ importRedditCookies: vi.fn(), isSessionValid: vi.fn(), getAuthenticatedUsername: vi.fn() },
|
||||
{ importRedditCookies: vi.fn(), isSessionValid: vi.fn(), getAuthenticatedIdentity: vi.fn() },
|
||||
{ setMatrixClient: vi.fn() } as never,
|
||||
createMatrix,
|
||||
vi.fn(),
|
||||
{ markAuthenticated: vi.fn() } as never,
|
||||
{ markAuthenticated: vi.fn(), invalidate: vi.fn() } as never,
|
||||
);
|
||||
|
||||
await expect(importer.import(cookies, { ...credentials, homeserver: 'https://attacker.example' }))
|
||||
|
||||
@@ -300,4 +300,48 @@ describe('conversations route', () => {
|
||||
});
|
||||
expect(context.repos.messages.getLatestByRecipient).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('waits for the conversation delta sync after sending a reply', async () => {
|
||||
const conversation = {
|
||||
username: 'alice', roomName: 'alice', roomId: 'room-alice',
|
||||
url: 'https://chat.reddit.com/room/room-alice',
|
||||
lastMessageTime: 10, lastMessageSnippet: 'Hello', isFromUs: false, unread: false,
|
||||
messages: [{ sender: 'alice', text: 'Hello', timestamp: 10, isSelf: false }],
|
||||
};
|
||||
let completeSync: (() => void) | undefined;
|
||||
const refreshTopChats = vi.fn(() => new Promise<void>((resolve) => {
|
||||
completeSync = resolve;
|
||||
}));
|
||||
const context = {
|
||||
logger: { error: vi.fn() },
|
||||
activity: { push: vi.fn() },
|
||||
broadcaster: { push: vi.fn() },
|
||||
services: { chat: {
|
||||
getCachedConversations: vi.fn().mockResolvedValue([conversation]),
|
||||
sendChatMessage: vi.fn().mockResolvedValue(true),
|
||||
refreshTopChats,
|
||||
} },
|
||||
} as unknown as WebContext;
|
||||
const app = express()
|
||||
.use(express.json())
|
||||
.use('/api', createConversationsRouter(context));
|
||||
|
||||
let responseSettled = false;
|
||||
const responsePromise = request(app, '/api/conversations/room-alice/send-reply', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ message: 'AI-assisted reply' }),
|
||||
}).then((response) => {
|
||||
responseSettled = true;
|
||||
return response;
|
||||
});
|
||||
|
||||
await vi.waitFor(() => expect(refreshTopChats).toHaveBeenCalledWith(5));
|
||||
expect(responseSettled).toBe(false);
|
||||
completeSync?.();
|
||||
|
||||
const response = await responsePromise;
|
||||
expect(response.status).toBe(200);
|
||||
expect(await response.json()).toEqual({ success: true });
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user