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
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user