import express from 'express'; import type { AddressInfo } from 'node:net'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { OutreachClearResponseSchema, OutreachCandidateDeleteResponseSchema, OutreachFunnelResponseSchema, OutreachMessageUpdateResponseSchema, } 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, init?: RequestInit): 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}`, init); } afterEach(async () => { await Promise.all(servers.splice(0).map((server) => new Promise((resolve) => server.close(() => resolve())))); }); describe('outreach route', () => { it('publishes prepared candidate context without leaking worker lease internals', 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, targetComment: { body: 'I wish that was me', permalink: '/r/example/comments/post-1/hello/comment-1/', sourcePostId: 'post-1', sourcePostAuthor: 'source-author', expressedGender: 'unknown', confidence: 0.94, }, }, prepared: { generatedMessage: 'Prepared opener for Alice', imageAnalysis: 'VISIBLE_FACTS:\nBODY: VISIBLE - full-body pose', detectedGender: 'female', attractivenessScore: 68, physiqueScore: 6, physiqueTargeting: { enabled: true, minScore: 4, maxScore: 7 }, sourceImageUrl: 'https://i.redd.it/post-1.jpg', strategyId: null, experimentId: null, promptVersion: null, selectionReason: null, modelId: 'operator-edited', toneId: 'direct', scripted: false, operatorEdited: true, }, 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 = { runtimeConfig: { reddit: { physiqueTargeting: { enabled: true, minScore: 1, maxScore: 6 } } }, 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/', targetComment: { body: 'I wish that was me', permalink: 'https://www.reddit.com/r/example/comments/post-1/hello/comment-1/', }, owner: { accountId: 'account-a', redditUsername: 'operator' }, preparation: { message: 'Prepared opener for Alice', attractivenessScore: 68, physiqueScore: 6, physiqueTargeting: { enabled: true, minScore: 1, maxScore: 6 }, sourceImageUrl: 'https://i.redd.it/post-1.jpg', strategyId: null, operatorEdited: true, }, }], }); 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' }); }); it('clears unsent candidates, reports retained sends, and records the operator action', async () => { const clearUnsent = vi.fn(() => ({ discovered: 2, screening: 1, ready: 3, retryable: 1, total: 7, retainedSending: 1, })); const activity = { push: vi.fn() }; const broadcaster = { push: vi.fn() }; const context = { services: { candidateQueue: { clearUnsent } }, activity, broadcaster, } as unknown as WebContext; const app = express().use('/api', createOutreachRouter(context)); const response = await request(app, '/api/outreach', { method: 'DELETE' }); const parsed = OutreachClearResponseSchema.parse(await response.json()); expect(response.status).toBe(200); expect(parsed).toEqual({ success: true, cleared: { discovered: 2, screening: 1, ready: 3, retryable: 1, total: 7 }, retainedSending: 1, }); expect(clearUnsent).toHaveBeenCalledOnce(); expect(activity.push).toHaveBeenCalledWith( 'info', 'Cleared 7 unsent outreach candidate(s); retained 1 in-flight send(s)', ); expect(broadcaster.push).toHaveBeenCalledWith( 'warn', 'Cleared 7 unsent outreach candidate(s); retained 1 in-flight send(s)', expect.objectContaining({ total: 7, retainedSending: 1 }), ); }); it('edits a prepared message and removes an individual unsent candidate', async () => { const updatePreparedMessage = vi.fn(() => 'updated' as const); const cancelUnsent = vi.fn(() => 'cancelled' as const); const activity = { push: vi.fn() }; const broadcaster = { push: vi.fn() }; const context = { services: { candidateQueue: { updatePreparedMessage, cancelUnsent } }, activity, broadcaster, } as unknown as WebContext; const app = express().use(express.json()).use('/api', createOutreachRouter(context)); const editResponse = await request(app, '/api/outreach/post%2Fone/message', { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ message: ' A revised opener ' }), }); expect(OutreachMessageUpdateResponseSchema.parse(await editResponse.json())).toEqual({ success: true, postId: 'post/one', message: 'A revised opener', }); expect(updatePreparedMessage).toHaveBeenCalledWith('post/one', 'A revised opener'); const deleteResponse = await request(app, '/api/outreach/post%2Fone', { method: 'DELETE' }); expect(OutreachCandidateDeleteResponseSchema.parse(await deleteResponse.json())).toEqual({ success: true, postId: 'post/one', }); expect(cancelUnsent).toHaveBeenCalledWith('post/one'); expect(activity.push).toHaveBeenCalledTimes(2); }); it('rejects unsafe edits and stale mutations that reached the sending boundary', async () => { const updatePreparedMessage = vi.fn(() => 'not-editable' as const); const cancelUnsent = vi.fn(() => 'not-cancellable' as const); const context = { services: { candidateQueue: { updatePreparedMessage, cancelUnsent } }, activity: { push: vi.fn() }, broadcaster: { push: vi.fn() }, } as unknown as WebContext; const app = express().use(express.json()).use('/api', createOutreachRouter(context)); const unsafe = await request(app, '/api/outreach/p1/message', { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ message: 'We should meet tonight' }), }); expect(unsafe.status).toBe(400); expect(updatePreparedMessage).not.toHaveBeenCalled(); const staleEdit = await request(app, '/api/outreach/p1/message', { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ message: 'A safe revised message' }), }); expect(staleEdit.status).toBe(409); const staleDelete = await request(app, '/api/outreach/p1', { method: 'DELETE' }); expect(staleDelete.status).toBe(409); }); });