Files
RedditBot/tests/web/outreach-route.test.ts
T

208 lines
8.9 KiB
TypeScript

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<ReturnType<express.Express['listen']>> = [];
async function request(app: express.Express, path: string, init?: RequestInit): 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}`, init);
}
afterEach(async () => {
await Promise.all(servers.splice(0).map((server) => new Promise<void>((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: [{ reason: 'Seller signals detected', count: 1 }],
})),
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 },
rejectionReasons: [{ reason: 'Seller signals detected', count: 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);
});
});