Files
RedditBot/tests/reddit-account-runtime-builder.test.ts
T

250 lines
10 KiB
TypeScript

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<RedditAccountRuntimeResources, 'postsRepo' | 'redditApi'>;
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<typeof vi.fn>; recoverAfterRestart: ReturnType<typeof vi.fn> },
browsingService?: { tick: ReturnType<typeof vi.fn> },
) {
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<void> {
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();
});
});
});