Files
wehub-resource-sync f9447f8e5f
CI / typecheck · build · test · bundle-size (push) Failing after 1s
CI / clean-room dependency closure smoke (push) Failing after 1s
CI / server-runtime e2e (docker · pg + valkey) (push) Failing after 2s
Deploy Install Scripts / deploy (push) Failing after 2s
Windows / build (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:07:03 +08:00

334 lines
13 KiB
TypeScript

// SPDX-License-Identifier: Apache-2.0
import { describe, it, expect, beforeEach, afterEach, mock } from 'bun:test';
mock.module('../../src/shared/worker-utils.js', () => ({
fetchWithTimeout: async (url: string, init: RequestInit, _timeoutMs: number) => {
return globalThis.fetch(url, init);
},
}));
import {
ServerClient,
ServerClientError,
isServerClientError,
} from '../../src/services/hooks/server-client.js';
interface CapturedRequest {
url: string;
method: string;
headers: Record<string, string>;
body?: unknown;
}
let captured: CapturedRequest[] = [];
const originalFetch = globalThis.fetch;
function installFetch(handler: (req: CapturedRequest) => Response | Promise<Response>): void {
// Reset capture buffer for each test.
captured = [];
globalThis.fetch = (async (url: string, init: RequestInit = {}) => {
const headers: Record<string, string> = {};
const rawHeaders = init.headers ?? {};
if (Array.isArray(rawHeaders)) {
for (const [k, v] of rawHeaders) headers[k.toLowerCase()] = v;
} else {
for (const [k, v] of Object.entries(rawHeaders as Record<string, string>)) {
headers[k.toLowerCase()] = v;
}
}
const body = init.body ? JSON.parse(String(init.body)) : undefined;
const req: CapturedRequest = { url, method: String(init.method ?? 'GET'), headers, body };
captured.push(req);
return handler(req);
}) as typeof globalThis.fetch;
}
describe('ServerClient', () => {
beforeEach(() => {
captured = [];
});
afterEach(() => {
globalThis.fetch = originalFetch;
});
it('throws missing_api_key when apiKey is empty', async () => {
const client = new ServerClient({ serverBaseUrl: 'http://x', apiKey: '' });
let caught: unknown;
try {
await client.recordEvent({
projectId: 'p1',
sourceType: 'hook',
eventType: 'tool_use',
occurredAtEpoch: 1,
});
} catch (error) {
caught = error;
}
expect(isServerClientError(caught)).toBe(true);
if (caught instanceof ServerClientError) {
expect(caught.kind).toBe('missing_api_key');
expect(caught.isFallbackEligible()).toBe(true);
}
});
it('startSession sends POST /v1/sessions/start with expected payload', async () => {
installFetch(async () => new Response(JSON.stringify({ session: { id: 'sess-1', projectId: 'p1', teamId: 't1', externalSessionId: 'ext', contentSessionId: 'ext' } }), { status: 201, headers: { 'content-type': 'application/json' } }));
const client = new ServerClient({ serverBaseUrl: 'http://localhost:9999/', apiKey: 'cmem_test' });
const result = await client.startSession({
projectId: 'p1',
externalSessionId: 'ext',
contentSessionId: 'ext',
platformSource: 'claude-code',
});
expect(captured).toHaveLength(1);
expect(captured[0]?.url).toBe('http://localhost:9999/v1/sessions/start');
expect(captured[0]?.method).toBe('POST');
expect(captured[0]?.headers.authorization).toBe('Bearer cmem_test');
expect(captured[0]?.headers['content-type']).toBe('application/json');
expect((captured[0]?.body as Record<string, unknown>).projectId).toBe('p1');
expect((captured[0]?.body as Record<string, unknown>).externalSessionId).toBe('ext');
expect((captured[0]?.body as Record<string, unknown>).platformSource).toBe('claude');
expect(result.session.id).toBe('sess-1');
});
it('recordEvent sends POST /v1/events with payload', async () => {
installFetch(async () => new Response(JSON.stringify({ event: { id: 'e1', projectId: 'p1', serverSessionId: null } }), { status: 201 }));
const client = new ServerClient({ serverBaseUrl: 'http://localhost:9999', apiKey: 'cmem_test' });
const result = await client.recordEvent({
projectId: 'p1',
contentSessionId: 'cs1',
platformSource: 'Codex CLI',
sourceType: 'hook',
eventType: 'tool_use',
occurredAtEpoch: 1234,
payload: { tool: 'Read' },
});
expect(captured[0]?.url).toBe('http://localhost:9999/v1/events');
expect((captured[0]?.body as Record<string, unknown>).eventType).toBe('tool_use');
expect((captured[0]?.body as Record<string, unknown>).sourceType).toBe('hook');
expect((captured[0]?.body as Record<string, unknown>).occurredAtEpoch).toBe(1234);
expect((captured[0]?.body as Record<string, unknown>).platformSource).toBe('codex');
expect(result.event.id).toBe('e1');
});
it('endSession sends POST /v1/sessions/:id/end', async () => {
installFetch(async () => new Response(JSON.stringify({ session: { id: 'sess-1' } }), { status: 200 }));
const client = new ServerClient({ serverBaseUrl: 'http://localhost:9999', apiKey: 'cmem_test' });
await client.endSession({ sessionId: 'sess-1' });
expect(captured[0]?.url).toBe('http://localhost:9999/v1/sessions/sess-1/end');
expect(captured[0]?.method).toBe('POST');
});
it('throws transport error on fetch failure', async () => {
globalThis.fetch = (async () => {
throw new Error('ECONNREFUSED');
}) as typeof globalThis.fetch;
const client = new ServerClient({ serverBaseUrl: 'http://localhost:9999', apiKey: 'cmem_test' });
let caught: unknown;
try {
await client.recordEvent({ projectId: 'p1', sourceType: 'hook', eventType: 'tool_use', occurredAtEpoch: 1 });
} catch (error) {
caught = error;
}
expect(isServerClientError(caught)).toBe(true);
if (caught instanceof ServerClientError) {
expect(caught.kind).toBe('transport');
expect(caught.isFallbackEligible()).toBe(true);
}
});
it('classifies 5xx as fallback-eligible http_error', async () => {
installFetch(async () => new Response('boom', { status: 502 }));
const client = new ServerClient({ serverBaseUrl: 'http://localhost:9999', apiKey: 'cmem_test' });
let caught: unknown;
try {
await client.recordEvent({ projectId: 'p1', sourceType: 'hook', eventType: 'tool_use', occurredAtEpoch: 1 });
} catch (error) {
caught = error;
}
expect(caught).toBeInstanceOf(ServerClientError);
if (caught instanceof ServerClientError) {
expect(caught.kind).toBe('http_error');
expect(caught.status).toBe(502);
expect(caught.isFallbackEligible()).toBe(true);
}
});
it('classifies 4xx (not 429) as non-fallback http_error', async () => {
installFetch(async () => new Response('bad', { status: 400 }));
const client = new ServerClient({ serverBaseUrl: 'http://localhost:9999', apiKey: 'cmem_test' });
let caught: unknown;
try {
await client.recordEvent({ projectId: 'p1', sourceType: 'hook', eventType: 'tool_use', occurredAtEpoch: 1 });
} catch (error) {
caught = error;
}
expect(caught).toBeInstanceOf(ServerClientError);
if (caught instanceof ServerClientError) {
expect(caught.kind).toBe('http_error');
expect(caught.status).toBe(400);
expect(caught.isFallbackEligible()).toBe(false);
}
});
it('classifies 429 as fallback-eligible http_error', async () => {
installFetch(async () => new Response('rate', { status: 429 }));
const client = new ServerClient({ serverBaseUrl: 'http://localhost:9999', apiKey: 'cmem_test' });
let caught: unknown;
try {
await client.recordEvent({ projectId: 'p1', sourceType: 'hook', eventType: 'tool_use', occurredAtEpoch: 1 });
} catch (error) {
caught = error;
}
expect(caught).toBeInstanceOf(ServerClientError);
if (caught instanceof ServerClientError) {
expect(caught.status).toBe(429);
expect(caught.isFallbackEligible()).toBe(true);
}
});
it('strips trailing slash from baseUrl', async () => {
installFetch(async () => new Response(JSON.stringify({ session: { id: 's' } }), { status: 200 }));
const client = new ServerClient({ serverBaseUrl: 'http://localhost:9999///', apiKey: 'cmem_test' });
await client.endSession({ sessionId: 's' });
expect(captured[0]?.url).toBe('http://localhost:9999/v1/sessions/s/end');
});
// ----- Phase 8 — MCP-backing methods. These exercise the same /v1/* paths
// the REST core exposes, so MCP tools never have a private write path. -----
it('addObservation sends POST /v1/memories with content', async () => {
installFetch(async () => new Response(
JSON.stringify({ memory: { id: 'o1', projectId: 'p1', teamId: 't1', serverSessionId: null, kind: 'manual', content: 'hello', metadata: {} } }),
{ status: 201 },
));
const client = new ServerClient({ serverBaseUrl: 'http://localhost:9999', apiKey: 'cmem_test' });
const result = await client.addObservation({
projectId: 'p1',
content: 'hello',
kind: 'manual',
metadata: { source: 'mcp' },
});
expect(captured[0]?.url).toBe('http://localhost:9999/v1/memories');
expect(captured[0]?.method).toBe('POST');
// Write-path contract (#2684): content maps onto narrative (the FTS-indexed
// / trigger-precondition column) and type defaults from kind, so the row is
// never empty. The old payload shipped a `content` field that no column
// accepted, producing a frozen/empty observation.
const body = captured[0]?.body as Record<string, unknown>;
expect(body.narrative).toBe('hello');
expect(body.kind).toBe('manual');
expect(body.type).toBe('manual');
expect(body.content).toBeUndefined();
expect(result.memory.id).toBe('o1');
});
it('searchObservations sends POST /v1/search with query', async () => {
installFetch(async () => new Response(
JSON.stringify({ observations: [{ id: 'o1', projectId: 'p1', content: 'matched' }] }),
{ status: 200 },
));
const client = new ServerClient({ serverBaseUrl: 'http://localhost:9999', apiKey: 'cmem_test' });
const result = await client.searchObservations({
projectId: 'p1',
query: 'login bug',
limit: 5,
platformSource: 'Codex CLI',
});
expect(captured[0]?.url).toBe('http://localhost:9999/v1/search');
expect((captured[0]?.body as Record<string, unknown>).query).toBe('login bug');
expect((captured[0]?.body as Record<string, unknown>).limit).toBe(5);
expect((captured[0]?.body as Record<string, unknown>).platformSource).toBe('codex');
expect(result.observations[0]?.id).toBe('o1');
});
it('contextObservations sends POST /v1/context and returns context string', async () => {
installFetch(async () => new Response(
JSON.stringify({
observations: [{ id: 'o1', projectId: 'p1', content: 'a' }, { id: 'o2', projectId: 'p1', content: 'b' }],
context: 'a\n\nb',
}),
{ status: 200 },
));
const client = new ServerClient({ serverBaseUrl: 'http://localhost:9999', apiKey: 'cmem_test' });
const result = await client.contextObservations({ projectId: 'p1', query: 'q', platformSource: 'Cursor' });
expect(captured[0]?.url).toBe('http://localhost:9999/v1/context');
expect((captured[0]?.body as Record<string, unknown>).platformSource).toBe('cursor');
expect(result.context).toBe('a\n\nb');
expect(result.observations).toHaveLength(2);
});
it('getJobStatus sends GET /v1/jobs/:id', async () => {
installFetch(async () => new Response(
JSON.stringify({ generationJob: { id: 'j1', status: 'queued' } }),
{ status: 200 },
));
const client = new ServerClient({ serverBaseUrl: 'http://localhost:9999', apiKey: 'cmem_test' });
const result = await client.getJobStatus('j1');
expect(captured[0]?.url).toBe('http://localhost:9999/v1/jobs/j1');
expect(captured[0]?.method).toBe('GET');
expect(result.generationJob.status).toBe('queued');
});
it('getJobStatus rejects empty jobId', async () => {
const client = new ServerClient({ serverBaseUrl: 'http://x', apiKey: 'cmem_test' });
let caught: unknown;
try {
await client.getJobStatus('');
} catch (error) {
caught = error;
}
expect(caught).toBeInstanceOf(ServerClientError);
});
it('payload builders omit absent fields', () => {
const client = new ServerClient({ serverBaseUrl: 'http://x', apiKey: 'k' });
// content → narrative, type defaults from kind (default 'manual') so a
// minimal observation_add still persists a searchable row (#2684).
expect(client.buildAddObservationPayload({ projectId: 'p', content: 'c' })).toEqual({
projectId: 'p',
kind: 'manual',
type: 'manual',
narrative: 'c',
});
expect(client.buildSearchPayload({ projectId: 'p', query: 'q' })).toEqual({
projectId: 'p',
query: 'q',
});
expect(client.buildSearchPayload({ projectId: 'p', query: 'q', limit: 7 })).toEqual({
projectId: 'p',
query: 'q',
limit: 7,
});
expect(client.buildSearchPayload({ projectId: 'p', query: 'q', platformSource: 'Codex CLI' })).toEqual({
projectId: 'p',
query: 'q',
platformSource: 'codex',
});
expect(client.buildEventPayload({
projectId: 'p',
sourceType: 'hook',
eventType: 'tool_use',
occurredAtEpoch: 1,
platformSource: 'Claude Code',
})).toEqual({
projectId: 'p',
sourceType: 'hook',
eventType: 'tool_use',
occurredAtEpoch: 1,
platformSource: 'claude',
});
expect(client.buildStartSessionPayload({
projectId: 'p',
externalSessionId: 'ext',
platformSource: 'Cursor',
})).toEqual({
projectId: 'p',
externalSessionId: 'ext',
platformSource: 'cursor',
});
});
});