Show per-account delivery status
This commit is contained in:
1 parent
9b86cee9be
commit
22eb32e5bf
14 files changed
+402
-13
No files matched your search
@@ -13,6 +13,7 @@ import { createDashboardRoutes } from '../../apps/dashboard/src/app/router.js';
|
||||
interface TestStatsOverrides {
|
||||
isRunning?: boolean;
|
||||
recentActivity?: readonly unknown[];
|
||||
accounts?: readonly unknown[];
|
||||
}
|
||||
|
||||
const server = setupServer();
|
||||
@@ -30,6 +31,7 @@ function createStats(overrides: TestStatsOverrides = {}) {
|
||||
subreddits: [{ name: 'casualconversation', type: 'chat' }],
|
||||
targetGenders: ['female'],
|
||||
recentActivity: overrides.recentActivity ?? [],
|
||||
accounts: overrides.accounts ?? [],
|
||||
invitationCooldown: null,
|
||||
};
|
||||
}
|
||||
@@ -224,11 +226,51 @@ 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(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(
|
||||
|
||||
@@ -132,7 +132,12 @@ describe('OperationsPage', () => {
|
||||
expect(new URL(request.url).searchParams.get('limit')).toBe('200');
|
||||
return HttpResponse.json([
|
||||
{ timestamp: Date.parse('2026-08-08T12:00:01Z'), level: 'info', message: 'Sync complete' },
|
||||
{ timestamp: Date.parse('2026-08-08T12:00:02Z'), level: 'error', message: 'Matrix request failed' },
|
||||
{
|
||||
timestamp: Date.parse('2026-08-08T12:00:02Z'),
|
||||
level: 'error',
|
||||
message: 'Matrix request failed: M_LIMIT_EXCEEDED',
|
||||
data: { accountId: '@t2_owner:reddit.com', redditUsername: 'operator' },
|
||||
},
|
||||
]);
|
||||
}),
|
||||
http.get('/api/chats/sync', () =>
|
||||
@@ -171,10 +176,14 @@ describe('OperationsPage', () => {
|
||||
|
||||
const consoleRegion = screen.getByRole('region', { name: 'Live console' });
|
||||
expect(consoleRegion).toHaveTextContent('Sync complete');
|
||||
expect(consoleRegion).toHaveTextContent('Matrix request failed');
|
||||
expect(consoleRegion).toHaveTextContent('Matrix request failed: M_LIMIT_EXCEEDED');
|
||||
expect(within(consoleRegion).getByRole('link', { name: 'u/operator' })).toHaveAttribute(
|
||||
'href',
|
||||
'https://www.reddit.com/u/operator',
|
||||
);
|
||||
await user.selectOptions(within(consoleRegion).getByLabelText('Log level'), 'error');
|
||||
expect(consoleRegion).not.toHaveTextContent('Sync complete');
|
||||
expect(consoleRegion).toHaveTextContent('Matrix request failed');
|
||||
expect(consoleRegion).toHaveTextContent('Matrix request failed: M_LIMIT_EXCEEDED');
|
||||
await user.type(within(consoleRegion).getByLabelText('Search logs'), 'no match');
|
||||
expect(consoleRegion).toHaveTextContent('No logs match the current filters.');
|
||||
expect(within(consoleRegion).getByRole('button', { name: 'Pause console' })).toHaveAttribute('aria-pressed', 'false');
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import winston from 'winston';
|
||||
import { RealtimeEventHub, type RealtimeEvent } from '../src/web/events/index.js';
|
||||
import { LogBroadcaster } from '../src/web/log-broadcaster.js';
|
||||
import { attachLogSink } from '../src/utils/logger.js';
|
||||
|
||||
describe('LogBroadcaster realtime bridge', () => {
|
||||
it('publishes each log through the typed event hub', () => {
|
||||
@@ -67,4 +69,27 @@ describe('LogBroadcaster realtime bridge', () => {
|
||||
expect(serializedRecent).not.toContain('must-not-leak');
|
||||
expect(serializedRecent).not.toContain('"request"');
|
||||
});
|
||||
|
||||
it('preserves account metadata from a child logger', async () => {
|
||||
const broadcaster = new LogBroadcaster();
|
||||
const logger = winston.createLogger({ level: 'debug' });
|
||||
attachLogSink(logger, broadcaster);
|
||||
|
||||
logger.child({
|
||||
accountId: '@t2_owner:reddit.com',
|
||||
redditUsername: 'operator',
|
||||
}).warn('Matrix request failed: M_LIMIT_EXCEEDED');
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
|
||||
expect(broadcaster.getRecent()).toEqual([
|
||||
expect.objectContaining({
|
||||
level: 'warn',
|
||||
message: 'Matrix request failed: M_LIMIT_EXCEEDED',
|
||||
data: {
|
||||
accountId: '@t2_owner:reddit.com',
|
||||
redditUsername: 'operator',
|
||||
},
|
||||
}),
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
import express from 'express';
|
||||
import type { AddressInfo } from 'node:net';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { StatsResponseSchema } from '../../packages/api-contracts/src/index.js';
|
||||
import type { WebContext } from '../../src/web/context.js';
|
||||
import { createStatsRouter } from '../../src/web/routes/stats.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('stats route account status', () => {
|
||||
it('reports sending availability and Matrix cooldown independently for every account', async () => {
|
||||
const now = Date.now();
|
||||
const account = (
|
||||
accountId: string,
|
||||
redditUsername: string,
|
||||
cooldown: { until: number; reason: string; redditCode: string | null } | null,
|
||||
) => ({
|
||||
session: { accountId, redditUsername },
|
||||
resources: {
|
||||
accountAuth: { isFullyAuthenticated: () => true },
|
||||
chatService: { getInvitationCooldown: () => cooldown },
|
||||
rateLimiter: {
|
||||
getStats: () => ({ messagesThisHour: cooldown ? 5 : 2, minutesSinceLastMessage: 1 }),
|
||||
getTimers: () => ({
|
||||
nextMessageAvailableAt: cooldown?.until ?? null,
|
||||
hourlyResetAt: now + 3_600_000,
|
||||
isHourlyLimitReached: false,
|
||||
messagesRemaining: cooldown ? 3 : 6,
|
||||
}),
|
||||
},
|
||||
conversationAutomation: { getPriorityStatus: () => ({ processing: false, actionableBacklog: 0 }) },
|
||||
messagesRepo: { getRecent: () => [] },
|
||||
activityLogRepo: { getRecent: () => [], countSentSkipped: () => 0 },
|
||||
},
|
||||
});
|
||||
const ready = account('@t2_ready:reddit.com', 'ready-account', null);
|
||||
const limited = account('@t2_limited:reddit.com', 'limited-account', {
|
||||
until: now + 600_000,
|
||||
reason: 'Matrix rate-limited: M_LIMIT_EXCEEDED',
|
||||
redditCode: 'rate.invitation_limit_mature_account',
|
||||
});
|
||||
const runtimes = new Map([[ready.session.accountId, ready], [limited.session.accountId, limited]]);
|
||||
const context = {
|
||||
config: {
|
||||
browser: { dryRun: false },
|
||||
rateLimit: { maxMessagesPerHour: 8 },
|
||||
reddit: { subreddits: [], targetGenders: [] },
|
||||
},
|
||||
services: {
|
||||
redditAccounts: { list: () => [...runtimes.values()].map((runtime) => runtime.session) },
|
||||
accountRuntimes: {
|
||||
list: () => [...runtimes.values()].map((runtime) => ({
|
||||
...runtime.session,
|
||||
matrixUserId: runtime.session.accountId,
|
||||
state: 'running',
|
||||
updatedAt: now,
|
||||
failure: null,
|
||||
})),
|
||||
get: (accountId: string) => runtimes.get(accountId) ?? null,
|
||||
},
|
||||
},
|
||||
repos: { messages: { getRecent: () => [] } },
|
||||
activity: { recent: () => [], totalProcessed: () => 0 },
|
||||
getBotRunning: () => true,
|
||||
getMessagesThisHour: () => 0,
|
||||
} as unknown as WebContext;
|
||||
const app = express().use('/api', createStatsRouter(context));
|
||||
|
||||
const response = await request(app, '/api/stats');
|
||||
const stats = StatsResponseSchema.parse(await response.json());
|
||||
|
||||
expect(stats.accounts).toEqual([
|
||||
expect.objectContaining({ redditUsername: 'ready-account', sendingState: 'ready', messagesThisHour: 2 }),
|
||||
expect.objectContaining({
|
||||
redditUsername: 'limited-account',
|
||||
sendingState: 'matrix-cooldown',
|
||||
invitationCooldown: expect.objectContaining({ reason: 'Matrix rate-limited: M_LIMIT_EXCEEDED' }),
|
||||
}),
|
||||
]);
|
||||
expect(stats.invitationCooldown).toMatchObject({ redditUsername: 'limited-account' });
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user