Add durable candidate funnel

This commit is contained in:
jjbrunton committed 2026-08-09 15:08:06 +01:00
1 parent 5abac31c28
commit df35648920
20 files changed
+1128 -142

No files matched your search

+168
View File
@@ -0,0 +1,168 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import Database from 'better-sqlite3';
import { runMigrations } from '../src/database/client.js';
import {
CandidateQueueRepository,
RuntimeStateRepository,
} from '../src/database/candidate-queue-repository.js';
import { RecipientHistoryRepository } from '../src/database/repositories.js';
import type { RedditPost } from '../src/reddit/api.js';
import type { ProcessedPost } from '../src/services/processor.js';
function post(id: string, author: string, created = 100): RedditPost {
return {
id,
subreddit: 'test',
author,
author_fullname: `t2_${author}`,
title: `[25F] ${id}`,
url: `https://example.com/${id}.jpg`,
permalink: `/r/test/${id}`,
thumbnail: '',
is_self: false,
created_utc: created,
over_18: true,
spoiler: false,
};
}
function prepared(candidate: RedditPost): ProcessedPost {
return {
post: candidate,
imageAnalysis: 'analysis',
detectedGender: 'female',
generatedMessage: 'prepared message',
strategyId: null,
experimentId: null,
promptVersion: null,
selectionReason: null,
modelId: 'model',
sourceType: 'image',
toneId: 'default',
scripted: false,
};
}
describe('CandidateQueueRepository', () => {
let db: Database.Database;
let queue: CandidateQueueRepository;
beforeEach(() => {
db = new Database(':memory:');
db.pragma('foreign_keys = ON');
runMigrations(db);
const insertAccount = db.prepare(`
INSERT INTO reddit_accounts (
account_id, reddit_username, matrix_user_id, status, is_active,
cookies_json, matrix_credentials_json, created_at, updated_at
) VALUES (?, ?, ?, 'verified', 1, '[]', '{}', 1, 1)
`);
insertAccount.run('account-a', 'account-a', '@account-a:reddit.com');
insertAccount.run('account-b', 'account-b', '@account-b:reddit.com');
queue = new CandidateQueueRepository(db);
});
afterEach(() => db.close());
it('builds a broad pool while keeping only one active candidate per author', () => {
const result = queue.enqueue([
post('p1', 'alice'),
post('p2', 'alice'),
post('p3', 'bob'),
], 'image', 2, 1_000);
expect(result).toEqual({
discovered: 2,
duplicatePost: 0,
duplicateAuthor: 1,
previouslyProcessed: 0,
previouslyContacted: 0,
});
expect(queue.getStats().byStatus.discovered).toBe(2);
});
it('excludes posts and recipients already handled anywhere', () => {
db.prepare(`
INSERT INTO processed_posts (account_id, id, subreddit, author, title, processed_at)
VALUES ('__legacy_unassigned__', 'old-post', 'test', 'old', 'title', 1)
`).run();
db.prepare(`INSERT INTO recipient_suppressions (recipient, reason, created_at) VALUES ('contacted', 'sent', 1)`).run();
const result = queue.enqueue([
post('old-post', 'different'),
post('fresh', 'contacted'),
], 'image', 1, 2_000);
expect(result.previouslyProcessed).toBe(1);
expect(result.previouslyContacted).toBe(1);
expect(queue.getStats().total).toBe(0);
});
it('recovers screening and sending leases after an interrupted deployment', () => {
queue.enqueue([post('screen', 'alice'), post('send', 'bob')], 'image', 1, 1_000);
const screening = queue.claimForScreening('screen-worker', 100, 1_010)!;
expect(queue.markReady(screening.postId, 'screen-worker', prepared(screening.post), 1_020)).toBe(true);
const sending = queue.claimReady('account-a', 'send-worker', 100, 1_030)!;
expect(sending.status).toBe('sending');
expect(queue.claimForScreening('other-worker', 100, 1_040)?.status).toBe('screening');
expect(queue.recoverExpiredLeases(1_200)).toBe(2);
expect(queue.getStats().byStatus.retryable).toBe(1);
expect(queue.getStats().byStatus.discovered).toBe(1);
});
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)!;
queue.markReady('p1', 'screen', prepared(screening.post), 1_002);
const sending = queue.claimReady('account-a', 'send-a', 100, 1_003)!;
queue.deferDelivery('p1', 'send-a', 'Matrix rate limited', 1_100, 1_004);
expect(queue.claimReady('account-b', 'send-b', 100, 1_099)).toBeNull();
expect(queue.promoteDueRetries(1_100)).toBe(1);
expect(queue.claimReady('account-b', 'send-b', 100, 1_101)).toMatchObject({
postId: 'p1',
assignedAccountId: 'account-b',
prepared: expect.objectContaining({ generatedMessage: 'prepared message' }),
});
});
});
describe('RuntimeStateRepository', () => {
it('persists the operator run state across repository instances', () => {
const db = new Database(':memory:');
runMigrations(db);
const first = new RuntimeStateRepository(db);
expect(first.isBotRunning()).toBe(false);
first.setBotRunning(true, 100);
expect(new RuntimeStateRepository(db).isBotRunning()).toBe(true);
first.setBotRunning(false, 200);
expect(new RuntimeStateRepository(db).isBotRunning()).toBe(false);
db.close();
});
});
describe('RecipientHistoryRepository recovery', () => {
it('releases only stale pending claims that have no delivery evidence', () => {
const db = new Database(':memory:');
runMigrations(db);
const history = new RecipientHistoryRepository(db);
expect(history.tryClaim('orphaned', 100)).toBe(true);
expect(history.tryClaim('delivered', 100)).toBe(true);
db.prepare(`
INSERT INTO processed_posts (account_id, id, subreddit, author, title, processed_at)
VALUES ('__legacy_unassigned__', 'post-1', 'test', 'delivered', 'title', 125)
`).run();
db.prepare(`
INSERT INTO sent_messages (
account_id, post_id, recipient, subject, message, sent_at
) VALUES ('__legacy_unassigned__', 'post-1', 'delivered', 'subject', 'message', 150)
`).run();
expect(history.recoverStaleClaims(200)).toBe(1);
expect(history.hasSentTo('orphaned')).toBe(false);
expect(history.hasSentTo('delivered')).toBe(true);
db.close();
});
});