Files
Codeman/test/routes/search-routes.test.ts
T
Aamer Akhter 95df96e06a COD-9 add cross-session search backend (GET /api/search) v1
Bounded federated search over in-memory stores (sessions/cases, run-summary
events, file paths). Zod-validated query (q 1-200 chars, types csv, limit 1-60),
grouped session->event->file with exact-match-first + recency tiebreak, total
cap 60 + per-group cap 25, snippet cap 200, path-safety (relativePath only).
Frontend search box (history panel) deferred to next cycle; resume/history-prompt
text matching deferred to v1.1 (lives in large on-disk files, out of v1 bounded scope).

New: src/search-service.ts (pure core), src/types/search.ts, src/web/routes/search-routes.ts.
Tests: test/search-service.test.ts (14), test/routes/search-routes.test.ts (10).
2026-06-19 12:35:16 -04:00

209 lines
7.9 KiB
TypeScript

/**
* @fileoverview Tests for the cross-session search route (COD-9).
*
* Uses app.inject() — no real HTTP ports needed.
* Port: N/A (app.inject doesn't open ports)
*
* Covers query validation (400s), result shaping (grouped cards), caps,
* exact-before-recency ranking, and that at least two distinct sources
* (sessions + events) return results. Source data is injected via the mock
* route context (sessions map, runSummaryTrackers map, attachment history).
*/
import { describe, it, expect, beforeEach } from 'vitest';
import Fastify, { type FastifyInstance } from 'fastify';
import { registerSearchRoutes } from '../../src/web/routes/search-routes.js';
import { installRouteErrorHandler } from '../../src/web/route-error-handler.js';
import { createMockRouteContext } from '../mocks/index.js';
import { RunSummaryTracker } from '../../src/run-summary.js';
type Ctx = ReturnType<typeof createMockRouteContext>;
async function harness(configure?: (ctx: Ctx) => void): Promise<{ app: FastifyInstance; ctx: Ctx }> {
const app = Fastify({ logger: false });
// Start from an empty session map so tests fully control the source data.
const ctx = createMockRouteContext();
ctx.sessions.clear();
ctx.runSummaryTrackers.clear();
configure?.(ctx);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
registerSearchRoutes(app, ctx as any);
installRouteErrorHandler(app);
await app.ready();
return { app, ctx };
}
/** Minimal session-like object compatible with the route's reads. */
function fakeSession(opts: {
id: string;
name: string;
workingDir: string;
lastActivityAt?: number;
attachmentHistory?: unknown[];
}) {
return {
id: opts.id,
name: opts.name,
workingDir: opts.workingDir,
lastActivityAt: opts.lastActivityAt ?? 0,
createdAt: 0,
attachmentHistory: opts.attachmentHistory ?? [],
};
}
describe('GET /api/search — validation', () => {
it('400 on missing q', async () => {
const { app } = await harness();
const res = await app.inject({ method: 'GET', url: '/api/search' });
expect(res.statusCode).toBe(400);
expect(JSON.parse(res.body).success).toBe(false);
});
it('400 on empty q', async () => {
const { app } = await harness();
const res = await app.inject({ method: 'GET', url: '/api/search?q=' });
expect(res.statusCode).toBe(400);
});
it('400 on oversized q (>200 chars)', async () => {
const { app } = await harness();
const q = 'x'.repeat(201);
const res = await app.inject({ method: 'GET', url: `/api/search?q=${q}` });
expect(res.statusCode).toBe(400);
});
it('400 on bad types value', async () => {
const { app } = await harness();
const res = await app.inject({ method: 'GET', url: '/api/search?q=foo&types=session,bogus' });
expect(res.statusCode).toBe(400);
});
it('400 on non-numeric limit', async () => {
const { app } = await harness();
const res = await app.inject({ method: 'GET', url: '/api/search?q=foo&limit=abc' });
expect(res.statusCode).toBe(400);
});
});
describe('GET /api/search — shaping & multi-source', () => {
beforeEach(() => {});
it('returns grouped results from sessions and events (two distinct sources)', async () => {
const { app } = await harness((ctx) => {
ctx.sessions.set(
's1',
fakeSession({ id: 's1', name: 'needle session', workingDir: '/home/u/proj', lastActivityAt: 100 }) as never
);
const tracker = new RunSummaryTracker('s2', 'Other session');
tracker.addEvent('warning', 'info', 'found a needle', 'in the logs');
ctx.runSummaryTrackers.set('s2', tracker);
ctx.sessions.set(
's2',
fakeSession({ id: 's2', name: 'Other session', workingDir: '/x', lastActivityAt: 50 }) as never
);
});
const res = await app.inject({ method: 'GET', url: '/api/search?q=needle' });
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body);
expect(body.success).toBe(true);
const types = body.data.groups.map((g: { type: string }) => g.type);
expect(types).toContain('session');
expect(types).toContain('event');
// Group order: sessions before events.
expect(types.indexOf('session')).toBeLessThan(types.indexOf('event'));
expect(body.data.totalResults).toBe(2);
const sessionResult = body.data.groups.find((g: { type: string }) => g.type === 'session').results[0];
expect(sessionResult.sessionId).toBe('s1');
expect(sessionResult.sessionName).toBe('needle session');
expect(typeof sessionResult.timestamp).toBe('number');
expect(typeof sessionResult.snippet).toBe('string');
expect(sessionResult.jumpTo).toEqual({ kind: 'session', sessionId: 's1' });
});
it('exposes file results via relativePath and never leaks an absolute path', async () => {
const { app } = await harness((ctx) => {
ctx.sessions.set(
's1',
fakeSession({
id: 's1',
name: 'Alpha',
workingDir: '/home/u/proj',
lastActivityAt: 1,
attachmentHistory: [
{
id: 'item-1',
sessionId: 's1',
fileName: 'needle.txt',
extension: 'txt',
attachmentType: 'text',
size: 10,
mtimeMs: 0,
timestamp: 5,
source: 'detected',
relativePath: 'docs/needle.txt',
externalPath: '/home/u/secret/needle.txt',
},
],
}) as never
);
});
const res = await app.inject({ method: 'GET', url: '/api/search?q=needle' });
const body = JSON.parse(res.body);
const fileGroup = body.data.groups.find((g: { type: string }) => g.type === 'file');
expect(fileGroup).toBeTruthy();
expect(fileGroup.results[0].jumpTo.relativePath).toBe('docs/needle.txt');
// The server-private absolute/external path must never appear in the payload.
expect(res.body).not.toContain('/home/u/secret');
});
it('ranks exact session-name matches before more-recent partial matches', async () => {
const { app } = await harness((ctx) => {
ctx.sessions.set(
'exact-old',
fakeSession({ id: 'exact-old', name: 'needle', workingDir: '/x', lastActivityAt: 1 }) as never
);
ctx.sessions.set(
'partial-new',
fakeSession({ id: 'partial-new', name: 'needle-haystack', workingDir: '/x', lastActivityAt: 9999 }) as never
);
});
const res = await app.inject({ method: 'GET', url: '/api/search?q=needle' });
const body = JSON.parse(res.body);
const ids = body.data.groups[0].results.map((r: { sessionId: string }) => r.sessionId);
expect(ids).toEqual(['exact-old', 'partial-new']);
});
});
describe('GET /api/search — caps & filters', () => {
it('respects the limit query param as a total cap', async () => {
const { app } = await harness((ctx) => {
for (let i = 0; i < 20; i++) {
ctx.sessions.set(
`s${i}`,
fakeSession({ id: `s${i}`, name: `needle ${i}`, workingDir: '/x', lastActivityAt: i }) as never
);
}
});
const res = await app.inject({ method: 'GET', url: '/api/search?q=needle&limit=5' });
const body = JSON.parse(res.body);
expect(body.data.totalResults).toBe(5);
expect(body.data.truncated).toBe(true);
});
it('filters by types when provided', async () => {
const { app } = await harness((ctx) => {
ctx.sessions.set(
's1',
fakeSession({ id: 's1', name: 'needle session', workingDir: '/x', lastActivityAt: 1 }) as never
);
const tracker = new RunSummaryTracker('s1', 'needle session');
tracker.addEvent('warning', 'info', 'needle event', '');
ctx.runSummaryTrackers.set('s1', tracker);
});
const res = await app.inject({ method: 'GET', url: '/api/search?q=needle&types=event' });
const body = JSON.parse(res.body);
const types = body.data.groups.map((g: { type: string }) => g.type);
expect(types).toEqual(['event']);
});
});