diff --git a/src/services/reddit-account-runtime-builder.ts b/src/services/reddit-account-runtime-builder.ts index 216422b..260c1bd 100644 --- a/src/services/reddit-account-runtime-builder.ts +++ b/src/services/reddit-account-runtime-builder.ts @@ -52,6 +52,25 @@ export interface ConcreteRuntimeFactoryOptions { isBotRunning?: () => boolean; } +export function materializePreparedPostForAccount( + resources: Pick, + processed: ProcessedPost, + now: number = Date.now(), +): void { + const { post } = processed; + resources.postsRepo.insertIfAbsent({ + id: post.id, + subreddit: post.subreddit, + author: post.author, + title: post.title, + postUrl: resources.redditApi.getPostUrl(post), + imageUrl: processed.sourceType === 'image' ? resources.redditApi.getImageUrl(post) : null, + postCreatedAt: post.created_utc * 1_000, + processedAt: now, + }); + resources.postsRepo.updateAnalysis(post.id, processed.imageAnalysis, processed.generatedMessage); +} + export function createConcreteRedditAccountRuntimeFactory( options: ConcreteRuntimeFactoryOptions, ): RedditAccountRuntimeFactory { @@ -326,6 +345,13 @@ class AccountRuntimeLoop { if (signal?.aborted) { return { status: 'retryable', reason: 'Delivery interrupted by shutdown', retryAt: Date.now() + 5_000 }; } + + // Preparation may have been performed by a different authenticated + // scanner. Sent messages are account-scoped and reference that account's + // processed-post row, so materialise the prepared candidate locally before + // performing the irreversible remote chat creation. + materializePreparedPostForAccount(this.resources, processed); + return rateLimiter.throttle(async (): Promise => { if (this.config.browser.dryRun) { const reason = 'Dry run: message prepared but not sent'; diff --git a/tests/reddit-account-runtime-builder.test.ts b/tests/reddit-account-runtime-builder.test.ts new file mode 100644 index 0000000..3731d62 --- /dev/null +++ b/tests/reddit-account-runtime-builder.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it, vi } from 'vitest'; +import type { RedditPost } from '../src/reddit/api.js'; +import type { RedditAccountRuntimeResources } from '../src/services/reddit-account-runtime.js'; +import { materializePreparedPostForAccount } from '../src/services/reddit-account-runtime-builder.js'; +import type { ProcessedPost } from '../src/services/processor.js'; + +describe('materializePreparedPostForAccount', () => { + it('creates the sending account post row before message persistence', () => { + const post: RedditPost = { + id: 'post-1', subreddit: 'test', author: 'alice', author_fullname: 't2_alice', + title: 'title', url: 'https://example.com/image.jpg', permalink: '/r/test/post-1', + thumbnail: '', is_self: false, created_utc: 123, over_18: true, spoiler: false, + }; + const processed: ProcessedPost = { + post, imageAnalysis: 'analysis', detectedGender: 'female', generatedMessage: 'message', + strategyId: null, experimentId: null, promptVersion: null, selectionReason: null, + modelId: 'model', sourceType: 'image', toneId: 'default', scripted: false, + }; + const insertIfAbsent = vi.fn(); + const updateAnalysis = vi.fn(); + const resources = { + postsRepo: { insertIfAbsent, updateAnalysis }, + redditApi: { + getPostUrl: () => 'https://reddit.example/post-1', + getImageUrl: () => 'https://reddit.example/image.jpg', + }, + } as unknown as Pick; + + materializePreparedPostForAccount(resources, processed, 456); + + expect(insertIfAbsent).toHaveBeenCalledWith({ + id: 'post-1', subreddit: 'test', author: 'alice', title: 'title', + postUrl: 'https://reddit.example/post-1', imageUrl: 'https://reddit.example/image.jpg', + postCreatedAt: 123_000, processedAt: 456, + }); + expect(updateAnalysis).toHaveBeenCalledWith('post-1', 'analysis', 'message'); + }); +});