250 lines
10 KiB
TypeScript
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();
|
|
});
|
|
});
|
|
});
|