Skip to content

Commit c46cddf

Browse files
committed
test(routes): HTTP-level coverage for event-chains + first-mentions extract (review #5)
The two PR #18 read endpoints had no HTTP-level tests — only the underlying repository / service unit tests existed, which left the schema-validation middleware and route-level wiring uncovered. A schema rename or a route-handler regression could ship green. New file `src/routes/__tests__/event-chains-and-first-mentions.test.ts` mirrors the route-test pattern from `src/__tests__/route-validation.test.ts`: spin up an Express app on `app.listen(0, ...)`, wire `createMemoryRouter` against a real `MemoryService` backed by the test DB, drive endpoints via `fetch`. The MemoryService gets a real `FirstMentionRepository` plus a stubbed `chatFn` so the LLM call returns a deterministic JSON array. Coverage: GET /v1/memories/event-chains - 400 when `user_id` is missing - 400 when `entity_ids` is missing - 400 when `entity_ids` contains an invalid UUID - 400 when `entity_ids` exceeds the 100-entry cap (review #6) - 400 when `entity_ids` is present but holds only empty tokens - happy path: seed memory + entity + TLL row, hit the route, parse the response with `EventChainsResponseSchema` POST /v1/memories/first-mentions/extract - 400 when `user_id` is missing - 400 when `conversation_text` is empty - 400 when `conversation_text` exceeds MAX_CONVERSATION_LENGTH (100_000 chars) - 400 when `memory_ids_by_turn_id` is missing entirely - 400 when `source_site` is missing - happy path: stub LLM returns 2 events, route stores+returns them, response parsed with `FirstMentionsExtractResponseSchema`, `position_in_conversation` is the post-sorted [0, 1] sequence from review #7. 12/12 new tests pass.
1 parent 35dc36a commit c46cddf

1 file changed

Lines changed: 320 additions & 0 deletions

File tree

Lines changed: 320 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,320 @@
1+
/**
2+
* HTTP-level tests for the two PR #18 read endpoints (review #5):
3+
* - GET /v1/memories/event-chains
4+
* - POST /v1/memories/first-mentions/extract
5+
*
6+
* Covers the schema-validation 400 paths (no DB hit needed for the
7+
* cases that are rejected before the handler runs) and one happy-path
8+
* end-to-end shape assertion per endpoint. The happy-path tests seed
9+
* the test DB and assert the response matches the response schema.
10+
*
11+
* Mirrors the route-test pattern from `src/__tests__/route-validation.test.ts`:
12+
* an Express app is built with `createMemoryRouter`, a real
13+
* `MemoryService` (wired against a real Postgres test DB plus mocked
14+
* embeddings + a stub LLM `chatFn`), and `fetch` is used to drive the
15+
* registered routes.
16+
*
17+
* Requires DATABASE_URL in .env.test.
18+
*/
19+
20+
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
21+
22+
// Mock embedText so the test process never hits an embedding provider
23+
// (CI uses a placeholder OPENAI_API_KEY). Returns a deterministic zero
24+
// vector matching the configured embedding dimensions, mirroring the
25+
// mock pattern in route-validation.test.ts.
26+
vi.mock('../../services/embedding.js', async (importOriginal) => {
27+
const actual = await importOriginal<typeof import('../../services/embedding.js')>();
28+
return {
29+
...actual,
30+
embedText: vi.fn(async () => {
31+
const { config: cfg } = await import('../../config.js');
32+
return new Array(cfg.embeddingDimensions).fill(0);
33+
}),
34+
};
35+
});
36+
37+
import express from 'express';
38+
import { pool } from '../../db/pool.js';
39+
import { MemoryRepository } from '../../db/memory-repository.js';
40+
import { ClaimRepository } from '../../db/claim-repository.js';
41+
import { EntityRepository } from '../../db/repository-entities.js';
42+
import { TllRepository } from '../../db/repository-tll.js';
43+
import { FirstMentionRepository } from '../../db/repository-first-mentions.js';
44+
import { FirstMentionService } from '../../services/first-mention-service.js';
45+
import { MemoryService } from '../../services/memory-service.js';
46+
import { createMemoryRouter } from '../memories.js';
47+
import { setupTestSchema, unitVector } from '../../db/__tests__/test-fixtures.js';
48+
import {
49+
EventChainsResponseSchema,
50+
FirstMentionsExtractResponseSchema,
51+
} from '../../schemas/responses.js';
52+
53+
const TEST_USER = 'event-chains-route-test-user';
54+
const VALID_UUID = '00000000-0000-0000-0000-000000000001';
55+
const INVALID_UUID = 'not-a-uuid';
56+
57+
let server: ReturnType<typeof app.listen>;
58+
let baseUrl: string;
59+
const app = express();
60+
app.use(express.json());
61+
62+
/**
63+
* Stub LLM chatFn returning a static JSON array shaped like a valid
64+
* first-mention extraction. Used for the happy-path test on
65+
* /first-mentions/extract — the schema validation cases never reach
66+
* the LLM call.
67+
*/
68+
function makeStubChatFn(json: string) {
69+
return vi.fn(async () => ({ text: json }));
70+
}
71+
72+
beforeAll(async () => {
73+
await setupTestSchema(pool);
74+
75+
const repo = new MemoryRepository(pool);
76+
const claimRepo = new ClaimRepository(pool);
77+
const entityRepo = new EntityRepository(pool);
78+
const tllRepo = new TllRepository(pool);
79+
const fmRepo = new FirstMentionRepository(pool);
80+
const stubChat = makeStubChatFn(JSON.stringify([
81+
{ topic: 'sample topic alpha', turn_id: 1, session_id: 1, anchor_date: null },
82+
{ topic: 'sample topic beta', turn_id: 3, session_id: 1, anchor_date: null },
83+
]));
84+
const fmService = new FirstMentionService(fmRepo, stubChat);
85+
const service = new MemoryService(
86+
repo,
87+
claimRepo,
88+
entityRepo,
89+
undefined,
90+
undefined,
91+
undefined,
92+
undefined,
93+
tllRepo,
94+
fmService,
95+
);
96+
app.use('/memories', createMemoryRouter(service));
97+
98+
await new Promise<void>((resolve) => {
99+
server = app.listen(0, () => {
100+
const addr = server.address();
101+
const port = typeof addr === 'object' && addr ? addr.port : 0;
102+
baseUrl = `http://localhost:${port}`;
103+
resolve();
104+
});
105+
});
106+
});
107+
108+
afterAll(async () => {
109+
await new Promise<void>((resolve) => server.close(() => resolve()));
110+
await pool.end();
111+
});
112+
113+
// ---------------------------------------------------------------------------
114+
// GET /v1/memories/event-chains — schema validation
115+
// ---------------------------------------------------------------------------
116+
117+
describe('GET /memories/event-chains — schema validation', () => {
118+
it('returns 400 when user_id is missing', async () => {
119+
const res = await fetch(`${baseUrl}/memories/event-chains?entity_ids=${VALID_UUID}`);
120+
expect(res.status).toBe(400);
121+
const body = (await res.json()) as { error: string };
122+
expect(body.error).toMatch(/user_id/i);
123+
});
124+
125+
it('returns 400 when entity_ids is missing', async () => {
126+
const res = await fetch(`${baseUrl}/memories/event-chains?user_id=${TEST_USER}`);
127+
expect(res.status).toBe(400);
128+
const body = (await res.json()) as { error: string };
129+
expect(body.error).toMatch(/entity_ids/i);
130+
});
131+
132+
it('returns 400 when entity_ids contains an invalid UUID', async () => {
133+
const res = await fetch(
134+
`${baseUrl}/memories/event-chains?user_id=${TEST_USER}&entity_ids=${VALID_UUID},${INVALID_UUID}`,
135+
);
136+
expect(res.status).toBe(400);
137+
const body = (await res.json()) as { error: string };
138+
expect(body.error).toMatch(/valid UUIDs/i);
139+
});
140+
141+
it('returns 400 when entity_ids exceeds the 100-entry cap (review #6)', async () => {
142+
// Build 101 distinct UUIDs to trip the anti-amplification cap.
143+
const ids = Array.from({ length: 101 }, (_, i) => {
144+
const hex = (i + 1).toString(16).padStart(12, '0');
145+
return `00000000-0000-0000-0000-${hex}`;
146+
});
147+
const res = await fetch(
148+
`${baseUrl}/memories/event-chains?user_id=${TEST_USER}&entity_ids=${ids.join(',')}`,
149+
);
150+
expect(res.status).toBe(400);
151+
const body = (await res.json()) as { error: string };
152+
expect(body.error).toMatch(/at most 100/i);
153+
});
154+
155+
it('returns 400 when entity_ids is present but contains only empty tokens', async () => {
156+
const res = await fetch(
157+
`${baseUrl}/memories/event-chains?user_id=${TEST_USER}&entity_ids=,,,`,
158+
);
159+
expect(res.status).toBe(400);
160+
const body = (await res.json()) as { error: string };
161+
expect(body.error).toMatch(/non-empty/i);
162+
});
163+
});
164+
165+
// ---------------------------------------------------------------------------
166+
// GET /v1/memories/event-chains — happy path
167+
// ---------------------------------------------------------------------------
168+
169+
describe('GET /memories/event-chains — happy path', () => {
170+
it('returns the seeded chain in the EventChainsResponseSchema shape', async () => {
171+
const repo = new MemoryRepository(pool);
172+
const entityRepo = new EntityRepository(pool);
173+
const tllRepo = new TllRepository(pool);
174+
const userId = `${TEST_USER}-happy`;
175+
176+
// Clean slate for this user.
177+
await pool.query('DELETE FROM temporal_linkage_list WHERE user_id = $1', [userId]);
178+
179+
const memId = await repo.storeMemory({
180+
userId,
181+
content: 'Started using Postgres on the project.',
182+
embedding: unitVector(101),
183+
importance: 0.7,
184+
sourceSite: 'event-chains-test',
185+
});
186+
const entityId = await entityRepo.resolveEntity({
187+
userId,
188+
name: 'Postgres',
189+
entityType: 'tool',
190+
embedding: unitVector(102),
191+
});
192+
await tllRepo.append(userId, memId, [entityId], new Date('2026-01-15T00:00:00Z'));
193+
194+
const res = await fetch(
195+
`${baseUrl}/memories/event-chains?user_id=${userId}&entity_ids=${entityId}`,
196+
);
197+
expect(res.status).toBe(200);
198+
const body = await res.json();
199+
const parsed = EventChainsResponseSchema.parse(body);
200+
expect(parsed.chains).toHaveLength(1);
201+
expect(parsed.chains[0].entity_id).toBe(entityId);
202+
expect(parsed.chains[0].events).toHaveLength(1);
203+
const ev = parsed.chains[0].events[0];
204+
expect(ev.memory_id).toBe(memId);
205+
expect(ev.position_in_chain).toBe(0);
206+
expect(ev.predecessor_memory_id).toBeNull();
207+
expect(typeof ev.observation_date).toBe('string');
208+
});
209+
});
210+
211+
// ---------------------------------------------------------------------------
212+
// POST /v1/memories/first-mentions/extract — schema validation
213+
// ---------------------------------------------------------------------------
214+
215+
describe('POST /memories/first-mentions/extract — schema validation', () => {
216+
async function postExpecting400(body: Record<string, unknown>): Promise<{ error: string }> {
217+
const res = await fetch(`${baseUrl}/memories/first-mentions/extract`, {
218+
method: 'POST',
219+
headers: { 'Content-Type': 'application/json' },
220+
body: JSON.stringify(body),
221+
});
222+
expect(res.status).toBe(400);
223+
return (await res.json()) as { error: string };
224+
}
225+
226+
it('returns 400 when user_id is missing', async () => {
227+
const { error } = await postExpecting400({
228+
conversation_text: 'hi',
229+
source_site: 'beam',
230+
memory_ids_by_turn_id: { '1': VALID_UUID },
231+
});
232+
expect(error).toMatch(/user_id/i);
233+
});
234+
235+
it('returns 400 when conversation_text is empty', async () => {
236+
const { error } = await postExpecting400({
237+
user_id: TEST_USER,
238+
conversation_text: '',
239+
source_site: 'beam',
240+
memory_ids_by_turn_id: { '1': VALID_UUID },
241+
});
242+
// Zod min(1) message — match loosely on the field name.
243+
expect(error).toMatch(/conversation_text/i);
244+
});
245+
246+
it('returns 400 when conversation_text exceeds the max length cap', async () => {
247+
// The schema cap is 100_000 characters. Build a string just over it.
248+
const oversized = 'x'.repeat(100_001);
249+
const { error } = await postExpecting400({
250+
user_id: TEST_USER,
251+
conversation_text: oversized,
252+
source_site: 'beam',
253+
memory_ids_by_turn_id: { '1': VALID_UUID },
254+
});
255+
expect(error).toMatch(/conversation_text/i);
256+
});
257+
258+
it('returns 400 when memory_ids_by_turn_id is missing entirely', async () => {
259+
const { error } = await postExpecting400({
260+
user_id: TEST_USER,
261+
conversation_text: 'hi',
262+
source_site: 'beam',
263+
});
264+
expect(error).toMatch(/memory_ids_by_turn_id/i);
265+
});
266+
267+
it('returns 400 when source_site is missing', async () => {
268+
const { error } = await postExpecting400({
269+
user_id: TEST_USER,
270+
conversation_text: 'hi',
271+
memory_ids_by_turn_id: { '1': VALID_UUID },
272+
});
273+
expect(error).toMatch(/source_site/i);
274+
});
275+
});
276+
277+
// ---------------------------------------------------------------------------
278+
// POST /v1/memories/first-mentions/extract — happy path
279+
// ---------------------------------------------------------------------------
280+
281+
async function seedMemoryFor(userId: string, content: string, seed: number): Promise<string> {
282+
const repo = new MemoryRepository(pool);
283+
return repo.storeMemory({
284+
userId,
285+
content,
286+
embedding: unitVector(seed),
287+
importance: 0.6,
288+
sourceSite: 'first-mentions-test',
289+
});
290+
}
291+
292+
describe('POST /memories/first-mentions/extract — happy path', () => {
293+
it('returns the stub-extracted events in the FirstMentionsExtractResponseSchema shape', async () => {
294+
const userId = `${TEST_USER}-fm-happy`;
295+
const mem1 = await seedMemoryFor(userId, 'turn 1 memory', 201);
296+
const mem3 = await seedMemoryFor(userId, 'turn 3 memory', 202);
297+
298+
const res = await fetch(`${baseUrl}/memories/first-mentions/extract`, {
299+
method: 'POST',
300+
headers: { 'Content-Type': 'application/json' },
301+
body: JSON.stringify({
302+
user_id: userId,
303+
conversation_text: 'turn 1: started X. turn 3: switched to Y.',
304+
source_site: 'first-mentions-test',
305+
memory_ids_by_turn_id: { '1': mem1, '3': mem3 },
306+
}),
307+
});
308+
expect(res.status).toBe(200);
309+
const body = await res.json();
310+
const parsed = FirstMentionsExtractResponseSchema.parse(body);
311+
expect(parsed.events).toHaveLength(2);
312+
// Topics come from the stub chatFn; positions are post-sorted (review #7).
313+
expect(parsed.events.map((e) => e.topic)).toEqual([
314+
'sample topic alpha',
315+
'sample topic beta',
316+
]);
317+
expect(parsed.events.map((e) => e.position_in_conversation)).toEqual([0, 1]);
318+
expect(parsed.events.map((e) => e.memory_id)).toEqual([mem1, mem3]);
319+
});
320+
});

0 commit comments

Comments
 (0)