feat(dashboard): close legacy parity gaps

This commit is contained in:
jjbrunton committed 2026-08-08 15:41:34 +01:00
1 parent 92287c0a9a
commit 4b3f00729f
21 files changed
+397 -28

No files matched your search

+7
View File
@@ -45,6 +45,7 @@ function installSuccessfulApi(overrides: TestStatsOverrides = {}) {
messagesRemaining: 6,
}),
),
http.get('/api/messages', () => HttpResponse.json([])),
);
}
@@ -106,6 +107,10 @@ describe('dashboard foundation', () => {
messagesRemaining: 6,
}),
),
http.get('/api/messages', () => HttpResponse.json([{
id: 1, postId: 'post-1', recipient: 'alice', subject: 'Hello', message: 'A recent opener',
sentAt: Date.now(), gotResponse: true, responseCheckedAt: Date.now(), strategyId: null, chatRoomId: null,
}])),
);
renderDashboard();
@@ -118,6 +123,8 @@ describe('dashboard foundation', () => {
expect(screen.getByText('6 messages remaining')).toBeInTheDocument();
expect(screen.getByText('No recent activity yet.')).toBeInTheDocument();
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');
});
it('keeps stats visible when timers fail and retries only the failed panel', async () => {
+21
View File
@@ -178,6 +178,27 @@ describe('ConversationsPage', () => {
expect(sendRequests).toBe(2);
});
it('generates a media ask and looks up durable processed-user history', async () => {
installConversationList();
server.use(
http.post('/api/conversations/room-alice/suggest-media-ask', () => HttpResponse.json({ suggestedReply: 'Would you like to share a photo?' })),
http.get('/api/posts/by-author/alice', () => HttpResponse.json([{
id: 'post-1', subreddit: 'askuk', author: 'alice', title: 'A processed post',
imageUrl: null, imageAnalysis: null, generatedMessage: 'Hello',
processedAt: Date.now(), messageSent: true, error: null,
}])),
);
const user = userEvent.setup();
renderConversations();
await user.type(screen.getByLabelText('Lookup Reddit username'), 'u/alice');
await user.click(screen.getByRole('button', { name: 'Look up user' }));
expect(await screen.findByRole('list', { name: 'Processed posts for u/alice' })).toHaveTextContent('A processed post');
await user.click(screen.getByRole('button', { name: /u\/alice/ }));
await user.click(await screen.findByRole('button', { name: 'Suggest media ask' }));
expect(await screen.findByRole('textbox', { name: 'Reply to u/alice' })).toHaveValue('Would you like to share a photo?');
});
it('starts a conversation and preserves both fields after a recoverable failure', async () => {
installConversationList();
let startRequests = 0;
@@ -10,6 +10,22 @@ function entry(id: string, level: 'debug' | 'info' | 'warn' | 'error', message:
}
describe('live console state', () => {
it('clears retained and paused entries without changing local controls', () => {
const initial = {
...createLiveConsoleState(5),
entries: [entry('one', 'info', 'one')],
pendingEntries: [entry('two', 'warn', 'two')],
isPaused: true,
search: 'needle',
};
expect(reduceLiveConsoleState(initial, { type: 'clear' })).toEqual({
...initial,
entries: [],
pendingEntries: [],
});
});
it('bounds entries, rejects duplicate IDs, and filters by level and search', () => {
let state = createLiveConsoleState(2);
state = reduceLiveConsoleState(state, { type: 'append', entry: entry('1', 'info', 'Sync started') });
@@ -7,7 +7,7 @@ import userEvent from '@testing-library/user-event';
import { HttpResponse, http } from 'msw';
import { setupServer } from 'msw/node';
import type { PropsWithChildren, ReactElement } from 'react';
import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest';
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
import { OperationsPage } from '../../apps/dashboard/src/features/operations/operations-page.js';
import { PerformancePage } from '../../apps/dashboard/src/features/performance/performance-page.js';
@@ -139,5 +139,35 @@ describe('OperationsPage', () => {
expect(consoleRegion).toHaveTextContent('No logs match the current filters.');
expect(within(consoleRegion).getByRole('button', { name: 'Pause console' })).toHaveAttribute('aria-pressed', 'false');
expect(within(consoleRegion).getByLabelText('Auto-scroll')).toBeChecked();
await user.clear(within(consoleRegion).getByLabelText('Search logs'));
await user.click(within(consoleRegion).getByRole('button', { name: 'Clear local console' }));
expect(consoleRegion).toHaveTextContent('Server logs were not deleted');
});
it('starts delta sync directly and confirms expensive chat cache operations', async () => {
let deltaCalls = 0;
let fullCalls = 0;
server.use(
http.get('/api/activity', () => HttpResponse.json([])),
http.get('/api/logs', () => HttpResponse.json([])),
http.get('/api/chats/sync', () => HttpResponse.json({ syncing: false, syncingKind: null, lastFullSyncAt: null, lastDeltaSyncAt: null, hasEverFullSynced: false, cachedChats: 2, progress: null })),
http.post('/api/chats/sync/delta', () => { deltaCalls += 1; return HttpResponse.json({ success: true, kind: 'delta' }); }),
http.post('/api/chats/sync/full', () => { fullCalls += 1; return HttpResponse.json({ success: true, kind: 'full' }); }),
http.post('/api/chats/fetch-all', () => HttpResponse.json({ success: true, count: 7 })),
);
const confirmSpy = vi.spyOn(globalThis, 'confirm').mockReturnValue(false);
const user = userEvent.setup();
renderPage(<OperationsPage realtimeEnabled={false} />);
await user.click(await screen.findByRole('button', { name: 'Sync latest chats' }));
expect(await screen.findByText(/Delta sync started/)).toBeInTheDocument();
expect(deltaCalls).toBe(1);
await user.click(screen.getByRole('button', { name: 'Full cache sync' }));
expect(fullCalls).toBe(0);
confirmSpy.mockReturnValue(true);
await user.click(screen.getByRole('button', { name: 'Full cache sync' }));
expect(await screen.findByText(/Full sync started/)).toBeInTheDocument();
expect(fullCalls).toBe(1);
await user.click(screen.getByRole('button', { name: 'Fetch all chats' }));
expect(await screen.findByText('Fetched 7 chats.')).toBeInTheDocument();
});
});
+55
View File
@@ -0,0 +1,55 @@
// @vitest-environment jsdom
import '@testing-library/jest-dom/vitest';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { cleanup, render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { HttpResponse, http } from 'msw';
import { setupServer } from 'msw/node';
import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest';
import type { ReactNode } from 'react';
import { MemoryRouter, Route, Routes, useLocation } from 'react-router-dom';
import { ConversationRoute } from '../../apps/dashboard/src/features/conversations/conversation-route.js';
import { MediaRoute } from '../../apps/dashboard/src/features/media/media-route.js';
const server = setupServer();
function LocationProbe() {
const location = useLocation();
return <output aria-label="Current URL">{location.pathname}{location.search}</output>;
}
function renderRoute(path: string, element: ReactNode) {
const client = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } });
render(<QueryClientProvider client={client}><MemoryRouter initialEntries={[path]}><LocationProbe /><Routes><Route path="*" element={element} /></Routes></MemoryRouter></QueryClientProvider>);
}
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
afterEach(() => { cleanup(); server.resetHandlers(); });
afterAll(() => server.close());
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 />);
const search = await screen.findByRole('searchbox', { name: 'Search conversations' });
expect(search).toHaveValue('alice');
expect(screen.getByRole('combobox', { name: 'Filter conversations' })).toHaveValue('unread');
await user.clear(search);
await user.type(search, 'bob');
expect(screen.getByLabelText('Current URL')).toHaveTextContent('q=bob');
});
it('hydrates and writes media filters', async () => {
server.use(
http.get('/api/media', () => HttpResponse.json([{ id: 'one', chatUsername: 'alice', chatRoomId: 'room-a', sender: 'alice', timestamp: Date.now(), originalUrl: 'https://example.test/a.gif', fileName: 'a.gif', relativePath: 'a.gif', createdAt: Date.now(), urlPath: '/media/a.gif' }])),
http.get('/api/media/sync', () => HttpResponse.json({ isSyncing: false, lastSyncAt: null, lastSyncDurationMs: null, lastSyncChats: null, lastError: null, nextSyncAt: null })),
);
const user = userEvent.setup();
renderRoute('/media?conversation=alice&type=gif', <MediaRoute />);
expect(await screen.findByRole('combobox', { name: 'Conversation' })).toHaveValue('alice');
await user.selectOptions(screen.getByRole('combobox', { name: 'Date range' }), '7d');
expect(screen.getByLabelText('Current URL')).toHaveTextContent('range=7d');
});
});