import express from 'express'; import type { AddressInfo } from 'node:net'; import { afterEach, describe, expect, it } from 'vitest'; import { MessagesResponseSchema, 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> = []; async function request(app: express.Express, path: string): Promise { const server = app.listen(0); servers.push(server); await new Promise((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((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; setAt: number } | 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', setAt: now, }); 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, getBotMode: () => 'running', 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' }); expect(stats.accounts[1]?.invitationCooldown).not.toHaveProperty('setAt'); }); }); describe('messages route contract', () => { it('accepts the attribution fields the learning loop adds to sent messages', async () => { const context = { services: {}, repos: { messages: { getRecent: () => [{ id: 1, postId: 'p1', recipient: 'someone', subject: 'hey', message: 'hello there', sentAt: 1_700_000_000_000, gotResponse: true, gotImage: true, responseCheckedAt: 1_700_000_100_000, strategyId: null, chatRoomId: '!room:reddit.com', // Added by the conversation learning loop; must not break the dashboard. matrixEventId: '$opener1', imageOutcomeCredit: 'human-created-success', imageOutcomeAt: 1_700_000_200_000, }], }, }, activity: { recent: () => [], totalProcessed: () => 0 }, getBotRunning: () => true, getBotMode: () => 'running', getMessagesThisHour: () => 0, } as unknown as WebContext; const app = express().use('/api', createStatsRouter(context)); const response = await request(app, '/api/messages'); const messages = MessagesResponseSchema.parse(await response.json()); expect(messages[0]?.matrixEventId).toBe('$opener1'); expect(messages[0]?.imageOutcomeCredit).toBe('human-created-success'); expect(messages[0]?.imageOutcomeAt).toBe(1_700_000_200_000); }); });