Stopping the bot previously halted conversation syncing too. On the
multi-account path the run-flag check sat above both the reply-sync and
response-check branches, so a stop left only MediaSyncService's coarse
30-minute chat refresh. The legacy single-account path did the opposite
and kept syncing while stopped — one boolean meaning two things.
Replace the boolean with an explicit three-way mode:
running — discover, screen, send, automate replies
scan — refresh chats, detect replies, check responses; send nothing
and discover nothing; actionable replies surface as backlog
stopped — everything halts, including background media sync
scan differs from browser.dryRun, which still discovers and pays for AI
screening before suppressing the send.
The mode persists in runtime_state.bot_mode; a migration backfills it
from bot_running, and rows written by an older build still read
correctly. Both loops now gate on the mode, the account runtime loop
reconciles background media sync on transitions only, and the dashboard
and agent API expose all three states.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
506 lines
21 KiB
TypeScript
506 lines
21 KiB
TypeScript
// @vitest-environment jsdom
|
||
|
||
import '@testing-library/jest-dom/vitest';
|
||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||
import { act, cleanup, render, screen, waitFor, within } 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, vi } from 'vitest';
|
||
import { createMemoryRouter, RouterProvider } from 'react-router-dom';
|
||
import { createDashboardRoutes } from '../../apps/dashboard/src/app/router.js';
|
||
|
||
interface TestStatsOverrides {
|
||
isRunning?: boolean;
|
||
mode?: 'running' | 'scan' | 'stopped';
|
||
recentActivity?: readonly unknown[];
|
||
accounts?: readonly unknown[];
|
||
}
|
||
|
||
const server = setupServer();
|
||
|
||
function createStats(overrides: TestStatsOverrides = {}) {
|
||
return {
|
||
isRunning: overrides.isRunning ?? false,
|
||
mode: overrides.mode ?? (overrides.isRunning ? 'running' : 'stopped'),
|
||
dryRunMode: true,
|
||
totalProcessed: 18,
|
||
totalMessagesSent: 12,
|
||
totalResponses: 3,
|
||
responseRate: 25,
|
||
messagesThisHour: 2,
|
||
maxMessagesPerHour: 8,
|
||
subreddits: [{ name: 'casualconversation', type: 'chat' }],
|
||
targetGenders: ['female'],
|
||
recentActivity: overrides.recentActivity ?? [],
|
||
accounts: overrides.accounts ?? [],
|
||
invitationCooldown: null,
|
||
};
|
||
}
|
||
|
||
function installSuccessfulApi(overrides: TestStatsOverrides = {}) {
|
||
server.use(
|
||
http.get('/api/stats', () => HttpResponse.json(createStats(overrides))),
|
||
http.get('/api/timers', () =>
|
||
HttpResponse.json({
|
||
nextMessageAvailableAt: null,
|
||
hourlyResetAt: Date.now() + 3_600_000,
|
||
isHourlyLimitReached: false,
|
||
messagesRemaining: 6,
|
||
}),
|
||
),
|
||
http.get('/api/messages', () => HttpResponse.json([])),
|
||
);
|
||
}
|
||
|
||
function renderDashboard(initialPath = '/') {
|
||
const queryClient = new QueryClient({
|
||
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
||
});
|
||
const router = createMemoryRouter(createDashboardRoutes(), { initialEntries: [initialPath] });
|
||
|
||
render(
|
||
<QueryClientProvider client={queryClient}>
|
||
<RouterProvider router={router} />
|
||
</QueryClientProvider>,
|
||
);
|
||
|
||
return { queryClient, router };
|
||
}
|
||
|
||
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
|
||
afterEach(() => {
|
||
cleanup();
|
||
server.resetHandlers();
|
||
vi.restoreAllMocks();
|
||
});
|
||
afterAll(() => server.close());
|
||
|
||
describe('dashboard foundation', () => {
|
||
it('provides responsive navigation with real routes for every platform area', async () => {
|
||
installSuccessfulApi();
|
||
const user = userEvent.setup();
|
||
const { router } = renderDashboard('/');
|
||
|
||
const navigation = screen.getByRole('navigation', { name: 'Primary navigation' });
|
||
for (const label of ['Overview', 'Needs attention', 'Outreach funnel', 'Conversations', 'Media', 'Performance', 'Operations', 'Settings']) {
|
||
expect(within(navigation).getByRole('link', { name: label })).toBeInTheDocument();
|
||
}
|
||
|
||
await user.click(screen.getByRole('button', { name: 'Open navigation' }));
|
||
expect(screen.getByRole('button', { name: 'Close navigation' })).toHaveAttribute('aria-expanded', 'true');
|
||
|
||
await user.click(within(navigation).getByRole('link', { name: 'Operations' }));
|
||
await waitFor(() => expect(router.state.location.pathname).toBe('/operations'));
|
||
expect(screen.getByRole('heading', { name: 'Operations' })).toBeInTheDocument();
|
||
});
|
||
|
||
it('shows actionable attention items and links handoffs to their conversation', async () => {
|
||
installSuccessfulApi();
|
||
server.use(http.get('/api/attention', () => HttpResponse.json({
|
||
generatedAt: Date.parse('2026-08-09T08:00:00Z'),
|
||
items: [{
|
||
id: 'conversation:one', kind: 'human-required', severity: 'warning',
|
||
title: 'Human follow-up required for u/alice', detail: 'Automated turns are exhausted.',
|
||
occurredAt: Date.parse('2026-08-09T07:00:00Z'),
|
||
owner: { accountId: '@one:reddit.com', redditUsername: 'operator' },
|
||
conversationId: '@one:reddit.com~!room:reddit.com', username: 'alice',
|
||
}],
|
||
})));
|
||
renderDashboard('/attention');
|
||
expect(await screen.findByRole('heading', { name: 'Needs attention' })).toBeInTheDocument();
|
||
expect(screen.getByText('Human follow-up required for u/alice')).toBeInTheDocument();
|
||
expect(screen.getByRole('link', { name: 'Open conversation' })).toHaveAttribute('href', '/conversations/%40one%3Areddit.com~!room%3Areddit.com');
|
||
});
|
||
|
||
it('shows the live outreach stages and the potential posts being burned down', async () => {
|
||
installSuccessfulApi();
|
||
const user = userEvent.setup();
|
||
server.use(
|
||
http.get('/api/login/status', () => HttpResponse.json({ ready: true, loggedIn: true, status: 'authenticated' })),
|
||
http.get('/api/chats/sync', () => HttpResponse.json({
|
||
syncing: false, syncingKind: null, lastFullSyncAt: null, lastDeltaSyncAt: null,
|
||
hasEverFullSynced: false, cachedChats: 0, progress: null,
|
||
})),
|
||
http.get('/api/outreach', () => HttpResponse.json({
|
||
generatedAt: Date.parse('2026-08-09T08:00:00Z'),
|
||
activeCount: 3,
|
||
stages: { discovered: 1, screening: 0, ready: 1, sending: 0, retryable: 1 },
|
||
outcomes: { sent: 12, rejected: 7 },
|
||
oldestActiveAt: Date.parse('2026-08-09T07:00:00Z'),
|
||
candidates: [{
|
||
postId: 'candidate-1', subreddit: 'example', author: 'alice', title: '[25F] A potential post',
|
||
postUrl: 'https://www.reddit.com/r/example/comments/candidate-1/', sourceType: 'image',
|
||
status: 'ready', priority: 2, postCreatedAt: Date.parse('2026-08-09T06:50:00Z'),
|
||
discoveredAt: Date.parse('2026-08-09T07:00:00Z'), updatedAt: Date.parse('2026-08-09T07:30:00Z'),
|
||
availableAt: Date.parse('2026-08-09T07:30:00Z'), attemptCount: 1, hasPreparedMessage: true,
|
||
physiqueScore: 6,
|
||
preparation: {
|
||
message: 'You look incredible in that pose — what inspired this post?',
|
||
imageAnalysis: 'VISIBLE_FACTS:\nBODY: VISIBLE - full-body pose',
|
||
detectedGender: 'female', attractivenessScore: 68, physiqueScore: 6,
|
||
physiqueTargeting: { enabled: true, minScore: 1, maxScore: 6 },
|
||
sourceImageUrl: 'https://i.redd.it/candidate-1.jpg',
|
||
strategyId: 'direct-observation', experimentId: 'strategies:direct-observation:abc123',
|
||
promptVersion: 'abc123', selectionReason: 'exploit', modelId: 'example/image-model',
|
||
toneId: 'direct', scripted: false,
|
||
},
|
||
note: null, owner: null,
|
||
}],
|
||
})),
|
||
);
|
||
|
||
renderDashboard('/outreach');
|
||
|
||
expect(await screen.findByRole('heading', { name: 'Outreach funnel' })).toBeInTheDocument();
|
||
expect(screen.getByRole('heading', { name: '3 active posts' })).toBeInTheDocument();
|
||
expect(screen.getByRole('list', { name: 'Active outreach candidates' })).toHaveTextContent('A potential post');
|
||
expect(screen.getAllByRole('link', { name: '[25F] A potential post' }))
|
||
.toEqual(expect.arrayContaining([
|
||
expect.objectContaining({ href: 'https://www.reddit.com/r/example/comments/candidate-1/' }),
|
||
]));
|
||
expect(within(screen.getByRole('list', { name: 'Active outreach candidates' })).getByText('Message prepared')).toBeInTheDocument();
|
||
const preparation = screen.getByRole('region', { name: 'Prepared outreach for u/alice' });
|
||
expect(preparation).toHaveTextContent('You look incredible in that pose');
|
||
expect(preparation).toHaveTextContent('68/100');
|
||
expect(preparation).toHaveTextContent('6/10');
|
||
expect(preparation).toHaveTextContent('Current target 1–6');
|
||
expect(preparation).toHaveTextContent('example/image-model');
|
||
const sourceImage = within(preparation).getByRole('img', { name: 'Source post: [25F] A potential post' });
|
||
expect(sourceImage).toHaveClass('is-blurred');
|
||
await user.click(within(preparation).getByRole('button', { name: 'Reveal source image: [25F] A potential post' }));
|
||
expect(sourceImage).not.toHaveClass('is-blurred');
|
||
});
|
||
|
||
it('hydrates media filters from the routed URL', async () => {
|
||
installSuccessfulApi();
|
||
server.use(
|
||
http.get('/api/media', () => HttpResponse.json([{
|
||
id: 'media-1',
|
||
chatUsername: 'alice',
|
||
chatRoomId: 'room-1',
|
||
sender: 'alice',
|
||
timestamp: Date.now(),
|
||
originalUrl: 'https://example.test/reaction.gif',
|
||
fileName: 'reaction.gif',
|
||
relativePath: 'reaction.gif',
|
||
createdAt: Date.now(),
|
||
urlPath: '/media/reaction.gif',
|
||
}])),
|
||
http.get('/api/media/sync', () => HttpResponse.json({
|
||
isSyncing: false,
|
||
lastSyncAt: null,
|
||
lastSyncDurationMs: null,
|
||
lastSyncChats: null,
|
||
lastError: null,
|
||
nextSyncAt: null,
|
||
})),
|
||
http.get('/api/login/status', () => HttpResponse.json({
|
||
ready: true,
|
||
loggedIn: true,
|
||
status: 'authenticated',
|
||
})),
|
||
http.get('/api/chats/sync', () => HttpResponse.json({
|
||
syncing: false,
|
||
syncingKind: null,
|
||
lastFullSyncAt: null,
|
||
lastDeltaSyncAt: null,
|
||
hasEverFullSynced: false,
|
||
cachedChats: 0,
|
||
progress: null,
|
||
})),
|
||
);
|
||
|
||
renderDashboard('/media?conversation=alice&type=gif&range=7d');
|
||
|
||
expect(await screen.findByRole('combobox', { name: 'Conversation' })).toHaveValue('alice');
|
||
expect(screen.getByRole('combobox', { name: 'Media type' })).toHaveValue('gif');
|
||
expect(screen.getByRole('combobox', { name: 'Date range' })).toHaveValue('7d');
|
||
});
|
||
|
||
it('shows loading then renders stats, timers, and an explicit empty activity state', async () => {
|
||
let resolveStats: ((response: Response) => void) | undefined;
|
||
server.use(
|
||
http.get('/api/stats', async () =>
|
||
new Promise<Response>((resolve) => {
|
||
resolveStats = resolve;
|
||
}),
|
||
),
|
||
http.get('/api/timers', () =>
|
||
HttpResponse.json({
|
||
nextMessageAvailableAt: null,
|
||
hourlyResetAt: Date.now() + 3_600_000,
|
||
isHourlyLimitReached: false,
|
||
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, gotImage: false, responseCheckedAt: Date.now(), strategyId: null, chatRoomId: null,
|
||
}])),
|
||
);
|
||
|
||
renderDashboard();
|
||
expect(screen.getByRole('status', { name: 'Loading overview' })).toBeInTheDocument();
|
||
|
||
await waitFor(() => expect(resolveStats).toBeTypeOf('function'));
|
||
resolveStats?.(HttpResponse.json(createStats()));
|
||
|
||
expect(await screen.findByText('12', { selector: '[data-metric="messages-sent"]' })).toBeInTheDocument();
|
||
expect(screen.getByText('6 messages remaining')).toBeInTheDocument();
|
||
expect(screen.getByText('No recent activity yet.')).toBeInTheDocument();
|
||
expect(screen.getByText('Dry run')).toBeInTheDocument();
|
||
expect(screen.getByText('No accounts configured.')).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('shows the live sending status and cooldown for every account', async () => {
|
||
const now = Date.now();
|
||
installSuccessfulApi({
|
||
isRunning: true,
|
||
accounts: [
|
||
{
|
||
accountId: '@t2_ready:reddit.com', redditUsername: 'ready-account', runtimeState: 'running',
|
||
authenticated: true, sendingState: 'ready', messagesThisHour: 2, maxMessagesPerHour: 8,
|
||
nextMessageAvailableAt: null, hourlyResetAt: now + 3_600_000, messagesRemaining: 6,
|
||
actionableReplies: 0, invitationCooldown: null, failure: null,
|
||
},
|
||
{
|
||
accountId: '@t2_limited:reddit.com', redditUsername: 'limited-account', runtimeState: 'running',
|
||
authenticated: true, sendingState: 'matrix-cooldown', messagesThisHour: 5, maxMessagesPerHour: 8,
|
||
nextMessageAvailableAt: now + 600_000, hourlyResetAt: now + 3_600_000, messagesRemaining: 3,
|
||
actionableReplies: 1,
|
||
invitationCooldown: {
|
||
until: now + 600_000,
|
||
reason: 'Matrix rate-limited: M_LIMIT_EXCEEDED',
|
||
redditCode: 'rate.invitation_limit_mature_account',
|
||
},
|
||
failure: null,
|
||
},
|
||
],
|
||
});
|
||
|
||
renderDashboard();
|
||
|
||
const panel = await screen.findByRole('region', { name: 'Account status' });
|
||
expect(within(panel).getByText('2 configured')).toBeInTheDocument();
|
||
expect(within(panel).getByRole('article', { name: 'Status for u/ready-account' })).toHaveTextContent('Ready');
|
||
const limited = within(panel).getByRole('article', { name: 'Status for u/limited-account' });
|
||
expect(limited).toHaveTextContent('Matrix cooldown');
|
||
expect(limited).toHaveTextContent('Matrix rate-limited: M_LIMIT_EXCEEDED');
|
||
expect(limited).toHaveTextContent('rate.invitation_limit_mature_account');
|
||
expect(limited).toHaveTextContent('5 of 8');
|
||
expect(limited).toHaveTextContent('Pending replies1');
|
||
});
|
||
|
||
it('keeps stats visible when timers fail and retries only the failed panel', async () => {
|
||
let timerRequests = 0;
|
||
server.use(
|
||
http.get('/api/stats', () => HttpResponse.json(createStats())),
|
||
http.get('/api/timers', () => {
|
||
timerRequests += 1;
|
||
if (timerRequests === 1) return HttpResponse.json({ error: 'Rate limiter not initialized' }, { status: 503 });
|
||
return HttpResponse.json({
|
||
nextMessageAvailableAt: null,
|
||
hourlyResetAt: Date.now() + 3_600_000,
|
||
isHourlyLimitReached: false,
|
||
messagesRemaining: 6,
|
||
});
|
||
}),
|
||
);
|
||
const user = userEvent.setup();
|
||
renderDashboard();
|
||
|
||
expect(await screen.findByText('12', { selector: '[data-metric="messages-sent"]' })).toBeInTheDocument();
|
||
const alert = await screen.findByRole('alert');
|
||
expect(alert).toHaveTextContent('Rate limiter not initialized');
|
||
|
||
await user.click(within(alert).getByRole('button', { name: 'Retry timers' }));
|
||
expect(await screen.findByText('6 messages remaining')).toBeInTheDocument();
|
||
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 })));
|
||
renderDashboard();
|
||
|
||
const retryButton = await screen.findByRole('button', { name: 'Retry messages' });
|
||
expect(retryButton).toHaveClass('button', 'secondary');
|
||
});
|
||
|
||
it('shows refresh progress while preserving the current overview', async () => {
|
||
installSuccessfulApi();
|
||
const user = userEvent.setup();
|
||
renderDashboard();
|
||
|
||
const refreshButton = await screen.findByRole('button', { name: 'Refresh overview' });
|
||
let resolveRefresh: ((response: Response) => void) | undefined;
|
||
server.use(
|
||
http.get('/api/stats', async () =>
|
||
new Promise<Response>((resolve) => {
|
||
resolveRefresh = resolve;
|
||
}),
|
||
),
|
||
);
|
||
|
||
await user.click(refreshButton);
|
||
expect(screen.getByRole('button', { name: 'Refreshing overview' })).toBeDisabled();
|
||
expect(screen.getByText('12', { selector: '[data-metric="messages-sent"]' })).toBeInTheDocument();
|
||
|
||
resolveRefresh?.(HttpResponse.json(createStats()));
|
||
await waitFor(() => expect(screen.getByRole('button', { name: 'Refresh overview' })).toBeEnabled());
|
||
});
|
||
|
||
it('requires confirmation before starting and reflects the authoritative response', async () => {
|
||
installSuccessfulApi();
|
||
let startRequests = 0;
|
||
server.use(
|
||
http.post('/api/mode', () => {
|
||
startRequests += 1;
|
||
return HttpResponse.json({ success: true, running: true, mode: 'running' });
|
||
}),
|
||
);
|
||
const user = userEvent.setup();
|
||
renderDashboard();
|
||
|
||
await user.click(await screen.findByRole('button', { name: 'Start bot' }));
|
||
const dialog = screen.getByRole('dialog', { name: 'Start bot?' });
|
||
expect(dialog).toHaveTextContent('The bot will resume automated processing.');
|
||
|
||
await user.click(within(dialog).getByRole('button', { name: 'Cancel' }));
|
||
expect(startRequests).toBe(0);
|
||
|
||
await user.click(screen.getByRole('button', { name: 'Start bot' }));
|
||
await user.click(within(screen.getByRole('dialog')).getByRole('button', { name: 'Confirm start' }));
|
||
|
||
expect(await screen.findByText('Running', { selector: '[data-bot-status]' })).toBeInTheDocument();
|
||
expect(startRequests).toBe(1);
|
||
});
|
||
|
||
it('offers scan mode alongside start and posts the chosen mode', async () => {
|
||
installSuccessfulApi();
|
||
let requested: string | null = null;
|
||
server.use(
|
||
http.post('/api/mode', async ({ request }) => {
|
||
requested = ((await request.json()) as { mode: string }).mode;
|
||
return HttpResponse.json({ success: true, running: false, mode: 'scan' });
|
||
}),
|
||
);
|
||
const user = userEvent.setup();
|
||
renderDashboard();
|
||
|
||
await user.click(await screen.findByRole('button', { name: 'Scan mode' }));
|
||
const dialog = screen.getByRole('dialog', { name: 'Switch to scan mode?' });
|
||
expect(dialog).toHaveTextContent('nothing is sent');
|
||
|
||
await user.click(within(dialog).getByRole('button', { name: 'Confirm scan mode' }));
|
||
|
||
expect(await screen.findByText('Scanning', { selector: '[data-bot-status]' })).toBeInTheDocument();
|
||
expect(requested).toBe('scan');
|
||
});
|
||
|
||
it('shows a start failure inside the confirmation dialog instead of a stray toast', async () => {
|
||
installSuccessfulApi();
|
||
server.use(
|
||
http.post('/api/mode', () => HttpResponse.json({ error: 'Browser runtime is not ready' }, { status: 503 })),
|
||
);
|
||
const user = userEvent.setup();
|
||
renderDashboard();
|
||
|
||
await user.click(await screen.findByRole('button', { name: 'Start bot' }));
|
||
const dialog = screen.getByRole('dialog', { name: 'Start bot?' });
|
||
await user.click(within(dialog).getByRole('button', { name: 'Confirm start' }));
|
||
|
||
expect(await within(dialog).findByRole('alert')).toHaveTextContent('Browser runtime is not ready');
|
||
// The dialog stays open so the operator can retry or cancel.
|
||
expect(screen.getByRole('dialog', { name: 'Start bot?' })).toBeInTheDocument();
|
||
|
||
await user.click(within(dialog).getByRole('button', { name: 'Cancel' }));
|
||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
|
||
});
|
||
|
||
it('flips the sending window from cooling down to ready without a refetch', async () => {
|
||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||
try {
|
||
let timerRequests = 0;
|
||
const startedAt = Date.now();
|
||
server.use(
|
||
http.get('/api/stats', () => HttpResponse.json(createStats())),
|
||
http.get('/api/timers', () => {
|
||
timerRequests += 1;
|
||
return HttpResponse.json({
|
||
nextMessageAvailableAt: startedAt + 5_000,
|
||
hourlyResetAt: startedAt + 3_600_000,
|
||
isHourlyLimitReached: false,
|
||
messagesRemaining: 6,
|
||
});
|
||
}),
|
||
http.get('/api/messages', () => HttpResponse.json([])),
|
||
);
|
||
renderDashboard();
|
||
|
||
expect(await screen.findByText('Cooling down')).toBeInTheDocument();
|
||
expect(screen.getByText(/^Ready in /)).toBeInTheDocument();
|
||
const requestsAfterLoad = timerRequests;
|
||
|
||
act(() => { vi.advanceTimersByTime(6_000); });
|
||
|
||
expect(screen.getByText('Ready')).toBeInTheDocument();
|
||
expect(screen.getByText('Available now')).toBeInTheDocument();
|
||
expect(screen.queryByText('Cooling down')).not.toBeInTheDocument();
|
||
expect(timerRequests).toBe(requestsAfterLoad);
|
||
} finally {
|
||
vi.useRealTimers();
|
||
}
|
||
});
|
||
});
|