fix: reconcile physique targeting for queued posts

This commit is contained in:
jjbrunton committed 2026-08-10 18:23:23 +01:00
1 parent d0a8bd0e2f
commit d8d635e1ee
10 files changed
+250 -12

No files matched your search

+52 -2
View File
@@ -26,13 +26,13 @@ function post(id: string, author: string, created = 100): RedditPost {
};
}
function prepared(candidate: RedditPost): ProcessedPost {
function prepared(candidate: RedditPost, physiqueScore: number | null = 5): ProcessedPost {
return {
post: candidate,
imageAnalysis: 'analysis',
detectedGender: 'female',
attractivenessScore: 64,
physiqueScore: 5,
physiqueScore,
physiqueTargeting: { enabled: true, minScore: 3, maxScore: 7 },
sourceImageUrl: 'https://i.redd.it/example.jpg',
generatedMessage: 'prepared message',
@@ -180,6 +180,56 @@ describe('CandidateQueueRepository', () => {
});
});
it('reconciles ready and retryable images when the physique range changes', () => {
queue.enqueue([
post('inside', 'alice', 300),
post('outside', 'bob', 200),
post('retry', 'carol', 100),
], 'image', 1, 1_000);
for (const worker of ['screen-1', 'screen-2', 'screen-3']) {
const candidate = queue.claimForScreening(worker, 100, 1_010)!;
const score = candidate.postId === 'outside' ? 7 : 4;
queue.markReady(candidate.postId, worker, prepared(candidate.post, score), 1_011);
}
const retry = queue.claimReady('account-a', 'send', 100, 1_012)!;
expect(retry.postId).toBe('inside');
queue.deferDelivery(retry.postId, 'send', 'rate limited', 2_000, 1_013);
expect(queue.reconcilePhysiqueTargeting(
{ enabled: true, minScore: 1, maxScore: 6 },
1_020,
)).toEqual({ retained: 2, rejected: 1 });
const active = queue.listActive();
expect(active.map((candidate) => candidate.postId)).toEqual(['inside', 'retry']);
expect(active.every((candidate) => (
candidate.prepared?.physiqueTargeting?.enabled === true
&& candidate.prepared.physiqueTargeting.maxScore === 6
))).toBe(true);
expect(queue.getStats().byStatus.rejected).toBe(1);
});
it('fails closed for an unscored prepared image but does not touch a leased send', () => {
queue.enqueue([
post('unscored', 'alice', 200),
post('sending', 'bob', 100),
], 'image', 1, 1_000);
for (const worker of ['screen-1', 'screen-2']) {
const candidate = queue.claimForScreening(worker, 100, 1_010)!;
queue.markReady(candidate.postId, worker, prepared(candidate.post, null), 1_011);
}
const sending = queue.claimReady('account-a', 'send', 100, 1_012)!;
expect(queue.reconcilePhysiqueTargeting(
{ enabled: true, minScore: 1, maxScore: 6 },
1_020,
)).toEqual({ retained: 0, rejected: 1 });
expect(queue.listActive()).toEqual([
expect.objectContaining({ postId: sending.postId, status: 'sending' }),
]);
});
it('lists only active candidates in newest-post order across stages', () => {
queue.enqueue([
post('waiting', 'alice', 100),
+52
View File
@@ -357,6 +357,58 @@ describe('RedditApiClient', () => {
expect(result).toBe(true);
});
it('detects a configured seller platform in structured social links', async () => {
const get = vi.fn().mockResolvedValue({
data: {
data: {
subreddit: { public_description: 'Just me', over_18: true },
social_links: [{ title: 'My OnlyFans', outbound_url: 'https://onlyfans.com/example' }],
},
},
});
vi.mocked(axios.create).mockReturnValue({ get } as any);
const client2 = new RedditApiClient(getTestConfig(), logger);
await expect(client2.isSellerProfile('testuser', ['onlyfans'], ['onlyfans.com']))
.resolves.toBe(true);
expect(get).toHaveBeenCalledTimes(1);
});
it('detects a narrow NSFW link-to-more funnel when Reddit omits the social link', async () => {
const get = vi.fn().mockResolvedValue({
data: {
data: {
subreddit: {
public_description: "Hit the link to see more of me, we both know why you're here",
title: '',
display_name_prefixed: 'u/testuser',
over_18: true,
},
},
},
});
vi.mocked(axios.create).mockReturnValue({ get } as any);
const client2 = new RedditApiClient(getTestConfig(), logger);
await expect(client2.isSellerProfile('testuser', ['onlyfans'], ['onlyfans.com']))
.resolves.toBe(true);
expect(get).toHaveBeenCalledTimes(1);
});
it('does not treat an ordinary or non-NSFW profile link as a seller funnel', async () => {
const get = vi.fn()
.mockResolvedValueOnce({
data: { data: { subreddit: { public_description: 'Hit the link to see more of my art', over_18: false } } },
})
.mockResolvedValueOnce({ data: { data: { children: [] } } });
vi.mocked(axios.create).mockReturnValue({ get } as any);
const client2 = new RedditApiClient(getTestConfig(), logger);
await expect(client2.isSellerProfile('testuser', ['onlyfans'], ['onlyfans.com']))
.resolves.toBe(false);
expect(get).toHaveBeenCalledTimes(2);
});
it('returns false when no keywords match', async () => {
const mockResponse = {
data: {
+11 -3
View File
@@ -25,10 +25,11 @@ function harness() {
// harnesses would otherwise share the same `conversations` instance.
const config = structuredClone(ConfigSchema.parse(getTestConfig())) as Config;
const saved: Config[] = [];
const candidateQueue = { reconcilePhysiqueTargeting: vi.fn(() => ({ retained: 2, rejected: 1 })) };
const ctx = {
config,
runtimeConfig: config,
services: { configStore: { save: (next: Config) => saved.push(next) } },
services: { configStore: { save: (next: Config) => saved.push(next) }, candidateQueue },
getOnConfigChange: () => undefined,
activity: { push: vi.fn() },
broadcaster: { push: vi.fn() },
@@ -37,7 +38,7 @@ function harness() {
const app = express();
app.use(express.json());
app.use('/api', createConfigRouter(ctx));
return { app, config, saved };
return { app, config, saved, candidateQueue, ctx };
}
describe('config route conversation kill controls', () => {
@@ -112,7 +113,7 @@ describe('config route conversation kill controls', () => {
describe('config route physique targeting', () => {
it('hot-updates and persists the image-candidate score range', async () => {
const { app, config, saved } = harness();
const { app, config, saved, candidateQueue, ctx } = harness();
const targetingRef = config.reddit.physiqueTargeting;
const response = await request(app, '/api/config', {
@@ -129,6 +130,13 @@ describe('config route physique targeting', () => {
expect(config.reddit.physiqueTargeting).toBe(targetingRef);
expect(config.reddit.physiqueTargeting).toEqual({ enabled: true, minScore: 3, maxScore: 7 });
expect(saved.at(-1)?.reddit.physiqueTargeting).toEqual({ enabled: true, minScore: 3, maxScore: 7 });
expect(candidateQueue.reconcilePhysiqueTargeting).toHaveBeenCalledWith({
enabled: true, minScore: 3, maxScore: 7,
});
expect(ctx.activity.push).toHaveBeenCalledWith(
'info',
'Physique targeting reconciled 2 prepared image candidate(s) and rejected 1',
);
});
it('rejects an inverted physique range without mutating configuration', async () => {