mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-03 05:59:43 +02:00
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).
This commit is contained in:
@@ -0,0 +1,208 @@
|
||||
/**
|
||||
* @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']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,235 @@
|
||||
/**
|
||||
* Unit tests for the pure cross-session search core (COD-9).
|
||||
*
|
||||
* The core (`searchSources`) takes already-collected, in-memory source data
|
||||
* plus a normalized query and returns grouped/ranked/capped results. No I/O,
|
||||
* no live server — these tests exercise grouping order, exact-before-recency
|
||||
* ranking, caps (total + per-group), snippet shaping, and path safety.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { searchSources, SEARCH_TOTAL_CAP, SEARCH_PER_GROUP_CAP, type SearchSources } from '../src/search-service.js';
|
||||
|
||||
function sources(overrides: Partial<SearchSources> = {}): SearchSources {
|
||||
return {
|
||||
sessions: [],
|
||||
events: [],
|
||||
files: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('searchSources — grouping & order', () => {
|
||||
it('orders groups sessions → events → files', () => {
|
||||
const data = sources({
|
||||
files: [
|
||||
{
|
||||
sessionId: 's1',
|
||||
sessionName: 'Alpha',
|
||||
fileName: 'query.txt',
|
||||
relativePath: 'docs/query.txt',
|
||||
timestamp: 100,
|
||||
itemId: 'f1',
|
||||
},
|
||||
],
|
||||
events: [
|
||||
{ sessionId: 's1', sessionName: 'Alpha', eventId: 'e1', title: 'query started', details: '', timestamp: 100 },
|
||||
],
|
||||
sessions: [{ sessionId: 's1', sessionName: 'query session', workingDir: '/home/u/proj', timestamp: 100 }],
|
||||
});
|
||||
const res = searchSources('query', data);
|
||||
expect(res.groups.map((g) => g.type)).toEqual(['session', 'event', 'file']);
|
||||
});
|
||||
|
||||
it('omits empty groups', () => {
|
||||
const data = sources({
|
||||
sessions: [{ sessionId: 's1', sessionName: 'query session', workingDir: '/home/u/proj', timestamp: 100 }],
|
||||
});
|
||||
const res = searchSources('query', data);
|
||||
expect(res.groups.map((g) => g.type)).toEqual(['session']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('searchSources — matching across distinct sources', () => {
|
||||
it('returns results from at least two distinct sources', () => {
|
||||
const data = sources({
|
||||
sessions: [{ sessionId: 's1', sessionName: 'needle project', workingDir: '/home/u/proj', timestamp: 100 }],
|
||||
events: [
|
||||
{ sessionId: 's2', sessionName: 'Other', eventId: 'e1', title: 'found a needle', details: '', timestamp: 100 },
|
||||
],
|
||||
});
|
||||
const res = searchSources('needle', data);
|
||||
const types = res.groups.map((g) => g.type);
|
||||
expect(types).toContain('session');
|
||||
expect(types).toContain('event');
|
||||
expect(res.totalResults).toBe(2);
|
||||
});
|
||||
|
||||
it('matches session working directory', () => {
|
||||
const data = sources({
|
||||
sessions: [{ sessionId: 's1', sessionName: 'Unrelated', workingDir: '/home/u/needle-dir', timestamp: 100 }],
|
||||
});
|
||||
const res = searchSources('needle', data);
|
||||
expect(res.totalResults).toBe(1);
|
||||
expect(res.groups[0].results[0].sessionId).toBe('s1');
|
||||
});
|
||||
|
||||
it('matches event details, not just title', () => {
|
||||
const data = sources({
|
||||
events: [
|
||||
{
|
||||
sessionId: 's1',
|
||||
sessionName: 'A',
|
||||
eventId: 'e1',
|
||||
title: 'nothing here',
|
||||
details: 'a needle in details',
|
||||
timestamp: 100,
|
||||
},
|
||||
],
|
||||
});
|
||||
const res = searchSources('needle', data);
|
||||
expect(res.totalResults).toBe(1);
|
||||
});
|
||||
|
||||
it('is case-insensitive', () => {
|
||||
const data = sources({
|
||||
sessions: [{ sessionId: 's1', sessionName: 'NEEDLE', workingDir: '/x', timestamp: 100 }],
|
||||
});
|
||||
expect(searchSources('needle', data).totalResults).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('searchSources — ranking (exact before recency)', () => {
|
||||
it('places exact name matches before more-recent partial matches', () => {
|
||||
const data = sources({
|
||||
sessions: [
|
||||
{ sessionId: 'old-exact', sessionName: 'needle', workingDir: '/x', timestamp: 1 },
|
||||
{ sessionId: 'new-partial', sessionName: 'needle-haystack', workingDir: '/x', timestamp: 9999 },
|
||||
],
|
||||
});
|
||||
const res = searchSources('needle', data);
|
||||
const ids = res.groups[0].results.map((r) => r.sessionId);
|
||||
expect(ids).toEqual(['old-exact', 'new-partial']);
|
||||
expect(res.groups[0].results[0].exactMatch).toBe(true);
|
||||
});
|
||||
|
||||
it('within the same exactness tier, sorts newest first', () => {
|
||||
const data = sources({
|
||||
sessions: [
|
||||
{ sessionId: 'older', sessionName: 'needle-a', workingDir: '/x', timestamp: 10 },
|
||||
{ sessionId: 'newer', sessionName: 'needle-b', workingDir: '/x', timestamp: 20 },
|
||||
],
|
||||
});
|
||||
const res = searchSources('needle', data);
|
||||
expect(res.groups[0].results.map((r) => r.sessionId)).toEqual(['newer', 'older']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('searchSources — caps', () => {
|
||||
it('enforces the per-group cap and flags truncated', () => {
|
||||
const sessions = Array.from({ length: SEARCH_PER_GROUP_CAP + 5 }, (_, i) => ({
|
||||
sessionId: `s${i}`,
|
||||
sessionName: `needle ${i}`,
|
||||
workingDir: '/x',
|
||||
timestamp: i,
|
||||
}));
|
||||
const res = searchSources('needle', sources({ sessions }));
|
||||
expect(res.groups[0].results.length).toBe(SEARCH_PER_GROUP_CAP);
|
||||
expect(res.truncated).toBe(true);
|
||||
});
|
||||
|
||||
it('enforces the total cap across groups', () => {
|
||||
// Fill every group to its per-group cap; total must not exceed SEARCH_TOTAL_CAP.
|
||||
const mk = <T>(n: number, f: (i: number) => T) => Array.from({ length: n }, (_, i) => f(i));
|
||||
const data = sources({
|
||||
sessions: mk(SEARCH_PER_GROUP_CAP, (i) => ({
|
||||
sessionId: `s${i}`,
|
||||
sessionName: `needle ${i}`,
|
||||
workingDir: '/x',
|
||||
timestamp: i,
|
||||
})),
|
||||
events: mk(SEARCH_PER_GROUP_CAP, (i) => ({
|
||||
sessionId: `e${i}`,
|
||||
sessionName: 'E',
|
||||
eventId: `e${i}`,
|
||||
title: `needle ${i}`,
|
||||
details: '',
|
||||
timestamp: i,
|
||||
})),
|
||||
files: mk(SEARCH_PER_GROUP_CAP, (i) => ({
|
||||
sessionId: `f${i}`,
|
||||
sessionName: 'F',
|
||||
fileName: `needle${i}.txt`,
|
||||
relativePath: `d/needle${i}.txt`,
|
||||
timestamp: i,
|
||||
itemId: `f${i}`,
|
||||
})),
|
||||
});
|
||||
const res = searchSources('needle', data);
|
||||
expect(res.totalResults).toBeLessThanOrEqual(SEARCH_TOTAL_CAP);
|
||||
});
|
||||
});
|
||||
|
||||
describe('searchSources — result card shape & path safety', () => {
|
||||
it('shapes a file result with a relative-path jump target and no absolute leakage', () => {
|
||||
const data = sources({
|
||||
files: [
|
||||
{
|
||||
sessionId: 's1',
|
||||
sessionName: 'Alpha',
|
||||
fileName: 'needle.txt',
|
||||
relativePath: 'docs/needle.txt',
|
||||
timestamp: 123,
|
||||
itemId: 'item-1',
|
||||
},
|
||||
],
|
||||
});
|
||||
const r = searchSources('needle', data).groups[0].results[0];
|
||||
expect(r.type).toBe('file');
|
||||
expect(r.sessionId).toBe('s1');
|
||||
expect(r.sessionName).toBe('Alpha');
|
||||
expect(r.timestamp).toBe(123);
|
||||
expect(r.jumpTo).toEqual({
|
||||
kind: 'file-preview',
|
||||
sessionId: 's1',
|
||||
targetId: 'item-1',
|
||||
relativePath: 'docs/needle.txt',
|
||||
});
|
||||
// No absolute path anywhere in the serialized result.
|
||||
expect(JSON.stringify(r)).not.toContain('/home/');
|
||||
});
|
||||
|
||||
it('drops files that only have a server-private absolute path (no relativePath)', () => {
|
||||
const data = sources({
|
||||
files: [
|
||||
{
|
||||
sessionId: 's1',
|
||||
sessionName: 'Alpha',
|
||||
fileName: 'needle.txt',
|
||||
relativePath: undefined,
|
||||
timestamp: 1,
|
||||
itemId: 'i1',
|
||||
},
|
||||
],
|
||||
});
|
||||
// fileName still matches, but there is no safe relativePath to expose → still
|
||||
// returned, but jumpTo must not carry an absolute path.
|
||||
const res = searchSources('needle', data);
|
||||
if (res.totalResults > 0) {
|
||||
expect(res.groups[0].results[0].jumpTo.relativePath).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it('truncates long snippets', () => {
|
||||
const longDetail = 'needle ' + 'x'.repeat(500);
|
||||
const data = sources({
|
||||
events: [{ sessionId: 's1', sessionName: 'A', eventId: 'e1', title: 'evt', details: longDetail, timestamp: 1 }],
|
||||
});
|
||||
const r = searchSources('needle', data).groups[0].results[0];
|
||||
expect(r.snippet.length).toBeLessThanOrEqual(200);
|
||||
});
|
||||
|
||||
it('returns empty for a blank query', () => {
|
||||
const data = sources({ sessions: [{ sessionId: 's1', sessionName: 'needle', workingDir: '/x', timestamp: 1 }] });
|
||||
expect(searchSources('', data).totalResults).toBe(0);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user