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 { AccountRuntimeLoop, 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', attractivenessScore: 64, physiqueScore: 5, physiqueTargeting: { enabled: false, minScore: 1, maxScore: 10 }, sourceImageUrl: 'https://example.com/image.jpg', 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'); }); }); describe('AccountRuntimeLoop mode gating', () => { function loopHarness( getBotMode: () => 'running' | 'scan' | 'stopped', growthService?: { tick: ReturnType; recoverAfterRestart: ReturnType }, browsingService?: { tick: ReturnType }, ) { const conversationAutomation = { processPendingConversations: vi.fn().mockResolvedValue([]) }; const responseChecker = { checkForResponses: vi.fn().mockResolvedValue([]) }; const mediaSyncService = { start: vi.fn(), stop: vi.fn() }; const chatService = { runStartupSync: vi.fn().mockResolvedValue(undefined), enrichUsernames: vi.fn().mockResolvedValue(undefined), }; const resources = { conversationAutomation, responseChecker, mediaSyncService, chatService, accountAuth: { isFullyAuthenticated: () => true, getSnapshot: () => ({ redditUsername: 'tester' }), }, activityLogRepo: { insert: vi.fn() }, recipientAccountStatusService: { start: vi.fn(), stop: vi.fn() }, establishmentService: { start: vi.fn(), stop: vi.fn() }, browserClient: { close: vi.fn().mockResolvedValue(undefined) }, ...(growthService ? { growthService } : {}), ...(browsingService ? { browsingService } : {}), }; const config = { reddit: { pollIntervalMs: 1_000 }, browser: { dryRun: false } }; const logger = { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }; const loop = new AccountRuntimeLoop( resources as never, config as never, logger as never, getBotMode, { } as never, ); return { loop, conversationAutomation, responseChecker, mediaSyncService, chatService, growthService, browsingService, }; } /** Lets the loop complete at least one full pass before we assert. */ async function runBriefly(loop: AccountRuntimeLoop): Promise { loop.start(); await new Promise((resolve) => setTimeout(resolve, 60)); await loop.stop(); } it('keeps conversations in sync in scan mode but suppresses replies', async () => { const fixture = loopHarness(() => 'scan'); await runBriefly(fixture.loop); expect(fixture.conversationAutomation.processPendingConversations) .toHaveBeenCalledWith(expect.anything(), { scanOnly: true }); expect(fixture.responseChecker.checkForResponses).toHaveBeenCalled(); expect(fixture.mediaSyncService.start).toHaveBeenCalled(); expect(fixture.chatService.runStartupSync).toHaveBeenCalled(); }); it('sends replies in running mode', async () => { const fixture = loopHarness(() => 'running'); await runBriefly(fixture.loop); expect(fixture.conversationAutomation.processPendingConversations) .toHaveBeenCalledWith(expect.anything(), { scanOnly: false }); }); it('halts every background refresh when stopped', async () => { const fixture = loopHarness(() => 'stopped'); await runBriefly(fixture.loop); expect(fixture.conversationAutomation.processPendingConversations).not.toHaveBeenCalled(); expect(fixture.responseChecker.checkForResponses).not.toHaveBeenCalled(); expect(fixture.mediaSyncService.start).not.toHaveBeenCalled(); expect(fixture.chatService.runStartupSync).not.toHaveBeenCalled(); }); it('stops the background media sync when the operator drops from scan to stopped', async () => { let mode: 'running' | 'scan' | 'stopped' = 'scan'; const fixture = loopHarness(() => mode); fixture.loop.start(); await new Promise((resolve) => setTimeout(resolve, 30)); expect(fixture.mediaSyncService.start).toHaveBeenCalledTimes(1); mode = 'stopped'; await new Promise((resolve) => setTimeout(resolve, 30)); await fixture.loop.stop(); expect(fixture.mediaSyncService.stop).toHaveBeenCalled(); // Only ever started once — the reconcile must not re-enter on every tick. expect(fixture.mediaSyncService.start).toHaveBeenCalledTimes(1); }); describe('account growth', () => { function growthStub(tick = vi.fn().mockResolvedValue({ kind: 'idle', reason: 'test' })) { return { tick, recoverAfterRestart: vi.fn().mockReturnValue(0) }; } it('retires interrupted comments before starting any growth work', async () => { const growth = growthStub(); const fixture = loopHarness(() => 'running', growth); await runBriefly(fixture.loop); expect(growth.recoverAfterRestart).toHaveBeenCalledTimes(1); const recoverOrder = growth.recoverAfterRestart.mock.invocationCallOrder[0]; const tickOrder = growth.tick.mock.invocationCallOrder[0]; expect(recoverOrder).toBeLessThan(tickOrder); }); it('lets growth post in running mode', async () => { const growth = growthStub(); const fixture = loopHarness(() => 'running', growth); await runBriefly(fixture.loop); expect(growth.tick).toHaveBeenCalledWith({ allowPosting: true }, expect.anything()); }); it('verifies but never posts in scan mode', async () => { const growth = growthStub(); const fixture = loopHarness(() => 'scan', growth); await runBriefly(fixture.loop); expect(growth.tick).toHaveBeenCalledWith({ allowPosting: false }, expect.anything()); }); it('does not run growth at all when stopped', async () => { const growth = growthStub(); const fixture = loopHarness(() => 'stopped', growth); await runBriefly(fixture.loop); expect(growth.tick).not.toHaveBeenCalled(); }); it('runs growth only after replies have been handled in the same pass', async () => { const growth = growthStub(); const fixture = loopHarness(() => 'running', growth); await runBriefly(fixture.loop); // Growth is the lowest-priority work: inbound replies come first. const replyOrder = fixture.conversationAutomation.processPendingConversations .mock.invocationCallOrder[0]; expect(replyOrder).toBeLessThan(growth.tick.mock.invocationCallOrder[0]); }); it('keeps reply handling alive when a growth tick throws', async () => { const growth = growthStub(vi.fn().mockRejectedValue(new Error('growth exploded'))); const fixture = loopHarness(() => 'running', growth); fixture.loop.start(); await new Promise((resolve) => setTimeout(resolve, 60)); await fixture.loop.stop(); expect(growth.tick).toHaveBeenCalled(); expect(fixture.responseChecker.checkForResponses).toHaveBeenCalled(); }); it('works with no growth service configured', async () => { const fixture = loopHarness(() => 'running'); await expect(runBriefly(fixture.loop)).resolves.toBeUndefined(); }); }); describe('browsing simulation', () => { const browsingStub = () => ({ tick: vi.fn().mockResolvedValue({ kind: 'idle', reason: 'test' }) }); it.each(['running', 'scan'] as const)('runs in %s mode after growth', async (mode) => { const growth = { tick: vi.fn().mockResolvedValue({ kind: 'idle' }), recoverAfterRestart: vi.fn() }; const browsing = browsingStub(); const fixture = loopHarness(() => mode, growth, browsing); await runBriefly(fixture.loop); expect(browsing.tick).toHaveBeenCalledWith( { allowVoting: mode === 'running' }, expect.anything(), ); expect(growth.tick.mock.invocationCallOrder[0]) .toBeLessThan(browsing.tick.mock.invocationCallOrder[0]); }); it('does not run while stopped', async () => { const browsing = browsingStub(); const fixture = loopHarness(() => 'stopped', undefined, browsing); await runBriefly(fixture.loop); expect(browsing.tick).not.toHaveBeenCalled(); }); it('isolates browsing failures from reply handling', async () => { const browsing = { tick: vi.fn().mockRejectedValue(new Error('simulation exploded')) }; const fixture = loopHarness(() => 'running', undefined, browsing); await runBriefly(fixture.loop); expect(browsing.tick).toHaveBeenCalled(); expect(fixture.conversationAutomation.processPendingConversations).toHaveBeenCalled(); }); }); });