105 lines
4.3 KiB
TypeScript
105 lines
4.3 KiB
TypeScript
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<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('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,
|
|
},
|
|
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: 'direct-observation',
|
|
experimentId: 'strategies:direct-observation:abc123',
|
|
promptVersion: 'abc123',
|
|
selectionReason: 'exploit',
|
|
modelId: 'example/image-model',
|
|
toneId: 'direct',
|
|
scripted: false,
|
|
}, 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' },
|
|
preparation: {
|
|
message: 'Prepared opener for Alice',
|
|
attractivenessScore: 68,
|
|
physiqueScore: 6,
|
|
physiqueTargeting: { enabled: true, minScore: 4, maxScore: 7 },
|
|
sourceImageUrl: 'https://i.redd.it/post-1.jpg',
|
|
strategyId: 'direct-observation',
|
|
},
|
|
}],
|
|
});
|
|
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' });
|
|
});
|
|
});
|