import express from 'express'; import Database from 'better-sqlite3'; import type { AddressInfo } from 'node:net'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { GrowthAccountResponseSchema, GrowthOverviewResponseSchema, } from '../../packages/api-contracts/src/index.js'; import { runMigrations } from '../../src/database/client.js'; import { AccountGrowthRepository } from '../../src/database/growth-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'; const ACCOUNT = '@t2_grower:reddit.com'; const ENCODED = encodeURIComponent(ACCOUNT); const servers: Array> = []; async function call( app: express.Express, method: string, path: string, body?: unknown, ): Promise<{ status: number; json: any }> { const server = app.listen(0); servers.push(server); await new Promise((resolve) => server.once('listening', resolve)); const { port } = server.address() as AddressInfo; const response = await fetch(`http://127.0.0.1:${port}${path}`, { method, headers: body === undefined ? {} : { 'Content-Type': 'application/json' }, body: body === undefined ? undefined : JSON.stringify(body), }); const text = await response.text(); return { status: response.status, json: text ? JSON.parse(text) : null }; } afterEach(async () => { await Promise.all(servers.splice(0).map((server) => ( new Promise((resolve) => server.close(() => resolve())) ))); }); function thread(id: string): RedditPost { return { id, name: `t3_${id}`, subreddit: 'cats', author: 'someone', title: `Thread ${id}`, url: `https://www.reddit.com/r/cats/comments/${id}/`, permalink: `/r/cats/comments/${id}/`, thumbnail: '', is_self: true, created_utc: 1_700_000, over_18: false, spoiler: false, num_comments: 12, } as RedditPost; } describe('growth route', () => { let db: Database.Database; let repo: AccountGrowthRepository; let app: express.Express; let pushed: Array<{ level: string; message: string }>; beforeEach(() => { db = new Database(':memory:'); db.pragma('foreign_keys = ON'); runMigrations(db); 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 (?, 'grower', ?, 'verified', 1, '[]', '{}', 0, 0) `).run(ACCOUNT, ACCOUNT); repo = new AccountGrowthRepository(db); pushed = []; const ctx = { broadcaster: { push: (level: string, message: string) => pushed.push({ level, message }) }, services: { growth: repo, redditAccounts: { list: () => [{ accountId: ACCOUNT, redditUsername: 'grower' }], }, }, } as unknown as WebContext; app = express(); app.use(express.json()); app.use('/api', createGrowthRouter(ctx)); }); describe('GET /api/growth', () => { it('returns a contract-valid overview for an unconfigured account', async () => { const { status, json } = await call(app, 'GET', '/api/growth'); expect(status).toBe(200); // Parsing with the shared package schema is what stops the server-side // mirror in growth-contracts.ts drifting from what the dashboard expects. const parsed = GrowthOverviewResponseSchema.parse(json); expect(parsed.accounts).toHaveLength(1); expect(parsed.accounts[0]).toMatchObject({ accountId: ACCOUNT, redditUsername: 'grower', subredditCount: 0, activeSubredditCount: 0, }); expect(parsed.accounts[0].settings).toMatchObject({ enabled: false, trustLevel: 0, dailyAllowance: 2, pausedReason: null, }); }); it('reports the raised allowance at a higher trust level', async () => { repo.setTrustLevel(ACCOUNT, 3); const { json } = await call(app, 'GET', '/api/growth'); expect(json.accounts[0].settings).toMatchObject({ trustLevel: 3, dailyAllowance: 8 }); }); it('returns 503 when growth storage is not wired up', async () => { const bare = express(); bare.use(express.json()); bare.use('/api', createGrowthRouter({ services: {}, broadcaster: { push: () => undefined }, } as unknown as WebContext)); const { status, json } = await call(bare, 'GET', '/api/growth'); expect(status).toBe(503); expect(json.error).toMatch(/unavailable/); }); }); describe('GET /api/growth/:accountId', () => { it('returns settings, communities and comment history', async () => { repo.addSubreddit(ACCOUNT, 'cats', { weight: 2, dailyCap: 3 }); repo.enqueueDiscovered(ACCOUNT, [thread('a')], 1_000); const { status, json } = await call(app, 'GET', `/api/growth/${ENCODED}`); expect(status).toBe(200); const parsed = GrowthAccountResponseSchema.parse(json); expect(parsed.account.subreddits).toEqual([expect.objectContaining({ subreddit: 'cats', weight: 2, dailyCap: 3, enabled: true, postedLast24h: 0, })]); expect(parsed.account.recentComments).toEqual([expect.objectContaining({ postId: 'a', subreddit: 'cats', status: 'discovered', removed: false, })]); expect(parsed.account.stats.queued).toBe(1); }); it('404s for an unknown account', async () => { const { status, json } = await call(app, 'GET', '/api/growth/nobody'); expect(status).toBe(404); expect(json.error).toBe('Reddit account not found'); }); }); describe('PATCH /api/growth/:accountId', () => { it('enables growth and logs it for the operator', async () => { const { status, json } = await call(app, 'PATCH', `/api/growth/${ENCODED}`, { enabled: true, minDelayMs: 1_800_000, quietHours: { enabled: true, startHour: 23, endHour: 7 }, }); expect(status).toBe(200); GrowthAccountResponseSchema.parse(json); expect(json.account.settings).toMatchObject({ enabled: true, minDelayMs: 1_800_000, quietHours: { enabled: true, startHour: 23, endHour: 7 }, }); expect(repo.getSettings(ACCOUNT).enabled).toBe(true); expect(pushed).toContainEqual({ level: 'info', message: 'Account growth enabled for u/grower', }); }); it('sets and clears the daily limit override', async () => { const set = await call(app, 'PATCH', `/api/growth/${ENCODED}`, { dailyLimit: 7 }); expect(set.status).toBe(200); GrowthAccountResponseSchema.parse(set.json); expect(set.json.account.settings).toMatchObject({ dailyLimit: 7, dailyAllowance: 7, adaptiveAllowance: 2, }); const cleared = await call(app, 'PATCH', `/api/growth/${ENCODED}`, { dailyLimit: null }); expect(cleared.json.account.settings).toMatchObject({ dailyLimit: null, dailyAllowance: 2, adaptiveAllowance: 2, }); }); it('reports the override as the enforced allowance even below the earned one', async () => { repo.setTrustLevel(ACCOUNT, 4); await call(app, 'PATCH', `/api/growth/${ENCODED}`, { dailyLimit: 1 }); const { json } = await call(app, 'GET', `/api/growth/${ENCODED}`); expect(json.account.settings).toMatchObject({ dailyLimit: 1, dailyAllowance: 1, adaptiveAllowance: 12, }); }); it.each([0, 51, 2.5])('rejects a daily limit of %s', async (dailyLimit) => { const { status } = await call(app, 'PATCH', `/api/growth/${ENCODED}`, { dailyLimit }); expect(status).toBe(400); }); it('never resets an earned trust level', async () => { repo.setTrustLevel(ACCOUNT, 2); await call(app, 'PATCH', `/api/growth/${ENCODED}`, { enabled: true }); expect(repo.getSettings(ACCOUNT).trustLevel).toBe(2); }); it.each([ [{ enabled: 'yes' }], [{ minDelayMs: -1 }], [{ quietHours: { enabled: true, startHour: 24, endHour: 3 } }], [{ unknownField: true }], [{ jitterMs: 99_999_999 }], ])('rejects invalid settings %o', async (body) => { const { status, json } = await call(app, 'PATCH', `/api/growth/${ENCODED}`, body); expect(status).toBe(400); expect(json.error).toMatch(/Invalid growth settings/); }); }); describe('pause and resume', () => { it('pauses with a default reason and resumes', async () => { const paused = await call(app, 'POST', `/api/growth/${ENCODED}/pause`, {}); expect(paused.status).toBe(200); expect(paused.json.account.settings.pausedReason).toBe('Paused by the operator'); const resumed = await call(app, 'POST', `/api/growth/${ENCODED}/resume`); expect(resumed.status).toBe(200); expect(resumed.json.account.settings.pausedReason).toBeNull(); }); it('accepts an operator-supplied reason', async () => { const { json } = await call(app, 'POST', `/api/growth/${ENCODED}/pause`, { reason: 'Checking a removal by hand', }); expect(json.account.settings.pausedReason).toBe('Checking a removal by hand'); expect(pushed).toContainEqual({ level: 'warn', message: 'Account growth paused for u/grower' }); }); it('clears the removal streak on resume', async () => { repo.setConsecutiveRemovals(ACCOUNT, 2); repo.pause(ACCOUNT, 'Two removals'); const { json } = await call(app, 'POST', `/api/growth/${ENCODED}/resume`); expect(json.account.settings.consecutiveRemovals).toBe(0); }); }); describe('subreddit list', () => { it('adds a community and returns 201', async () => { const { status, json } = await call(app, 'POST', `/api/growth/${ENCODED}/subreddits`, { subreddit: 'r/AskUK', dailyCap: 3, }); expect(status).toBe(201); GrowthAccountResponseSchema.parse(json); expect(json.account.subreddits).toEqual([expect.objectContaining({ subreddit: 'AskUK', dailyCap: 3, })]); }); it('rejects a malformed subreddit name', async () => { const { status, json } = await call(app, 'POST', `/api/growth/${ENCODED}/subreddits`, { subreddit: 'not a subreddit', }); expect(status).toBe(400); expect(json.error).toMatch(/not a valid subreddit/); }); it('rejects an out-of-range cap', async () => { const { status } = await call(app, 'POST', `/api/growth/${ENCODED}/subreddits`, { subreddit: 'cats', dailyCap: 500, }); expect(status).toBe(400); }); it('updates weight, cap and enabled state', async () => { repo.addSubreddit(ACCOUNT, 'cats'); const { status, json } = await call(app, 'PATCH', `/api/growth/${ENCODED}/subreddits/cats`, { weight: 5, dailyCap: 4, enabled: false, }); expect(status).toBe(200); expect(json.account.subreddits[0]).toMatchObject({ subreddit: 'cats', weight: 5, dailyCap: 4, enabled: false, }); }); it('404s when updating a community not on the list', async () => { const { status, json } = await call(app, 'PATCH', `/api/growth/${ENCODED}/subreddits/dogs`, { enabled: false, }); expect(status).toBe(404); expect(json.error).toMatch(/not on this account's list/); }); it('removes a community', async () => { repo.addSubreddit(ACCOUNT, 'cats'); const { status, json } = await call(app, 'DELETE', `/api/growth/${ENCODED}/subreddits/cats`); expect(status).toBe(200); expect(json.account.subreddits).toEqual([]); }); it('404s when removing a community not on the list', async () => { const { status } = await call(app, 'DELETE', `/api/growth/${ENCODED}/subreddits/dogs`); expect(status).toBe(404); }); it('surfaces a Reddit-imposed disable reason and cooldown', async () => { repo.addSubreddit(ACCOUNT, 'cats'); repo.disableSubreddit(ACCOUNT, 'cats', 'banned (r/cats)'); repo.addSubreddit(ACCOUNT, 'dogs'); repo.cooldownSubreddit(ACCOUNT, 'dogs', 9_999_999_999_999); const { json } = await call(app, 'GET', `/api/growth/${ENCODED}`); const parsed = GrowthAccountResponseSchema.parse(json); expect(parsed.account.subreddits[0]).toMatchObject({ subreddit: 'cats', enabled: false, disabledReason: 'banned (r/cats)', }); expect(parsed.account.subreddits[1].cooldownUntil).toBe(9_999_999_999_999); expect(parsed.account.activeSubredditCount).toBe(0); }); }); it('scopes every route to one account', async () => { const other = '@t2_other:reddit.com'; 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 (?, 'other', ?, 'verified', 1, '[]', '{}', 0, 0) `).run(other, other); repo.addSubreddit(ACCOUNT, 'cats'); // The stub account service only knows the first account, so the second is // correctly treated as unknown by the route. const { status } = await call(app, 'GET', `/api/growth/${encodeURIComponent(other)}`); expect(status).toBe(404); expect(repo.listSubreddits(other)).toEqual([]); }); });