feat: add agent API and operational analytics
This commit is contained in:
1 parent
720ea0652a
commit
e8b730b592
65 files changed
+2650
-1214
No files matched your search
@@ -0,0 +1,22 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { AgentJobManager } from '../src/web/agent-job-manager.js';
|
||||
|
||||
describe('AgentJobManager', () => {
|
||||
it('tracks an asynchronous job through completion', async () => {
|
||||
const execute = vi.fn(async () => ({ processed: 4 }));
|
||||
const manager = new AgentJobManager(execute);
|
||||
const created = manager.create({ type: 'chat-sync', kind: 'delta' });
|
||||
|
||||
expect(created.status).toBe('queued');
|
||||
await vi.waitFor(() => expect(manager.get(created.id)?.status).toBe('succeeded'));
|
||||
expect(manager.get(created.id)).toMatchObject({ result: { processed: 4 }, error: null });
|
||||
});
|
||||
|
||||
it('records executor failures without losing the job', async () => {
|
||||
const manager = new AgentJobManager(async () => { throw new Error('sync unavailable'); });
|
||||
const created = manager.create({ type: 'media-sync', kind: 'full' });
|
||||
|
||||
await vi.waitFor(() => expect(manager.get(created.id)?.status).toBe('failed'));
|
||||
expect(manager.get(created.id)?.error).toBe('sync unavailable');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { API_OPERATIONS, capabilities, createOpenApiDocument } from '../src/web/api-catalog.js';
|
||||
|
||||
describe('agent API discovery', () => {
|
||||
it('publishes every operation under a unique method and path', () => {
|
||||
const keys = API_OPERATIONS.map((operation) => `${operation.method} ${operation.path}`);
|
||||
expect(new Set(keys).size).toBe(keys.length);
|
||||
expect(keys).toContain('GET /api/v1/overview');
|
||||
expect(keys).toContain('POST /api/v1/control/start');
|
||||
expect(keys).toContain('POST /api/v1/control/stop');
|
||||
expect(keys).toContain('POST /api/v1/jobs');
|
||||
expect(keys).toContain('GET /api/events/stream');
|
||||
});
|
||||
|
||||
it('cannot drift from the Express route modules', () => {
|
||||
const routesDirectory = path.resolve(process.cwd(), 'src/web/routes');
|
||||
const implemented = fs.readdirSync(routesDirectory)
|
||||
.filter((file) => file.endsWith('.ts'))
|
||||
.flatMap((file) => {
|
||||
const source = fs.readFileSync(path.join(routesDirectory, file), 'utf8');
|
||||
return [...source.matchAll(/router\.(get|post|put|delete|patch)\('([^']+)'/g)].map((match) => {
|
||||
const routePath = match[2] === '/healthz' ? match[2] : `/api${match[2]}`;
|
||||
return `${match[1].toUpperCase()} ${routePath.replace(/:([A-Za-z0-9_]+)/g, '{$1}')}`;
|
||||
});
|
||||
});
|
||||
const catalogued = API_OPERATIONS.map((item) => `${item.method} ${item.path}`);
|
||||
|
||||
expect([...new Set(catalogued)].sort()).toEqual([...new Set(implemented)].sort());
|
||||
});
|
||||
|
||||
it('generates OpenAPI with safety metadata and schemas for versioned operations', () => {
|
||||
const document = createOpenApiDocument() as { openapi: string; paths: Record<string, Record<string, Record<string, unknown>>> };
|
||||
expect(document.openapi).toBe('3.1.0');
|
||||
expect(document.paths['/healthz'].get.security).toEqual([]);
|
||||
expect(document.paths['/api/v1/overview'].get['x-access']).toBe('read');
|
||||
expect(document.paths['/api/v1/control/start'].post['x-access']).toBe('control');
|
||||
expect(document.paths['/api/v1/control/start'].post['x-mutates']).toBe(true);
|
||||
expect(document.paths['/api/v1/jobs'].post.requestBody).toBeDefined();
|
||||
expect((document.paths['/api/v1/jobs'].post.responses as Record<string, unknown>)['202']).toBeDefined();
|
||||
});
|
||||
|
||||
it('returns a compact capability catalogue for agents', () => {
|
||||
const result = capabilities();
|
||||
expect(result.authentication.agentScopes).toEqual(['read', 'control', 'admin']);
|
||||
expect(result.operations.find((item) => item.operationId === 'sendReply')).toMatchObject({
|
||||
access: 'admin',
|
||||
mutates: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -85,13 +85,9 @@ describe('ChatService progressive full backfill', () => {
|
||||
expect(repos.messagesRepo.getResponseRateTrend()).toEqual([
|
||||
expect.objectContaining({ totalSent: 1, totalResponses: 1, responseRate: 100 }),
|
||||
]);
|
||||
expect(repos.successfulMessagesRepo.getRecent()).toEqual([
|
||||
expect.objectContaining({
|
||||
originalMessage: 'Hello Alice',
|
||||
postTitle: 'Source post',
|
||||
subreddit: 'example',
|
||||
}),
|
||||
]);
|
||||
// A single reply counts toward response performance, but the
|
||||
// reciprocal-engagement policy excludes it from learning examples.
|
||||
expect(repos.successfulMessagesRepo.getRecent()).toEqual([]);
|
||||
} finally {
|
||||
db.close();
|
||||
await fs.rm(tmpDir, { recursive: true, force: true });
|
||||
|
||||
@@ -42,11 +42,15 @@ describe('ConfigStore', () => {
|
||||
const config = getTestConfig();
|
||||
config.openrouter.apiKey = 'database-must-not-contain-this';
|
||||
config.web.authToken = 'also-secret';
|
||||
config.web.agentReadToken = 'read-agent-secret';
|
||||
config.web.agentControlToken = 'control-agent-secret';
|
||||
store.save(config);
|
||||
|
||||
const row = db.prepare('SELECT config_json FROM app_config WHERE id = 1').get() as { config_json: string };
|
||||
expect(row.config_json).not.toContain('database-must-not-contain-this');
|
||||
expect(row.config_json).not.toContain('also-secret');
|
||||
expect(row.config_json).not.toContain('read-agent-secret');
|
||||
expect(row.config_json).not.toContain('control-agent-secret');
|
||||
expect(JSON.parse(row.config_json)).not.toHaveProperty('database');
|
||||
});
|
||||
|
||||
|
||||
@@ -221,7 +221,8 @@ describe('ConfigSchema', () => {
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.strategies.explorationRate).toBe(0.2);
|
||||
expect(result.data.strategies.minSamples).toBe(5);
|
||||
expect(result.data.strategies.minSamples).toBe(30);
|
||||
expect(result.data.strategies.responseWindowHours).toBe(48);
|
||||
expect(result.data.strategies.variants).toEqual([]);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import type {
|
||||
ConversationOutcomesRepository,
|
||||
MessagesRepository,
|
||||
SentMessageRecord,
|
||||
SuccessfulMessagesRepository,
|
||||
} from '../src/database/repositories.js';
|
||||
import { recordConversationReply } from '../src/services/conversation-outcome-recorder.js';
|
||||
import { createMockLogger } from './mockLogger.js';
|
||||
|
||||
function message(): SentMessageRecord {
|
||||
return {
|
||||
id: 1,
|
||||
postId: 'post-1',
|
||||
recipient: 'alice',
|
||||
subject: 'alice',
|
||||
message: 'Hello',
|
||||
sentAt: 1,
|
||||
gotResponse: false,
|
||||
gotImage: false,
|
||||
responseCheckedAt: null,
|
||||
strategyId: 'direct',
|
||||
chatRoomId: 'room-1',
|
||||
subreddit: 'example',
|
||||
};
|
||||
}
|
||||
|
||||
function dependencies() {
|
||||
return {
|
||||
messages: { markGotResponse: vi.fn() } as unknown as MessagesRepository,
|
||||
successful: { insert: vi.fn(), existsByMessage: vi.fn(() => false) } as unknown as SuccessfulMessagesRepository,
|
||||
outcomes: { record: vi.fn(), suppressRecipient: vi.fn() } as unknown as ConversationOutcomesRepository,
|
||||
logger: createMockLogger(),
|
||||
};
|
||||
}
|
||||
|
||||
describe('recordConversationReply', () => {
|
||||
it('records a first reply but excludes it from learning until engagement is reciprocal', () => {
|
||||
const deps = dependencies();
|
||||
expect(recordConversationReply(message(), 'Thanks!', { messageCount: 2, reciprocal: false }, deps)).toBe('unknown');
|
||||
expect(deps.messages.markGotResponse).toHaveBeenCalledWith(1);
|
||||
expect(deps.successful.insert).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('stores only reciprocal engaged conversations as learning examples', () => {
|
||||
const deps = dependencies();
|
||||
expect(recordConversationReply(message(), 'Tell me more', { messageCount: 4, reciprocal: true }, deps)).toBe('engaged');
|
||||
expect(deps.successful.insert).toHaveBeenCalledWith(expect.objectContaining({ sentMessageId: 1 }));
|
||||
});
|
||||
|
||||
it('permanently suppresses a recipient after a boundary response', () => {
|
||||
const deps = dependencies();
|
||||
expect(recordConversationReply(message(), 'Stop messaging me', { messageCount: 2 }, deps)).toBe('boundary');
|
||||
expect(deps.outcomes.suppressRecipient).toHaveBeenCalledWith('alice', 'boundary');
|
||||
expect(deps.successful.insert).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -3,11 +3,18 @@ import { classifyConversationOutcome } from '../src/services/conversation-outcom
|
||||
|
||||
describe('conversation outcome classification', () => {
|
||||
it.each([
|
||||
['That sounds fun, tell me more', 'engaged'],
|
||||
['That sounds fun, tell me more', 'unknown'],
|
||||
['No thanks, not interested', 'declined'],
|
||||
['Check my OnlyFans link in bio', 'upsell'],
|
||||
['Stop messaging me', 'boundary'],
|
||||
] as const)('classifies %s as %s', (reply, expected) => {
|
||||
expect(classifyConversationOutcome(reply)).toBe(expected);
|
||||
});
|
||||
|
||||
it('classifies a reciprocal multi-message conversation as engaged', () => {
|
||||
expect(classifyConversationOutcome('That sounds fun, tell me more', {
|
||||
messageCount: 4,
|
||||
reciprocal: true,
|
||||
})).toBe('engaged');
|
||||
});
|
||||
});
|
||||
@@ -68,11 +68,26 @@ describe('PerformancePage', () => {
|
||||
},
|
||||
]),
|
||||
),
|
||||
http.get('/api/analytics/funnel', () => HttpResponse.json({
|
||||
responseWindowHours: 48,
|
||||
processed: 100,
|
||||
sent: 40,
|
||||
pending: 5,
|
||||
mature: 35,
|
||||
responded: 12,
|
||||
engaged: 4,
|
||||
declined: 2,
|
||||
boundary: 1,
|
||||
upsell: 3,
|
||||
images: 2,
|
||||
})),
|
||||
);
|
||||
|
||||
renderPage(<PerformancePage />);
|
||||
|
||||
expect(await screen.findByRole('heading', { name: 'Performance' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('heading', { name: 'Outcome funnel' })).toBeInTheDocument();
|
||||
expect(screen.getByText('5 awaiting maturity')).toBeInTheDocument();
|
||||
const subredditTable = screen.getByRole('table', { name: 'Subreddit performance' });
|
||||
expect(within(subredditTable).getByText('n = 40')).toBeInTheDocument();
|
||||
expect(within(subredditTable).getByText('n = 3')).toBeInTheDocument();
|
||||
|
||||
@@ -107,7 +107,7 @@ describe('Database Client', () => {
|
||||
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'")
|
||||
.all() as { name: string }[];
|
||||
// Existing domain/config tables plus account, conversation, sync and media ownership stores.
|
||||
expect(tables.length).toBe(13);
|
||||
expect(tables.length).toBe(14);
|
||||
});
|
||||
|
||||
it('tracks schema version and migrates a legacy sent_messages table transactionally', () => {
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { RedditBotApiClient, RedditBotApiError } from '../src/mcp/api-client.js';
|
||||
|
||||
describe('RedditBotApiClient', () => {
|
||||
it('uses a bearer token and parses JSON responses', async () => {
|
||||
const fetchImplementation = vi.fn(async () => new Response(JSON.stringify({ running: true }), { status: 200 }));
|
||||
const client = new RedditBotApiClient({
|
||||
baseUrl: 'http://redditbot.test/',
|
||||
token: 'read-token',
|
||||
fetchImplementation: fetchImplementation as typeof fetch,
|
||||
});
|
||||
|
||||
await expect(client.get('/api/v1/control')).resolves.toEqual({ running: true });
|
||||
expect(fetchImplementation).toHaveBeenCalledWith('http://redditbot.test/api/v1/control', expect.objectContaining({
|
||||
headers: expect.objectContaining({ Authorization: 'Bearer read-token' }),
|
||||
}));
|
||||
});
|
||||
|
||||
it('returns structured API errors to MCP tools', async () => {
|
||||
const client = new RedditBotApiClient({
|
||||
token: 'read-token',
|
||||
fetchImplementation: vi.fn(async () => new Response(JSON.stringify({ error: 'Agent credential lacks the required access' }), { status: 403 })) as typeof fetch,
|
||||
});
|
||||
|
||||
await expect(client.post('/api/v1/control/start')).rejects.toEqual(
|
||||
new RedditBotApiError('Agent credential lacks the required access', 403),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
||||
import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { RedditBotApiClient } from '../src/mcp/api-client.js';
|
||||
import { createRedditBotMcpServer } from '../src/mcp/server.js';
|
||||
|
||||
describe('RedditBot MCP server', () => {
|
||||
const closeables: Array<{ close(): Promise<void> }> = [];
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(closeables.splice(0).map((item) => item.close()));
|
||||
});
|
||||
|
||||
async function connectedClient() {
|
||||
const get = vi.fn(async () => ({ status: 'operational' }));
|
||||
const post = vi.fn(async () => ({ success: true, running: true }));
|
||||
const server = createRedditBotMcpServer({ get, post } as unknown as RedditBotApiClient);
|
||||
const client = new Client({ name: 'test-client', version: '1.0.0' });
|
||||
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
||||
await Promise.all([server.connect(serverTransport), client.connect(clientTransport)]);
|
||||
closeables.push(client, server);
|
||||
return { client, get, post };
|
||||
}
|
||||
|
||||
it('advertises read, control, and explicit external-send tools', async () => {
|
||||
const { client } = await connectedClient();
|
||||
const tools = await client.listTools();
|
||||
const names = tools.tools.map((tool) => tool.name);
|
||||
|
||||
expect(names).toEqual(expect.arrayContaining([
|
||||
'get_overview', 'list_accounts', 'list_conversations', 'start_bot', 'stop_bot',
|
||||
'set_bot_running', 'trigger_sync', 'send_reply', 'start_conversation',
|
||||
]));
|
||||
expect(tools.tools.find((tool) => tool.name === 'get_overview')?.annotations?.readOnlyHint).toBe(true);
|
||||
expect(tools.tools.find((tool) => tool.name === 'send_reply')?.annotations?.destructiveHint).toBe(true);
|
||||
});
|
||||
|
||||
it('routes start and stop tools to the versioned control API', async () => {
|
||||
const { client, post } = await connectedClient();
|
||||
|
||||
await client.callTool({ name: 'start_bot', arguments: {} });
|
||||
await client.callTool({ name: 'stop_bot', arguments: {} });
|
||||
|
||||
expect(post).toHaveBeenNthCalledWith(1, '/api/v1/control/start');
|
||||
expect(post).toHaveBeenNthCalledWith(2, '/api/v1/control/stop');
|
||||
});
|
||||
});
|
||||
+20
-3
@@ -17,6 +17,7 @@ function createMockRedditApi(posts: RedditPost[] = []): RedditApiClient {
|
||||
function createMockPostsRepo(processedIds: Set<string> = new Set()): PostsRepository {
|
||||
return {
|
||||
isProcessed: vi.fn((id: string) => processedIds.has(id)),
|
||||
isProcessedAnywhere: vi.fn((id: string) => processedIds.has(id)),
|
||||
insert: vi.fn(),
|
||||
updateAnalysis: vi.fn(),
|
||||
markMessageSent: vi.fn(),
|
||||
@@ -86,6 +87,23 @@ describe('MonitorService', () => {
|
||||
expect(result.length).toBe(0);
|
||||
});
|
||||
|
||||
it('uses shared post history so another account cannot rediscover a post', async () => {
|
||||
const post: RedditPost = {
|
||||
id: 't3_shared', subreddit: 'sub1', author: 'user1', title: 'Post',
|
||||
url: 'http://example.com/1.jpg', permalink: '/r/sub1/1', thumbnail: '',
|
||||
is_self: false, created_utc: Date.now() / 1000, over_18: false, spoiler: false,
|
||||
};
|
||||
const redditApi = createMockRedditApi([post]);
|
||||
const postsRepo = createMockPostsRepo();
|
||||
vi.mocked(postsRepo.isProcessed).mockReturnValue(false);
|
||||
vi.mocked(postsRepo.isProcessedAnywhere).mockReturnValue(true);
|
||||
const config = getTestConfig({ reddit: { subreddits: [{ name: 'sub1', type: 'image' }] } });
|
||||
monitor = new MonitorService(redditApi, postsRepo, createMockMessagesRepo(), config, logger, 0);
|
||||
|
||||
await expect(monitor.getUnprocessedPosts()).resolves.toEqual([]);
|
||||
expect(postsRepo.isProcessedAnywhere).toHaveBeenCalledWith(post.id);
|
||||
});
|
||||
|
||||
it('skips posts from users already messaged', async () => {
|
||||
const post1: RedditPost = {
|
||||
id: 't3_1', subreddit: 'sub1', author: 'already_msgd', title: 'Post 1',
|
||||
@@ -370,7 +388,7 @@ describe('MonitorService', () => {
|
||||
// (We can't directly test the shuffle, but we can verify it doesn't crash)
|
||||
});
|
||||
|
||||
it('limits results to 10 per author via postsRepo', async () => {
|
||||
it('checks shared processed-post history before considering a candidate', async () => {
|
||||
const post: RedditPost = {
|
||||
id: 't3_1', subreddit: 'sub1', author: 'user1', title: 'Post',
|
||||
url: 'http://example.com/1.jpg', permalink: '/r/sub1/1', thumbnail: '',
|
||||
@@ -385,8 +403,7 @@ describe('MonitorService', () => {
|
||||
monitor = new MonitorService(redditApi, postsRepo, messagesRepo, config, logger, 0);
|
||||
await monitor.getUnprocessedPosts();
|
||||
|
||||
// Verify isProcessed was called
|
||||
expect(postsRepo.isProcessed).toHaveBeenCalledWith('t3_1');
|
||||
expect(postsRepo.isProcessedAnywhere).toHaveBeenCalledWith('t3_1');
|
||||
});
|
||||
|
||||
describe('image/chat interleaving', () => {
|
||||
|
||||
@@ -3,6 +3,7 @@ import axios from 'axios';
|
||||
import type { AxiosInstance } from 'axios';
|
||||
import type { BrowserContext } from 'playwright';
|
||||
import { RedditApiClient, RedditRateLimitError } from '../src/reddit/api.js';
|
||||
import type { RedditRequestScheduler } from '../src/reddit/request-coordinator.js';
|
||||
import type { RedditPost } from '../src/reddit/api.js';
|
||||
import { createMockLogger } from '../tests/mockLogger.js';
|
||||
import { getTestConfig } from '../tests/testUtils.js';
|
||||
@@ -517,6 +518,23 @@ describe('RedditApiClient', () => {
|
||||
)).toBe(true);
|
||||
});
|
||||
|
||||
it('extends the shared request cooldown when Reddit returns 429', async () => {
|
||||
const error = {
|
||||
isAxiosError: true,
|
||||
response: { status: 429, headers: { 'retry-after': '12' } },
|
||||
};
|
||||
const mockAxios = { get: vi.fn().mockRejectedValue(error) };
|
||||
vi.mocked(axios.create).mockReturnValue(mockAxios as unknown as AxiosInstance);
|
||||
const scheduler: RedditRequestScheduler = {
|
||||
schedule: async (request) => await request(),
|
||||
extendCooldown: vi.fn(),
|
||||
};
|
||||
const client2 = new RedditApiClient(getTestConfig(), logger, scheduler);
|
||||
|
||||
await expect(client2.getNewPosts('test')).rejects.toMatchObject({ retryAfterMs: 12_000 });
|
||||
expect(scheduler.extendCooldown).toHaveBeenCalledWith(12_000);
|
||||
});
|
||||
|
||||
it('parses valid image posts from response', async () => {
|
||||
const mockResponse = {
|
||||
data: { data: { children: [
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { RedditRequestCoordinator } from '../src/reddit/request-coordinator.js';
|
||||
|
||||
describe('RedditRequestCoordinator', () => {
|
||||
it('paces requests and applies a shared cooldown to the next queued request', async () => {
|
||||
let now = 0;
|
||||
const sleeps: number[] = [];
|
||||
const coordinator = new RedditRequestCoordinator(6_500, {
|
||||
now: () => now,
|
||||
sleep: async (ms) => { sleeps.push(ms); now += ms; },
|
||||
});
|
||||
|
||||
await coordinator.schedule(async () => undefined);
|
||||
coordinator.extendCooldown(30_000);
|
||||
await coordinator.schedule(async () => undefined);
|
||||
|
||||
expect(sleeps).toEqual([31_000]);
|
||||
});
|
||||
|
||||
it('serializes concurrent requests through one process-wide queue', async () => {
|
||||
const order: string[] = [];
|
||||
let releaseFirst!: () => void;
|
||||
const firstFinished = new Promise<void>((resolve) => { releaseFirst = resolve; });
|
||||
const coordinator = new RedditRequestCoordinator(0);
|
||||
|
||||
const first = coordinator.schedule(async () => {
|
||||
order.push('first:start');
|
||||
await firstFinished;
|
||||
order.push('first:end');
|
||||
});
|
||||
const second = coordinator.schedule(async () => { order.push('second'); });
|
||||
|
||||
await Promise.resolve();
|
||||
expect(order).toEqual(['first:start']);
|
||||
releaseFirst();
|
||||
await Promise.all([first, second]);
|
||||
expect(order).toEqual(['first:start', 'first:end', 'second']);
|
||||
});
|
||||
|
||||
it('does not release a queued request before a failing request records its cooldown', async () => {
|
||||
let now = 0;
|
||||
const sleeps: number[] = [];
|
||||
const coordinator = new RedditRequestCoordinator(0, {
|
||||
now: () => now,
|
||||
sleep: async (ms) => { sleeps.push(ms); now += ms; },
|
||||
});
|
||||
|
||||
const first = coordinator.schedule(async () => {
|
||||
coordinator.extendCooldown(15_000);
|
||||
throw new Error('429');
|
||||
});
|
||||
const second = coordinator.schedule(async () => undefined);
|
||||
|
||||
await expect(first).rejects.toThrow('429');
|
||||
await second;
|
||||
expect(sleeps).toEqual([16_000]);
|
||||
});
|
||||
});
|
||||
@@ -6,6 +6,8 @@ import {
|
||||
MessagesRepository,
|
||||
SuccessfulMessagesRepository,
|
||||
ConversationStateRepository,
|
||||
ConversationOutcomesRepository,
|
||||
RecipientHistoryRepository,
|
||||
} from '../src/database/repositories.js';
|
||||
import { buildConversationState } from '../src/services/conversation-state.js';
|
||||
|
||||
@@ -607,6 +609,37 @@ describe('ConversationStateRepository', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('Mature performance and guardrails', () => {
|
||||
it('excludes pending sends and reports a confidence interval', () => {
|
||||
const { postsRepo, messagesRepo } = createTestRepositories();
|
||||
postsRepo.insert({ id: 'old', subreddit: 'test', author: 'old-user', title: 'old', imageUrl: null, processedAt: 1 });
|
||||
postsRepo.insert({ id: 'new', subreddit: 'test', author: 'new-user', title: 'new', imageUrl: null, processedAt: 1 });
|
||||
const oldId = messagesRepo.insert({
|
||||
postId: 'old', recipient: 'old-user', subject: 's', message: 'm',
|
||||
sentAt: Date.now() - 72 * 3600000, strategyId: 'direct',
|
||||
});
|
||||
messagesRepo.markGotResponse(oldId);
|
||||
messagesRepo.insert({
|
||||
postId: 'new', recipient: 'new-user', subject: 's', message: 'm',
|
||||
sentAt: Date.now(), strategyId: 'direct',
|
||||
});
|
||||
|
||||
expect(messagesRepo.getStrategyStats(Date.now() - 48 * 3600000)).toEqual([
|
||||
expect.objectContaining({
|
||||
strategyId: 'direct', totalSent: 1, totalResponses: 1, responseRate: 100,
|
||||
pendingSent: 1, responseRateLow: expect.any(Number), responseRateHigh: expect.any(Number),
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('suppresses boundary recipients across account history checks', () => {
|
||||
const db = createTestDb();
|
||||
const outcomes = new ConversationOutcomesRepository(db);
|
||||
outcomes.suppressRecipient('Alice', 'boundary');
|
||||
expect(new RecipientHistoryRepository(db).hasSentTo('alice')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Repository edge cases', () => {
|
||||
it('handles special characters in post titles', () => {
|
||||
const repos = createTestRepositories();
|
||||
|
||||
@@ -194,7 +194,7 @@ describe('ResponseCheckerService', () => {
|
||||
expect(successfulMessagesRepo.insert).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('stores successful messages for learning', async () => {
|
||||
it('does not treat a single inbox reply as an engaged learning example', async () => {
|
||||
const post: ProcessedPostRecord = {
|
||||
id: 'post1',
|
||||
subreddit: 'gonewild',
|
||||
@@ -245,13 +245,8 @@ describe('ResponseCheckerService', () => {
|
||||
});
|
||||
|
||||
await checker.checkForResponses();
|
||||
expect(successfulMessagesRepo.insert).toHaveBeenCalled();
|
||||
|
||||
const insertCall = vi.mocked(successfulMessagesRepo.insert).mock.calls[0][0];
|
||||
expect(insertCall.originalMessage).toBe('Hey gorgeous!');
|
||||
expect(insertCall.imageAnalysis).toBe('Beautiful woman');
|
||||
expect(insertCall.postTitle).toBe('My selfie');
|
||||
expect(insertCall.subreddit).toBe('gonewild');
|
||||
expect(messagesRepo.markGotResponse).toHaveBeenCalledWith(1);
|
||||
expect(successfulMessagesRepo.insert).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not turn a declined reply into a learning example', async () => {
|
||||
|
||||
@@ -14,6 +14,7 @@ export function createTestDb(): Database.Database {
|
||||
// Disable FK for tests - we manage referential integrity manually
|
||||
db.pragma('foreign_keys = OFF');
|
||||
runMigrations(db);
|
||||
db.pragma('foreign_keys = OFF');
|
||||
return db;
|
||||
}
|
||||
|
||||
@@ -77,6 +78,7 @@ export function getTestConfig(overrides?: Partial<Config>): Config {
|
||||
strategies: {
|
||||
explorationRate: 0.2,
|
||||
minSamples: 5,
|
||||
responseWindowHours: 48,
|
||||
variants: [
|
||||
{ id: 'variant-a', weight: 1, promptOverride: 'Be direct' },
|
||||
{ id: 'variant-b', weight: 1, promptOverride: 'Be teasing' },
|
||||
@@ -86,6 +88,7 @@ export function getTestConfig(overrides?: Partial<Config>): Config {
|
||||
chatStrategies: {
|
||||
explorationRate: 0.2,
|
||||
minSamples: 5,
|
||||
responseWindowHours: 48,
|
||||
variants: [],
|
||||
...overrides?.chatStrategies,
|
||||
},
|
||||
|
||||
@@ -6,6 +6,7 @@ import { afterEach, describe, expect, it } from 'vitest';
|
||||
import type { BrowserClient } from '../src/browser/client.js';
|
||||
import { ActivityLogRepository } from '../src/database/repositories.js';
|
||||
import { WebServer } from '../src/web/server.js';
|
||||
import { OverviewResponseSchema } from '../src/web/agent-api-contracts.js';
|
||||
import { createMockLogger } from './mockLogger.js';
|
||||
import { createTestDb, createTestRepositories, getTestConfig } from './testUtils.js';
|
||||
|
||||
@@ -31,13 +32,25 @@ function createWorkspace(includeReactBuild = true): string {
|
||||
return workspace;
|
||||
}
|
||||
|
||||
async function startServer(options: { debugEnabled?: boolean; includeReactBuild?: boolean } = {}): Promise<RunningServer> {
|
||||
async function startServer(options: {
|
||||
debugEnabled?: boolean;
|
||||
includeReactBuild?: boolean;
|
||||
agentReadToken?: string;
|
||||
agentControlToken?: string;
|
||||
} = {}): Promise<RunningServer> {
|
||||
const database = createTestDb();
|
||||
const repositories = createTestRepositories(database);
|
||||
const workspace = createWorkspace(options.includeReactBuild);
|
||||
const server = new WebServer(
|
||||
0,
|
||||
getTestConfig({ web: { enabled: true, port: 0, authToken: AUTH_TOKEN, debugEnabled: options.debugEnabled ?? false } }),
|
||||
getTestConfig({ web: {
|
||||
enabled: true,
|
||||
port: 0,
|
||||
authToken: AUTH_TOKEN,
|
||||
debugEnabled: options.debugEnabled ?? false,
|
||||
agentReadToken: options.agentReadToken,
|
||||
agentControlToken: options.agentControlToken,
|
||||
} }),
|
||||
repositories.postsRepo,
|
||||
repositories.messagesRepo,
|
||||
repositories.successfulMessagesRepo,
|
||||
@@ -110,4 +123,44 @@ describe('authenticated React dashboard cutover', () => {
|
||||
expect(upload.status).toBe(404);
|
||||
});
|
||||
|
||||
it('exposes public liveness and scoped discovery/control APIs', async () => {
|
||||
const running = await startServer({
|
||||
agentReadToken: 'agent-read-token',
|
||||
agentControlToken: 'agent-control-token',
|
||||
});
|
||||
runningServers.push(running);
|
||||
running.server.setAccountAuth({
|
||||
isFullyAuthenticated: () => true,
|
||||
} as never);
|
||||
|
||||
const health = await fetch(`${running.baseUrl}/healthz`);
|
||||
const openapi = await fetch(`${running.baseUrl}/api/openapi.json`, {
|
||||
headers: { Authorization: 'Bearer agent-read-token' },
|
||||
});
|
||||
const overview = await fetch(`${running.baseUrl}/api/v1/overview`, {
|
||||
headers: { Authorization: 'Bearer agent-read-token' },
|
||||
});
|
||||
const forbiddenStart = await fetch(`${running.baseUrl}/api/v1/control/start`, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: 'Bearer agent-read-token' },
|
||||
});
|
||||
const allowedStart = await fetch(`${running.baseUrl}/api/v1/control/start`, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: 'Bearer agent-control-token' },
|
||||
});
|
||||
const allowedStop = await fetch(`${running.baseUrl}/api/v1/control/stop`, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: 'Bearer agent-control-token' },
|
||||
});
|
||||
|
||||
expect(health.status).toBe(200);
|
||||
expect(await health.json()).toMatchObject({ status: 'ok', service: 'reddit-bot' });
|
||||
expect(openapi.status).toBe(200);
|
||||
expect(await openapi.json()).toMatchObject({ openapi: '3.1.0' });
|
||||
expect(OverviewResponseSchema.safeParse(await overview.json()).success).toBe(true);
|
||||
expect(forbiddenStart.status).toBe(403);
|
||||
expect(await allowedStart.json()).toMatchObject({ success: true, running: true, changed: true });
|
||||
expect(await allowedStop.json()).toMatchObject({ success: true, running: false, changed: true });
|
||||
});
|
||||
|
||||
});
|
||||
@@ -48,6 +48,28 @@ describe('dashboard authentication', () => {
|
||||
expect(await response.json()).toEqual({ ok: true });
|
||||
});
|
||||
|
||||
it('limits agent bearer tokens to their declared access', async () => {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use(createDashboardAuth('dashboard-secret', {
|
||||
readToken: 'agent-read-secret',
|
||||
controlToken: 'agent-control-secret',
|
||||
}));
|
||||
app.get('/api/v1/overview', (_req, res) => res.json({ ok: true }));
|
||||
app.post('/api/v1/control/start', (_req, res) => res.json({ ok: true }));
|
||||
app.post('/api/conversations/start', (_req, res) => res.json({ ok: true }));
|
||||
|
||||
const read = await request(app, '/api/v1/overview', { headers: { Authorization: 'Bearer agent-read-secret' } });
|
||||
const readMutation = await request(app, '/api/v1/control/start', { method: 'POST', headers: { Authorization: 'Bearer agent-read-secret' } });
|
||||
const control = await request(app, '/api/v1/control/start', { method: 'POST', headers: { Authorization: 'Bearer agent-control-secret' } });
|
||||
const legacyMutation = await request(app, '/api/conversations/start', { method: 'POST', headers: { Authorization: 'Bearer agent-control-secret' } });
|
||||
|
||||
expect(read.status).toBe(200);
|
||||
expect(readMutation.status).toBe(403);
|
||||
expect(control.status).toBe(200);
|
||||
expect(legacyMutation.status).toBe(403);
|
||||
});
|
||||
|
||||
it('issues an HttpOnly session only for a schema-valid token payload', async () => {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
|
||||
@@ -11,6 +11,7 @@ describe('control route realtime events', () => {
|
||||
const context = {
|
||||
config: { browser: { dryRun: true } },
|
||||
services: { accountAuth: { isFullyAuthenticated: () => true } },
|
||||
getBotRunning: () => running,
|
||||
setBotRunning: (next: boolean) => { running = next; },
|
||||
activity: { push: vi.fn() },
|
||||
logger: { info: vi.fn() },
|
||||
|
||||
Reference in new issue
Block a user