import express from 'express'; import Database from 'better-sqlite3'; import type { AddressInfo } from 'node:net'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { LearningTrajectoryDetailResponseSchema } from '../../packages/api-contracts/src/index.js'; import { runMigrations } from '../../src/database/client.js'; import { ConversationLearningRepository } from '../../src/database/conversation-learning-repository.js'; import type { WebContext } from '../../src/web/context.js'; import { createLearningRouter } from '../../src/web/routes/learning.js'; const ACCOUNT = '@t2_learner:reddit.com'; const CONVERSATION = '!room:reddit.com'; const servers: Array> = []; async function call(app: express.Express, path: string): 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}`); 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())) ))); }); describe('GET /api/learning/trajectories/:id', () => { let db: Database.Database; let repo: ConversationLearningRepository; let app: express.Express; 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 (?, 'learner', ?, 'verified', 1, '[]', '{}', 0, 0) `).run(ACCOUNT, ACCOUNT); repo = new ConversationLearningRepository(db, ACCOUNT); repo.upsertTrajectory({ id: 'traj-1', conversationId: CONVERSATION, sentMessageId: null, subreddit: 'cats', sourceType: 'chat', policyMode: 'r4r-media', firstHumanEventId: '$human1', outcomeEventId: '$image1', outcomeType: 'image-received', creditClass: 'human-created-success', attributionConfidence: 'exact', status: 'eligible', maturityAt: 1_000, invalidatedAt: null, invalidationReason: null, now: 900, }); repo.upsertDecision({ id: 'dec-1', trajectoryId: 'traj-1', decisionEventId: '$human1', stage: 'media-invitation', context: [{ eventId: '$in1', timestamp: 500, role: 'recipient', actor: 'recipient', kind: 'inbound-text', text: 'hey there', }], chosenResponse: 'no pressure, only if you want to', turnsUntilOutcome: 2, isDirectMediaInvitation: true, qualityStatus: 'eligible', now: 900, }); const ctx = { config: { learning: { captureEnabled: true, generationEnabled: false, shadowMode: true, policyVersion: 'baseline-v1' } }, services: { accountRuntimes: { list: () => [{ accountId: ACCOUNT }], get: () => ({ resources: { conversationLearningRepo: repo } }), }, }, } as unknown as WebContext; app = express(); app.use(express.json()); app.use('/api', createLearningRouter(ctx)); }); it('returns the redacted decisions behind a trajectory', async () => { const { status, json } = await call(app, '/api/learning/trajectories/traj-1'); expect(status).toBe(200); const parsed = LearningTrajectoryDetailResponseSchema.parse(json); expect(parsed.trajectory.conversationId).toBe(CONVERSATION); expect(parsed.trajectory.creditClass).toBe('human-created-success'); expect(parsed.decisions).toHaveLength(1); expect(parsed.decisions[0]?.chosenResponse).toBe('no pressure, only if you want to'); expect(parsed.decisions[0]?.context[0]?.text).toBe('hey there'); expect(parsed.decisions[0]?.isDirectMediaInvitation).toBe(true); }); it('does not nest decisions inside the trajectory object', async () => { const { json } = await call(app, '/api/learning/trajectories/traj-1'); expect(json.trajectory.decisions).toBeUndefined(); }); it('404s for an unknown trajectory', async () => { const { status, json } = await call(app, '/api/learning/trajectories/missing'); expect(status).toBe(404); expect(json.error).toBe('Learning trajectory not found'); }); });