import express from 'express'; import type { AddressInfo } from 'node:net'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { OutreachFunnelResponseSchema } from '../../packages/api-contracts/src/index.js'; import type { WebContext } from '../../src/web/context.js'; import { createOutreachRouter } from '../../src/web/routes/outreach.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('outreach route', () => { it('publishes active candidates and queue outcomes without prepared message content', async () => { const candidateQueue = { getStats: vi.fn(() => ({ total: 9, byStatus: { discovered: 2, screening: 1, ready: 1, sending: 1, retryable: 1, sent: 2, rejected: 1 }, ready: 1, oldestReadyAt: 2_000, rejectionReasons: [], })), getOldestActiveAt: vi.fn(() => 1_000), listActive: vi.fn(() => [{ postId: 'post-1', subreddit: 'example', author: 'alice', sourceType: 'image', post: { id: 'post-1', subreddit: 'example', author: 'alice', author_fullname: 't2_alice', title: '[25F] Hello', url: 'https://i.redd.it/post-1.jpg', permalink: '/r/example/comments/post-1/hello/', thumbnail: '', is_self: false, created_utc: 100, over_18: true, spoiler: false, }, prepared: { generatedMessage: 'private prepared text' }, priority: 2, status: 'sending', discoveredAt: 1_000, updatedAt: 2_000, availableAt: 2_000, leaseOwner: 'worker-secret', leaseExpiresAt: 3_000, assignedAccountId: 'account-a', attemptCount: 2, lastError: null, }]), }; const context = { services: { candidateQueue, redditAccounts: { list: () => [{ accountId: 'account-a', redditUsername: 'operator' }] }, }, } as unknown as WebContext; const app = express().use('/api', createOutreachRouter(context)); const response = await request(app, '/api/outreach'); const body: unknown = await response.json(); const parsed = OutreachFunnelResponseSchema.parse(body); expect(response.status).toBe(200); expect(parsed).toMatchObject({ activeCount: 6, stages: { discovered: 2, screening: 1, ready: 1, sending: 1, retryable: 1 }, outcomes: { sent: 2, rejected: 1 }, candidates: [{ postId: 'post-1', status: 'sending', hasPreparedMessage: true, postUrl: 'https://www.reddit.com/r/example/comments/post-1/hello/', owner: { accountId: 'account-a', redditUsername: 'operator' }, }], }); expect(JSON.stringify(body)).not.toContain('private prepared text'); expect(JSON.stringify(body)).not.toContain('worker-secret'); }); it('reports an unavailable queue instead of an empty funnel', async () => { const context = { services: {} } as unknown as WebContext; const app = express().use('/api', createOutreachRouter(context)); const response = await request(app, '/api/outreach'); expect(response.status).toBe(503); expect(await response.json()).toEqual({ error: 'Outreach queue not initialized' }); }); });