Add account browsing simulation
This commit is contained in:
1 parent
e2d7dc4b82
commit
27012399ec
31 files changed
+1476
-28
No files matched your search
@@ -0,0 +1,247 @@
|
||||
import Database from 'better-sqlite3';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { runMigrations } from '../src/database/client.js';
|
||||
import {
|
||||
AccountBrowsingSimulationRepository,
|
||||
type BrowsingDecisionRecord,
|
||||
} from '../src/database/browsing-simulation-repository.js';
|
||||
import type { BrowsingThing, RedditPost } from '../src/reddit/api.js';
|
||||
import {
|
||||
AccountBrowsingSimulationService,
|
||||
dailyTarget,
|
||||
isQuietTime,
|
||||
} from '../src/services/account-browsing-simulation.js';
|
||||
import { createMockLogger } from './mockLogger.js';
|
||||
|
||||
const ACCOUNT = '@t2_owner:reddit.com';
|
||||
const OTHER = '@t2_other:reddit.com';
|
||||
const NOW = new Date(2026, 7, 11, 12, 0, 0, 0).getTime();
|
||||
|
||||
function insertAccount(db: Database.Database, accountId: string, username: string): void {
|
||||
db.prepare(`
|
||||
INSERT INTO reddit_accounts (
|
||||
account_id, reddit_username, matrix_user_id, status, is_active,
|
||||
cookies_json, matrix_credentials_json, created_at, updated_at
|
||||
) VALUES (?, ?, ?, 'verified', 1, '[]', '{}', 0, 0)
|
||||
`).run(accountId, username, accountId);
|
||||
}
|
||||
|
||||
function post(id: string, overrides: Partial<RedditPost> = {}): RedditPost {
|
||||
return {
|
||||
id,
|
||||
name: `t3_${id}`,
|
||||
subreddit: 'AskUK',
|
||||
author: `author_${id}`,
|
||||
title: `A useful discussion about topic ${id}`,
|
||||
selftext: 'Enough context for an ordinary browsing choice.',
|
||||
url: `https://www.reddit.com/r/AskUK/comments/${id}/`,
|
||||
permalink: `/r/AskUK/comments/${id}/topic/`,
|
||||
thumbnail: '',
|
||||
is_self: true,
|
||||
created_utc: NOW / 1_000 - 60,
|
||||
over_18: false,
|
||||
spoiler: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function comment(id: string, parentPostId: string): BrowsingThing {
|
||||
return {
|
||||
fullname: `t1_${id}`,
|
||||
kind: 'comment',
|
||||
subreddit: 'AskUK',
|
||||
author: `commenter_${id}`,
|
||||
permalink: `/r/AskUK/comments/${parentPostId}/topic/${id}/`,
|
||||
createdAt: NOW - 30_000,
|
||||
score: 2,
|
||||
text: 'This comment has enough useful context to be eligible.',
|
||||
nsfw: false,
|
||||
parentPostId,
|
||||
distinguished: null,
|
||||
removed: false,
|
||||
};
|
||||
}
|
||||
|
||||
describe('AccountBrowsingSimulationService', () => {
|
||||
let db: Database.Database;
|
||||
let repository: AccountBrowsingSimulationRepository;
|
||||
|
||||
beforeEach(() => {
|
||||
db = new Database(':memory:');
|
||||
db.pragma('foreign_keys = ON');
|
||||
runMigrations(db);
|
||||
insertAccount(db, ACCOUNT, 'owner');
|
||||
insertAccount(db, OTHER, 'other');
|
||||
repository = new AccountBrowsingSimulationRepository(db);
|
||||
});
|
||||
|
||||
function service(options: {
|
||||
posts?: RedditPost[];
|
||||
comments?: BrowsingThing[];
|
||||
random?: number;
|
||||
} = {}) {
|
||||
const getHotPosts = vi.fn().mockResolvedValue(options.posts ?? [post('one')]);
|
||||
const getBrowsingComments = vi.fn().mockResolvedValue(options.comments ?? []);
|
||||
const instance = new AccountBrowsingSimulationService({
|
||||
accountId: ACCOUNT,
|
||||
repository,
|
||||
growthSubreddits: () => [{ subreddit: 'AskUK', weight: 1 }] as never,
|
||||
reddit: { getHotPosts, getBrowsingComments },
|
||||
sink: repository,
|
||||
logger: createMockLogger(),
|
||||
now: () => NOW,
|
||||
random: () => options.random ?? 0,
|
||||
});
|
||||
return { instance, getHotPosts, getBrowsingComments };
|
||||
}
|
||||
|
||||
function enable(overrides: Parameters<AccountBrowsingSimulationRepository['saveSettings']>[1] = {}): void {
|
||||
repository.saveSettings(ACCOUNT, {
|
||||
enabled: true,
|
||||
quietHours: { enabled: false, startHour: 0, endHour: 7 },
|
||||
...overrides,
|
||||
}, NOW - 1);
|
||||
}
|
||||
|
||||
it('is disabled by default and performs no discovery', async () => {
|
||||
const fixture = service();
|
||||
|
||||
await expect(fixture.instance.tick()).resolves.toEqual({
|
||||
kind: 'idle', reason: 'Browsing simulation is disabled',
|
||||
});
|
||||
expect(fixture.getHotPosts).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('persists a randomized due time that survives service recreation', async () => {
|
||||
enable({ minimumGapMs: 45 * 60_000, jitterMs: 10 * 60_000 });
|
||||
const first = service({ random: 0.5 });
|
||||
|
||||
const scheduled = await first.instance.tick();
|
||||
const dueAt = repository.getSettings(ACCOUNT).nextDueAt;
|
||||
|
||||
expect(scheduled).toEqual({ kind: 'scheduled', nextDueAt: dueAt });
|
||||
expect(dueAt).toBeGreaterThanOrEqual(NOW + 45 * 60_000);
|
||||
expect(dueAt).toBeLessThanOrEqual(NOW + 12 * 60 * 60_000);
|
||||
const restarted = service({ random: 0.1 });
|
||||
await expect(restarted.instance.tick()).resolves.toEqual({
|
||||
kind: 'idle', reason: 'Browsing simulation is not due',
|
||||
});
|
||||
expect(restarted.getHotPosts).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('records an eligible post as a simulation without any write transport', async () => {
|
||||
enable({ postWeight: 100, commentWeight: 0 });
|
||||
repository.setNextDueAt(ACCOUNT, NOW, NOW - 1);
|
||||
|
||||
const result = await service().instance.tick();
|
||||
|
||||
expect(result.kind).toBe('simulated');
|
||||
expect(repository.listRecent(ACCOUNT)).toEqual([
|
||||
expect.objectContaining({ thingFullname: 't3_one', decision: 'simulated-upvote' }),
|
||||
]);
|
||||
});
|
||||
|
||||
it('honours comment weighting when eligible comments exist', async () => {
|
||||
enable({ postWeight: 0, commentWeight: 100 });
|
||||
repository.setNextDueAt(ACCOUNT, NOW, NOW - 1);
|
||||
|
||||
const result = await service({ comments: [comment('c1', 'one')] }).instance.tick();
|
||||
|
||||
expect(result).toMatchObject({
|
||||
kind: 'simulated', decision: { thingFullname: 't1_c1', thingKind: 'comment' },
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects owned authors, outreach recipients, NSFW and cross-account duplicates', async () => {
|
||||
db.prepare(`INSERT INTO recipient_suppressions (recipient, reason, created_at) VALUES ('contacted', 'test', 0)`).run();
|
||||
const otherSelection: BrowsingDecisionRecord = {
|
||||
accountId: OTHER, thingFullname: 't3_taken', thingKind: 'post', postId: 'taken',
|
||||
subreddit: 'AskUK', author: 'someone', permalink: '/taken', decision: 'simulated-upvote',
|
||||
reason: 'test', discoveredAt: NOW - 1, decidedAt: NOW - 1, scheduledSessionId: 'other',
|
||||
};
|
||||
repository.recordDecision(otherSelection);
|
||||
enable({ postWeight: 100, commentWeight: 0, allowNsfw: false });
|
||||
repository.setNextDueAt(ACCOUNT, NOW, NOW - 1);
|
||||
const posts = [
|
||||
post('owned', { author: 'owner' }),
|
||||
post('recipient', { author: 'contacted' }),
|
||||
post('adult', { over_18: true }),
|
||||
post('taken'),
|
||||
post('allowed'),
|
||||
];
|
||||
|
||||
const result = await service({ posts }).instance.tick();
|
||||
|
||||
expect(result).toMatchObject({ kind: 'simulated', decision: { thingFullname: 't3_allowed' } });
|
||||
const recent = repository.listRecent(ACCOUNT, 20);
|
||||
expect(recent.filter((entry) => entry.decision === 'rejected').map((entry) => entry.reason))
|
||||
.toEqual(expect.arrayContaining([
|
||||
'Authored by an owned account',
|
||||
'Authored by an outreach recipient',
|
||||
'NSFW content is disabled',
|
||||
'Already selected by another owned account',
|
||||
]));
|
||||
});
|
||||
|
||||
it('atomically prevents two accounts from recording the same simulated selection', async () => {
|
||||
const first: BrowsingDecisionRecord = {
|
||||
accountId: ACCOUNT, thingFullname: 't3_same', thingKind: 'post', postId: 'same',
|
||||
subreddit: 'AskUK', author: 'author', permalink: '/same', decision: 'simulated-upvote',
|
||||
reason: 'test', discoveredAt: NOW, decidedAt: NOW, scheduledSessionId: 'first',
|
||||
};
|
||||
expect(repository.recordDecision(first)).toBe(true);
|
||||
expect(repository.recordDecision({ ...first, accountId: OTHER, scheduledSessionId: 'second' }))
|
||||
.toBe(false);
|
||||
});
|
||||
|
||||
it('stops at the deterministic daily target before making a Reddit request', async () => {
|
||||
enable({ dailyTargetMin: 1, dailyTargetMax: 1 });
|
||||
repository.recordDecision({
|
||||
accountId: ACCOUNT, thingFullname: 't3_done', thingKind: 'post', postId: 'done',
|
||||
subreddit: 'AskUK', author: 'author', permalink: '/done', decision: 'simulated-upvote',
|
||||
reason: 'test', discoveredAt: NOW - 1, decidedAt: NOW - 1, scheduledSessionId: 'done',
|
||||
});
|
||||
repository.setNextDueAt(ACCOUNT, NOW, NOW - 1);
|
||||
const fixture = service();
|
||||
|
||||
expect(await fixture.instance.tick()).toMatchObject({ kind: 'scheduled' });
|
||||
expect(fixture.getHotPosts).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('browsing pacing policy', () => {
|
||||
it('handles quiet hours that cross midnight', () => {
|
||||
const settings = { quietHours: { enabled: true, startHour: 23, endHour: 7 } };
|
||||
expect(isQuietTime(new Date(2026, 7, 11, 23, 30), settings)).toBe(true);
|
||||
expect(isQuietTime(new Date(2026, 7, 12, 6, 59), settings)).toBe(true);
|
||||
expect(isQuietTime(new Date(2026, 7, 12, 7, 0), settings)).toBe(false);
|
||||
});
|
||||
|
||||
it('derives one stable bounded target per account and local day', () => {
|
||||
const settings = { dailyTargetMin: 3, dailyTargetMax: 6 };
|
||||
const first = dailyTarget(ACCOUNT, NOW, settings);
|
||||
expect(dailyTarget(ACCOUNT, NOW, settings)).toBe(first);
|
||||
expect(first).toBeGreaterThanOrEqual(3);
|
||||
expect(first).toBeLessThanOrEqual(6);
|
||||
});
|
||||
});
|
||||
|
||||
describe('browsing migration', () => {
|
||||
it('upgrades the previous production schema without disturbing growth settings', () => {
|
||||
const db = new Database(':memory:');
|
||||
db.pragma('foreign_keys = ON');
|
||||
runMigrations(db);
|
||||
const version = db.pragma('user_version', { simple: true }) as number;
|
||||
db.exec('DROP TABLE account_browsing_decisions; DROP TABLE account_browsing_settings;');
|
||||
db.pragma(`user_version = ${version - 1}`);
|
||||
|
||||
runMigrations(db);
|
||||
insertAccount(db, ACCOUNT, 'owner');
|
||||
|
||||
expect(new AccountBrowsingSimulationRepository(db).getSettings(ACCOUNT)).toMatchObject({
|
||||
enabled: false, dailyTargetMin: 3, dailyTargetMax: 6, nextDueAt: null,
|
||||
});
|
||||
expect(db.prepare(`PRAGMA table_info(account_growth_settings)`).all())
|
||||
.toEqual(expect.arrayContaining([expect.objectContaining({ name: 'daily_limit' })]));
|
||||
});
|
||||
});
|
||||
@@ -43,6 +43,28 @@ function stats(overrides: Record<string, unknown> = {}) {
|
||||
};
|
||||
}
|
||||
|
||||
function browsing(includeRecent = false) {
|
||||
return {
|
||||
settings: {
|
||||
enabled: false,
|
||||
dailyTargetMin: 3,
|
||||
dailyTargetMax: 6,
|
||||
postWeight: 60,
|
||||
commentWeight: 40,
|
||||
quietHours: { enabled: true, startHour: 23, endHour: 7 },
|
||||
minimumGapMs: 2_700_000,
|
||||
jitterMs: 900_000,
|
||||
allowNsfw: false,
|
||||
nextDueAt: null,
|
||||
updatedAt: 1_700_000_000_000,
|
||||
},
|
||||
todayTarget: 4,
|
||||
stats: { selected: 0, posts: 0, comments: 0, rejected: 0, skipped: 0 },
|
||||
noVotesSubmitted: true,
|
||||
...(includeRecent ? { recentDecisions: [] } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function summary(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
accountId: ACCOUNT_ID,
|
||||
@@ -51,6 +73,7 @@ function summary(overrides: Record<string, unknown> = {}) {
|
||||
stats: stats(),
|
||||
subredditCount: 2,
|
||||
activeSubredditCount: 1,
|
||||
browsing: browsing(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
@@ -112,6 +135,7 @@ function detail(overrides: Record<string, unknown> = {}) {
|
||||
lastError: 'Comment solicits contact or promotes a profile',
|
||||
},
|
||||
],
|
||||
browsing: browsing(true),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
@@ -133,6 +157,10 @@ const server = setupServer(
|
||||
requests.push({ method: 'PATCH', url: request.url, body: await request.json() });
|
||||
return HttpResponse.json({ success: true, account: detailPayload });
|
||||
}),
|
||||
http.patch('/api/growth/:accountId/browsing', async ({ request }) => {
|
||||
requests.push({ method: 'PATCH', url: request.url, body: await request.json() });
|
||||
return HttpResponse.json({ success: true, account: detailPayload });
|
||||
}),
|
||||
http.post('/api/growth/:accountId/pause', async ({ request }) => {
|
||||
requests.push({ method: 'POST', url: request.url, body: await request.json() });
|
||||
return HttpResponse.json({ success: true, account: detailPayload });
|
||||
@@ -187,6 +215,21 @@ describe('GrowthPage', () => {
|
||||
expect(screen.getByText(/posted without review/i)).toBeVisible();
|
||||
});
|
||||
|
||||
it('shows browsing as an explicit no-vote simulation and can enable it', async () => {
|
||||
renderGrowth();
|
||||
const user = userEvent.setup();
|
||||
|
||||
expect(await screen.findByRole('heading', { name: 'Browsing simulation' })).toBeVisible();
|
||||
expect(screen.getByText('No Reddit votes were submitted.')).toBeVisible();
|
||||
expect(screen.getByText('0 / 4')).toBeVisible();
|
||||
await user.click(screen.getByLabelText('Simulate browsing selections'));
|
||||
|
||||
await waitFor(() => expect(requests).toContainEqual(expect.objectContaining({
|
||||
method: 'PATCH', body: { enabled: true },
|
||||
})));
|
||||
expect(requests[0].url).toContain('/api/growth/%40t2_grower%3Areddit.com/browsing');
|
||||
});
|
||||
|
||||
it('shows an empty state when there are no accounts', async () => {
|
||||
overviewPayload = [];
|
||||
renderGrowth();
|
||||
|
||||
@@ -108,7 +108,7 @@ describe('Database Client', () => {
|
||||
.all() as { name: string }[];
|
||||
// Existing domain/config tables plus account, conversation, sync, media
|
||||
// ownership and account-growth stores.
|
||||
expect(tables.length).toBe(26);
|
||||
expect(tables.length).toBe(28);
|
||||
expect(tables.map((table) => table.name)).toEqual(expect.arrayContaining([
|
||||
'candidate_queue',
|
||||
'runtime_state',
|
||||
@@ -120,6 +120,8 @@ describe('Database Client', () => {
|
||||
'account_growth_settings',
|
||||
'account_growth_subreddits',
|
||||
'account_growth_comments',
|
||||
'account_browsing_settings',
|
||||
'account_browsing_decisions',
|
||||
]));
|
||||
});
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ import type { RedditPost } from '../src/reddit/api.js';
|
||||
const ACCOUNT = '@t2_grower:reddit.com';
|
||||
const OTHER_ACCOUNT = '@t2_other:reddit.com';
|
||||
/** Migrations that make up account growth, replayed together in the upgrade test. */
|
||||
const GROWTH_MIGRATION_COUNT = 2;
|
||||
const GROWTH_MIGRATION_COUNT = 3;
|
||||
|
||||
function thread(id: string, overrides: Partial<RedditPost> = {}): RedditPost {
|
||||
return {
|
||||
@@ -536,7 +536,9 @@ describe('growth migration', () => {
|
||||
runMigrations(db);
|
||||
const version = db.pragma('user_version', { simple: true }) as number;
|
||||
db.exec('ALTER TABLE account_growth_settings DROP COLUMN daily_limit');
|
||||
db.pragma(`user_version = ${version - 1}`);
|
||||
// Browsing simulation is the migration after the growth daily-limit
|
||||
// column, so rewind both to replay the real upgrade sequence.
|
||||
db.pragma(`user_version = ${version - 2}`);
|
||||
insertAccount(db, ACCOUNT);
|
||||
// Seed a row with the old shape so the upgrade runs against real data.
|
||||
db.prepare(`
|
||||
|
||||
@@ -43,6 +43,7 @@ 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([]) };
|
||||
@@ -65,13 +66,17 @@ describe('AccountRuntimeLoop mode gating', () => {
|
||||
establishmentService: { start: vi.fn(), stop: vi.fn() },
|
||||
browserClient: { close: vi.fn().mockResolvedValue(undefined) },
|
||||
...(growthService ? { growthService } : {}),
|
||||
...(browsingService ? { browsingService } : {}),
|
||||
};
|
||||
const config = { reddit: { pollIntervalMs: 1_000 } };
|
||||
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 };
|
||||
return {
|
||||
loop, conversationAutomation, responseChecker, mediaSyncService, chatService,
|
||||
growthService, browsingService,
|
||||
};
|
||||
}
|
||||
|
||||
/** Lets the loop complete at least one full pass before we assert. */
|
||||
@@ -204,4 +209,39 @@ describe('AccountRuntimeLoop mode gating', () => {
|
||||
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(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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -30,6 +30,32 @@ describe('RedditApiClient', () => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('getBrowsingComments', () => {
|
||||
it('returns typed comment identity and moderation fields without changing the text reader', async () => {
|
||||
const get = vi.fn().mockResolvedValue({
|
||||
data: [{ data: { children: [] } }, { data: { children: [{
|
||||
data: {
|
||||
name: 't1_comment', author: 'alice', subreddit: 'AskUK',
|
||||
permalink: '/r/AskUK/comments/post/topic/comment/', created_utc: 123,
|
||||
score: 7, body: 'A useful answer with enough detail', distinguished: null,
|
||||
},
|
||||
}] } }],
|
||||
});
|
||||
vi.mocked(axios.create).mockReturnValue({ get } as unknown as AxiosInstance);
|
||||
const client2 = new RedditApiClient(getTestConfig(), logger);
|
||||
|
||||
await expect(client2.getBrowsingComments('AskUK', 'post', 10)).resolves.toEqual([{
|
||||
fullname: 't1_comment', kind: 'comment', subreddit: 'AskUK', author: 'alice',
|
||||
permalink: '/r/AskUK/comments/post/topic/comment/', createdAt: 123_000,
|
||||
score: 7, text: 'A useful answer with enough detail', nsfw: false,
|
||||
parentPostId: 'post', distinguished: null, removed: false,
|
||||
}]);
|
||||
expect(get).toHaveBeenCalledWith('/r/AskUK/comments/post.json', {
|
||||
params: { limit: 10, depth: 1, sort: 'hot', raw_json: 1 }, headers: {},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('hasImage', () => {
|
||||
const validPostFactory = (overrides: Partial<RedditPost> = {}): RedditPost => ({
|
||||
id: 't3_test',
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
} from '../../packages/api-contracts/src/index.js';
|
||||
import { runMigrations } from '../../src/database/client.js';
|
||||
import { AccountGrowthRepository } from '../../src/database/growth-repository.js';
|
||||
import { AccountBrowsingSimulationRepository } from '../../src/database/browsing-simulation-repository.js';
|
||||
import type { WebContext } from '../../src/web/context.js';
|
||||
import { createGrowthRouter } from '../../src/web/routes/growth.js';
|
||||
import type { RedditPost } from '../../src/reddit/api.js';
|
||||
@@ -63,6 +64,7 @@ function thread(id: string): RedditPost {
|
||||
describe('growth route', () => {
|
||||
let db: Database.Database;
|
||||
let repo: AccountGrowthRepository;
|
||||
let browsingRepo: AccountBrowsingSimulationRepository;
|
||||
let app: express.Express;
|
||||
let pushed: Array<{ level: string; message: string }>;
|
||||
|
||||
@@ -77,12 +79,14 @@ describe('growth route', () => {
|
||||
) VALUES (?, 'grower', ?, 'verified', 1, '[]', '{}', 0, 0)
|
||||
`).run(ACCOUNT, ACCOUNT);
|
||||
repo = new AccountGrowthRepository(db);
|
||||
browsingRepo = new AccountBrowsingSimulationRepository(db);
|
||||
pushed = [];
|
||||
|
||||
const ctx = {
|
||||
broadcaster: { push: (level: string, message: string) => pushed.push({ level, message }) },
|
||||
services: {
|
||||
growth: repo,
|
||||
browsing: browsingRepo,
|
||||
redditAccounts: {
|
||||
list: () => [{ accountId: ACCOUNT, redditUsername: 'grower' }],
|
||||
},
|
||||
@@ -246,6 +250,37 @@ describe('growth route', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('PATCH /api/growth/:accountId/browsing', () => {
|
||||
it('enables simulation within the conservative hard limits', async () => {
|
||||
const { status, json } = await call(app, 'PATCH', `/api/growth/${ENCODED}/browsing`, {
|
||||
enabled: true,
|
||||
dailyTargetMin: 4,
|
||||
dailyTargetMax: 8,
|
||||
minimumGapMs: 3_600_000,
|
||||
});
|
||||
|
||||
expect(status).toBe(200);
|
||||
const parsed = GrowthAccountResponseSchema.parse(json);
|
||||
expect(parsed.account.browsing).toMatchObject({
|
||||
settings: { enabled: true, dailyTargetMin: 4, dailyTargetMax: 8 },
|
||||
noVotesSubmitted: true,
|
||||
});
|
||||
expect(pushed).toContainEqual({
|
||||
level: 'info', message: 'Browsing simulation enabled for u/grower',
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ dailyTargetMax: 13 },
|
||||
{ minimumGapMs: 44 * 60_000 },
|
||||
{ postWeight: 0, commentWeight: 0 },
|
||||
{ nextDueAt: 123 },
|
||||
])('rejects unsafe or non-operator settings %o', async (body) => {
|
||||
const { status } = await call(app, 'PATCH', `/api/growth/${ENCODED}/browsing`, body);
|
||||
expect(status).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe('pause and resume', () => {
|
||||
it('pauses with a default reason and resumes', async () => {
|
||||
const paused = await call(app, 'POST', `/api/growth/${ENCODED}/pause`, {});
|
||||
|
||||
Reference in new issue
Block a user