Persist comment source monitoring

This commit is contained in:
jjbrunton committed 2026-08-14 13:40:55 +01:00
1 parent 5e1fc5def9
commit 8d932fcf36
16 files changed
+376 -24

No files matched your search

+64
View File
@@ -0,0 +1,64 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import Database from 'better-sqlite3';
import { runMigrations } from '../src/database/client.js';
import { CommentSourceWatchRepository } from '../src/database/comment-source-watch-repository.js';
import type { BrowsingThing, RedditPost } from '../src/reddit/api.js';
function post(id: string, createdAtMs: number): RedditPost {
return {
id, name: id, subreddit: 'scene', author: `author-${id}`, title: id,
url: `https://example.test/${id}.jpg`, permalink: `/r/scene/comments/${id}/`, thumbnail: '',
is_self: false, created_utc: createdAtMs / 1_000, over_18: true, spoiler: false,
};
}
function comment(text: string): BrowsingThing {
return {
fullname: 't1_comment', kind: 'comment', subreddit: 'scene', author: 'reader',
permalink: '/r/scene/comments/source/post/comment/', createdAt: 2_000, score: 1,
text, nsfw: true, parentPostId: 'source', distinguished: null, removed: false,
};
}
describe('CommentSourceWatchRepository', () => {
let db: Database.Database;
let repo: CommentSourceWatchRepository;
beforeEach(() => {
db = new Database(':memory:');
runMigrations(db);
repo = new CommentSourceWatchRepository(db);
});
afterEach(() => db.close());
it('durably rotates source posts until their configured watch window expires', () => {
expect(repo.refreshSources([post('older', 1_000), post('newer', 2_000)], 10_000, 3_000))
.toEqual({ added: 2, active: 2, expired: 0 });
expect(repo.nextSource('SCENE', 3_000)?.id).toBe('newer');
repo.markChecked('newer', 3_100);
expect(repo.nextSource('scene', 3_200)?.id).toBe('older');
const restored = new CommentSourceWatchRepository(db);
expect(restored.nextSource('scene', 3_200)?.id).toBe('older');
expect(restored.refreshSources([], 10_000, 12_001)).toEqual({ added: 0, active: 0, expired: 2 });
});
it('reuses unchanged verdicts across restarts and reclassifies edited bodies', () => {
const original = comment('I would love that done to me');
const verdict = {
id: original.fullname, qualifies: true, expressedGender: 'female' as const,
confidence: 0.94, evidence: 'recipient role',
};
repo.recordVerdict(original, verdict, 4_000);
expect(new CommentSourceWatchRepository(db).getVerdict(original)).toEqual({ ...verdict, evidence: '' });
const stored = db.prepare(`
SELECT body_sha256, verdict_json FROM comment_source_comment_observations WHERE comment_id = ?
`).get(original.fullname);
expect(JSON.stringify(stored)).not.toContain(original.text);
expect(JSON.stringify(stored)).not.toContain(verdict.evidence);
expect(repo.getVerdict(comment('Edited: I would really love that done to me'))).toBeNull();
});
});
+3 -1
View File
@@ -108,9 +108,11 @@ 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(33);
expect(tables.length).toBe(35);
expect(tables.map((table) => table.name)).toEqual(expect.arrayContaining([
'candidate_queue',
'comment_source_watches',
'comment_source_comment_observations',
'conversation_events',
'conversation_learning_trajectories',
'conversation_learning_decisions',
+57
View File
@@ -9,6 +9,9 @@ import {
import type { PostsRepository, MessagesRepository } from '../src/database/repositories.js';
import { createMockLogger } from '../tests/mockLogger.js';
import { getTestConfig } from '../tests/testUtils.js';
import Database from 'better-sqlite3';
import { runMigrations } from '../src/database/client.js';
import { CommentSourceWatchRepository } from '../src/database/comment-source-watch-repository.js';
function createMockRedditApi(posts: RedditPost[] = []): RedditApiClient {
return {
@@ -677,6 +680,60 @@ describe('MonitorService', () => {
});
describe('comment-author targeting', () => {
it('keeps watching a durable source and classifies only new comments newest-first', async () => {
const db = new Database(':memory:');
runMigrations(db);
try {
const source: RedditPost = {
id: 'post1', name: 't3_post1', subreddit: 'scene', author: 'poster', title: 'A scene',
url: 'https://example.com/scene.jpg', permalink: '/r/scene/comments/post1/', thumbnail: '',
is_self: false, created_utc: Date.now() / 1_000, over_18: true, spoiler: false,
};
const first = {
fullname: 't1_first', kind: 'comment' as const, subreddit: 'scene', author: 'first',
permalink: '/first', createdAt: Date.now(), score: 1, text: 'As a woman I would love that done to me',
nsfw: true, parentPostId: 'post1', distinguished: null, removed: false,
};
const second = { ...first, fullname: 't1_second', author: 'second', permalink: '/second' };
const redditApi = createMockRedditApi([source]);
redditApi.getNewPosts = vi.fn()
.mockResolvedValueOnce([source])
.mockResolvedValueOnce([]);
redditApi.getBrowsingComments = vi.fn()
.mockResolvedValueOnce([first])
.mockResolvedValueOnce([second, first]);
const screener = { screenCommentReactions: vi.fn(async (comments) => comments.map((item) => ({
id: item.id, qualifies: true, expressedGender: 'female' as const, confidence: 0.95, evidence: 'recipient role',
}))) };
const config = getTestConfig({ reddit: {
subreddits: [{ name: 'scene', type: 'image', targetingMode: 'comment-author' }],
targetGenders: ['female'], recentWindowMinutes: 600,
} });
monitor = new MonitorService(
redditApi, createMockPostsRepo(), createMockMessagesRepo(), config, logger, 0, undefined, screener,
new CommentSourceWatchRepository(db),
);
await expect(monitor.fetchCandidates(config.reddit.subreddits[0]))
.resolves.toEqual([expect.objectContaining({ id: 't1_first' })]);
await expect(monitor.fetchCandidates(config.reddit.subreddits[0]))
.resolves.toEqual(expect.arrayContaining([
expect.objectContaining({ id: 't1_first' }),
expect.objectContaining({ id: 't1_second' }),
]));
expect(screener.screenCommentReactions).toHaveBeenNthCalledWith(1, [
{ id: 't1_first', text: first.text },
]);
expect(screener.screenCommentReactions).toHaveBeenNthCalledWith(2, [
{ id: 't1_second', text: second.text },
]);
expect(redditApi.getNewPosts).toHaveBeenCalledWith('scene', 100, 'image');
expect(redditApi.getBrowsingComments).toHaveBeenCalledWith('scene', 'post1', 100, 'new');
} finally {
db.close();
}
});
it('turns a high-confidence targeted reaction into a commenter candidate with source media intact', async () => {
const source: RedditPost = {
id: 'post1', name: 't3_post1', subreddit: 'scene', author: 'poster', title: 'A scene',
+14
View File
@@ -54,6 +54,20 @@ describe('RedditApiClient', () => {
params: { limit: 10, depth: 1, sort: 'hot', raw_json: 1 }, headers: {},
});
});
it('can request newest comments for a durable source-post watch', async () => {
const get = vi.fn().mockResolvedValue({
data: [{ data: { children: [] } }, { data: { children: [] } }],
});
vi.mocked(axios.create).mockReturnValue({ get } as unknown as AxiosInstance);
const client2 = new RedditApiClient(getTestConfig(), logger);
await client2.getBrowsingComments('scene', 'source', 100, 'new');
expect(get).toHaveBeenCalledWith('/r/scene/comments/source.json', {
params: { limit: 100, depth: 1, sort: 'new', raw_json: 1 }, headers: {},
});
});
});
describe('hasImage', () => {