Add manual outreach funnel clearing
This commit is contained in:
1 parent
6b2e1bc380
commit
4798385d8b
16 files changed
+319
-17
No files matched your search
@@ -164,6 +164,39 @@ describe('CandidateQueueRepository', () => {
|
||||
expect(queue.getStats().byStatus.discovered).toBe(1);
|
||||
});
|
||||
|
||||
it('manually clears every unsent stage while retaining an in-flight send', () => {
|
||||
queue.enqueue([post('sending', 'alice')], 'image', 1, 1_000);
|
||||
const sendingScreen = queue.claimForScreening('screen-sending', 1_000, 1_001)!;
|
||||
queue.markReady(sendingScreen.postId, 'screen-sending', prepared(sendingScreen.post), 1_002);
|
||||
queue.claimReady('account-a', 'send-worker', 1_000, 1_003);
|
||||
|
||||
queue.enqueue([post('retryable', 'bob')], 'image', 1, 1_004);
|
||||
const retryScreen = queue.claimForScreening('screen-retry', 1_000, 1_005)!;
|
||||
queue.markReady(retryScreen.postId, 'screen-retry', prepared(retryScreen.post), 1_006);
|
||||
queue.claimReady('account-b', 'retry-worker', 1_000, 1_007);
|
||||
queue.deferDelivery('retryable', 'retry-worker', 'rate limited', 2_000, 1_008);
|
||||
|
||||
queue.enqueue([post('ready', 'carol')], 'image', 1, 1_009);
|
||||
const readyScreen = queue.claimForScreening('screen-ready', 1_000, 1_010)!;
|
||||
queue.markReady(readyScreen.postId, 'screen-ready', prepared(readyScreen.post), 1_011);
|
||||
queue.enqueue([post('screening', 'dana')], 'image', 1, 1_012);
|
||||
queue.claimForScreening('screen-active', 1_000, 1_013);
|
||||
queue.enqueue([post('discovered', 'erin')], 'image', 1, 1_014);
|
||||
|
||||
expect(queue.clearUnsent(1_020)).toEqual({
|
||||
discovered: 1, screening: 1, ready: 1, retryable: 1, total: 4, retainedSending: 1,
|
||||
});
|
||||
expect(queue.listActive()).toEqual([
|
||||
expect.objectContaining({ postId: 'sending', status: 'sending', leaseOwner: 'send-worker' }),
|
||||
]);
|
||||
expect(queue.markReady('screening', 'screen-active', prepared(post('screening', 'dana')), 1_021)).toBe(false);
|
||||
expect(queue.markSent('sending', 'send-worker', 1_022)).toBe(true);
|
||||
expect(queue.getStats()).toMatchObject({ byStatus: { rejected: 4, sent: 1 } });
|
||||
const reasons = db.prepare(`SELECT DISTINCT last_error FROM candidate_queue WHERE status = 'rejected'`)
|
||||
.all() as Array<{ last_error: string }>;
|
||||
expect(reasons).toEqual([{ last_error: 'Cleared manually by operator' }]);
|
||||
});
|
||||
|
||||
it('retries a rate-limited prepared candidate and preserves its generated message', () => {
|
||||
queue.enqueue([post('p1', 'alice')], 'image', 1, 1_000);
|
||||
const screening = queue.claimForScreening('screen', 100, 1_001)!;
|
||||
|
||||
@@ -172,6 +172,44 @@ describe('dashboard foundation', () => {
|
||||
expect(sourceImage).not.toHaveClass('is-blurred');
|
||||
});
|
||||
|
||||
it('requires confirmation before clearing unsent outreach and preserves in-flight sends', async () => {
|
||||
installSuccessfulApi();
|
||||
let cleared = false;
|
||||
let deleteRequests = 0;
|
||||
server.use(
|
||||
http.get('/api/outreach', () => HttpResponse.json({
|
||||
generatedAt: Date.now(),
|
||||
activeCount: cleared ? 1 : 3,
|
||||
stages: { discovered: cleared ? 0 : 2, screening: 0, ready: 0, sending: 1, retryable: 0 },
|
||||
outcomes: { sent: 12, rejected: cleared ? 9 : 7 },
|
||||
oldestActiveAt: Date.now() - 60_000,
|
||||
candidates: [],
|
||||
})),
|
||||
http.delete('/api/outreach', () => {
|
||||
deleteRequests += 1;
|
||||
cleared = true;
|
||||
return HttpResponse.json({
|
||||
success: true,
|
||||
cleared: { discovered: 2, screening: 0, ready: 0, retryable: 0, total: 2 },
|
||||
retainedSending: 1,
|
||||
});
|
||||
}),
|
||||
);
|
||||
renderDashboard('/outreach');
|
||||
const user = userEvent.setup();
|
||||
|
||||
await user.click(await screen.findByRole('button', { name: 'Clear funnel' }));
|
||||
const dialog = screen.getByRole('dialog', { name: 'Clear the outreach funnel?' });
|
||||
expect(dialog).toHaveTextContent('cancel 2 waiting, screening, prepared, or retrying candidates');
|
||||
expect(dialog).toHaveTextContent('1 in-flight send will be left alone');
|
||||
expect(deleteRequests).toBe(0);
|
||||
|
||||
await user.click(within(dialog).getByRole('button', { name: 'Clear funnel' }));
|
||||
expect(await screen.findByRole('status')).toHaveTextContent('Cleared 2 unsent candidates. 1 in-flight send was retained.');
|
||||
await waitFor(() => expect(screen.getByRole('heading', { name: '1 active post' })).toBeInTheDocument());
|
||||
expect(deleteRequests).toBe(1);
|
||||
});
|
||||
|
||||
it('hydrates media filters from the routed URL', async () => {
|
||||
installSuccessfulApi();
|
||||
server.use(
|
||||
|
||||
@@ -1,18 +1,21 @@
|
||||
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 {
|
||||
OutreachClearResponseSchema,
|
||||
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> {
|
||||
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}`);
|
||||
return fetch(`http://127.0.0.1:${port}${path}`, init);
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
@@ -102,4 +105,35 @@ describe('outreach route', () => {
|
||||
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 }),
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user