test: add 196 tests across MCP, services, utils, autocomplete (#855)

Co-authored-by: Jwalin Shah <jshah1331@gmail.com>
Co-authored-by: Steven Enamakel <enamakel@tinyhumans.ai>
This commit is contained in:
Jwalin Shah
2026-04-24 11:47:26 -07:00
committed by GitHub
co-authored by Jwalin Shah Steven Enamakel
parent 9b1cfccb53
commit 95504d4b09
12 changed files with 2174 additions and 0 deletions
+82
View File
@@ -0,0 +1,82 @@
/**
* Unit tests for MCP error handling utilities
*/
import { describe, expect, it, vi } from 'vitest';
import { ErrorCategory, logAndFormatError, withErrorHandling } from './errorHandler';
import { ValidationError } from './validation';
describe('logAndFormatError', () => {
it('returns an MCPToolResult with isError=true', () => {
const result = logAndFormatError('myFn', new Error('boom'));
expect(result.isError).toBe(true);
expect(result.content).toHaveLength(1);
expect(result.content[0].type).toBe('text');
});
it('includes the error code in the user message for generic errors', () => {
const result = logAndFormatError('myFn', new Error('internal'));
expect(result.content[0].text).toMatch(/code:/);
});
it('exposes the ValidationError message directly to the user', () => {
const err = new ValidationError('chat_id must be a positive integer');
const result = logAndFormatError('myFn', err, ErrorCategory.VALIDATION);
expect(result.content[0].text).toBe('chat_id must be a positive integer');
});
it('includes a VALIDATION-001 error code when category is VALIDATION', () => {
const result = logAndFormatError('sendMsg', new Error('oops'), ErrorCategory.VALIDATION);
// ValidationError path is not triggered — but category affects the code
expect(result.content[0].text).toMatch(/VALIDATION-001/);
});
it('uses the supplied category as a prefix for non-validation errors', () => {
const result = logAndFormatError('sendMsg', new Error('fail'), ErrorCategory.CHAT);
expect(result.content[0].text).toMatch(/CHAT/);
});
it('produces a stable code for the same function name', () => {
const r1 = logAndFormatError('stableFn', new Error('a'));
const r2 = logAndFormatError('stableFn', new Error('b'));
// Strip the leading text, just compare the code portion
const code1 = r1.content[0].text.match(/code: ([^)]+)/)?.[1];
const code2 = r2.content[0].text.match(/code: ([^)]+)/)?.[1];
expect(code1).toBe(code2);
});
});
describe('withErrorHandling', () => {
it('returns the wrapped function result when no error is thrown', async () => {
const fn = vi
.fn()
.mockResolvedValue({ content: [{ type: 'text' as const, text: 'ok' }], isError: false });
const wrapped = withErrorHandling(fn);
const result = await wrapped();
expect(result.isError).toBe(false);
expect(result.content[0].text).toBe('ok');
});
it('catches a thrown Error and returns an error MCPToolResult', async () => {
const fn = vi.fn().mockRejectedValue(new Error('network down'));
const wrapped = withErrorHandling(fn, ErrorCategory.MSG);
const result = await wrapped();
expect(result.isError).toBe(true);
expect(result.content[0].text).toMatch(/code:/);
});
it('catches a thrown non-Error value and wraps it', async () => {
const fn = vi.fn().mockRejectedValue('string error');
const wrapped = withErrorHandling(fn);
const result = await wrapped();
expect(result.isError).toBe(true);
});
it('exposes ValidationError message through the wrapper', async () => {
const fn = vi.fn().mockRejectedValue(new ValidationError('param must be string'));
const wrapped = withErrorHandling(fn, ErrorCategory.VALIDATION);
const result = await wrapped();
expect(result.isError).toBe(true);
expect(result.content[0].text).toBe('param must be string');
});
});
+162
View File
@@ -0,0 +1,162 @@
/**
* Unit tests for MCP rate limiter
*
* Note: tests that would exercise real sleeping (inter-call delays, per-minute
* window waits) are skipped to keep the suite fast. Those paths require either
* fake timers or vi.useFakeTimers() integration with async Promises, which
* conflicts with the shared mock-server setup in setup.ts.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
classifyTool,
enforceRateLimit,
getRateLimitStatus,
isHeavyTool,
isStateOnlyTool,
RATE_LIMIT_CONFIG,
resetRequestCallCount,
} from './rateLimiter';
// Reset module-level state before every test so tests are independent.
beforeEach(() => {
resetRequestCallCount();
});
// Restore any timer fakes after each test.
afterEach(() => {
vi.useRealTimers();
});
describe('classifyTool', () => {
it('classifies a known state-only tool as state_only', () => {
expect(classifyTool('get_chats')).toBe('state_only');
});
it('classifies a known API read tool as api_read', () => {
expect(classifyTool('search_contacts')).toBe('api_read');
});
it('classifies a known API write tool as api_write', () => {
expect(classifyTool('send_message')).toBe('api_write');
});
it('defaults unknown tools to api_read', () => {
expect(classifyTool('totally_unknown_tool')).toBe('api_read');
});
});
describe('isStateOnlyTool', () => {
it('returns true for get_me', () => {
expect(isStateOnlyTool('get_me')).toBe(true);
});
it('returns false for send_message', () => {
expect(isStateOnlyTool('send_message')).toBe(false);
});
});
describe('isHeavyTool', () => {
it('returns true for delete_message', () => {
expect(isHeavyTool('delete_message')).toBe(true);
});
it('returns false for get_me', () => {
expect(isHeavyTool('get_me')).toBe(false);
});
});
describe('getRateLimitStatus', () => {
it('returns zero call counts at startup', () => {
const status = getRateLimitStatus();
expect(status.callsThisRequest).toBe(0);
expect(typeof status.callsThisMinute).toBe('number');
expect(status.callsThisMinute).toBeGreaterThanOrEqual(0);
});
});
describe('enforceRateLimit — state_only tools', () => {
it('resolves immediately without incrementing the request counter', async () => {
await enforceRateLimit('get_chats');
// state_only tools must not count against the per-request budget
expect(getRateLimitStatus().callsThisRequest).toBe(0);
});
it('resolves immediately even when called many times', async () => {
for (let i = 0; i < 25; i++) {
await enforceRateLimit('get_me');
}
expect(getRateLimitStatus().callsThisRequest).toBe(0);
});
});
describe('enforceRateLimit — per-request cap', () => {
it('throws when the per-request cap is exceeded', async () => {
// Use fake timers so inter-call delays resolve instantly.
vi.useFakeTimers();
const max = RATE_LIMIT_CONFIG.MAX_CALLS_PER_REQUEST;
// Build promises and immediately attach a catch so Node never sees an
// unhandled rejection, even if the async fn throws synchronously.
const settled: Array<{ ok: true } | { ok: false; err: Error }> = [];
const promises = Array.from({ length: max + 1 }, () => {
const p = enforceRateLimit('search_contacts', 'api_read');
p.catch(() => {}); // suppress unhandled rejection
return p;
});
await vi.runAllTimersAsync();
for (const p of promises) {
await p.then(
() => settled.push({ ok: true }),
(err: Error) => settled.push({ ok: false, err })
);
}
const rejected = settled.filter(s => !s.ok);
expect(rejected.length).toBeGreaterThanOrEqual(1);
const firstRejected = rejected[0];
if (!firstRejected.ok) {
expect(firstRejected.err.message).toMatch(/Rate limit/);
}
});
});
describe('resetRequestCallCount', () => {
it('resets the per-request counter so subsequent API calls are allowed again', async () => {
vi.useFakeTimers();
// Fill up the request budget, suppressing rejections to avoid unhandled errors.
const max = RATE_LIMIT_CONFIG.MAX_CALLS_PER_REQUEST;
const fill = Array.from({ length: max }, () => {
const p = enforceRateLimit('list_contacts', 'api_read');
p.catch(() => {});
return p;
});
await vi.runAllTimersAsync();
// Drain settled results (some may reject if counter already exceeded)
await Promise.allSettled(fill);
// Reset and confirm a subsequent call succeeds
resetRequestCallCount();
const afterReset = enforceRateLimit('list_contacts', 'api_read');
await vi.runAllTimersAsync();
await expect(afterReset).resolves.toBeUndefined();
});
});
describe('RATE_LIMIT_CONFIG', () => {
it('has the expected shape', () => {
expect(RATE_LIMIT_CONFIG.API_READ_DELAY_MS).toBeTypeOf('number');
expect(RATE_LIMIT_CONFIG.API_WRITE_DELAY_MS).toBeTypeOf('number');
expect(RATE_LIMIT_CONFIG.MAX_CALLS_PER_MINUTE).toBeTypeOf('number');
expect(RATE_LIMIT_CONFIG.MAX_CALLS_PER_REQUEST).toBeTypeOf('number');
// Write delay should be heavier than read delay
expect(RATE_LIMIT_CONFIG.API_WRITE_DELAY_MS).toBeGreaterThan(
RATE_LIMIT_CONFIG.API_READ_DELAY_MS
);
});
});
+218
View File
@@ -0,0 +1,218 @@
/**
* Unit tests for SocketIOMCPTransportImpl
*
* The socket.io-client module is replaced with a lightweight in-process fake
* so no real network is involved.
*/
import { describe, expect, it, vi } from 'vitest';
import { SocketIOMCPTransportImpl } from './transport';
import type { MCPRequest, MCPResponse } from './types';
// ---------------------------------------------------------------------------
// Minimal Socket fake
// ---------------------------------------------------------------------------
type EventHandler = (...args: unknown[]) => void;
function makeSocket(overrides: { connected?: boolean } = {}) {
const handlers = new Map<string, EventHandler[]>();
const socket = {
connected: overrides.connected ?? true,
emit: vi.fn(),
on(event: string, handler: EventHandler) {
if (!handlers.has(event)) handlers.set(event, []);
handlers.get(event)!.push(handler);
},
off(event: string, handler: EventHandler) {
const list = handlers.get(event) ?? [];
const idx = list.indexOf(handler);
if (idx !== -1) list.splice(idx, 1);
},
/** Test helper: trigger a registered handler */
trigger(event: string, ...args: unknown[]) {
for (const h of handlers.get(event) ?? []) {
h(...args);
}
},
_handlers: handlers,
};
return socket;
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function makeRequest(id: string | number = 'req-1', method = 'test.method'): MCPRequest {
return { jsonrpc: '2.0', id, method };
}
function makeResponse(id: string | number, result: unknown = { ok: true }): MCPResponse {
return { jsonrpc: '2.0', id, result };
}
function makeErrorResponse(id: string | number, message = 'RPC error'): MCPResponse {
return { jsonrpc: '2.0', id, error: { code: -32000, message } };
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe('SocketIOMCPTransportImpl — connected property', () => {
it('returns true when the socket is connected', () => {
const socket = makeSocket({ connected: true });
const transport = new SocketIOMCPTransportImpl(socket as never);
expect(transport.connected).toBe(true);
});
it('returns false when the socket is disconnected', () => {
const socket = makeSocket({ connected: false });
const transport = new SocketIOMCPTransportImpl(socket as never);
expect(transport.connected).toBe(false);
});
it('returns false when socket is null', () => {
const transport = new SocketIOMCPTransportImpl(null);
expect(transport.connected).toBe(false);
});
});
describe('SocketIOMCPTransportImpl — emit', () => {
it('emits with the mcp: prefix when socket is connected', () => {
const socket = makeSocket();
const transport = new SocketIOMCPTransportImpl(socket as never);
transport.emit('request', { id: 1 });
expect(socket.emit).toHaveBeenCalledWith('mcp:request', { id: 1 });
});
it('does nothing when socket is disconnected', () => {
const socket = makeSocket({ connected: false });
const transport = new SocketIOMCPTransportImpl(socket as never);
transport.emit('request', { id: 1 });
expect(socket.emit).not.toHaveBeenCalled();
});
});
describe('SocketIOMCPTransportImpl — on / off', () => {
it('registers a handler on the prefixed event', () => {
const socket = makeSocket();
const transport = new SocketIOMCPTransportImpl(socket as never);
const handler = vi.fn();
transport.on('tool_call', handler);
// Trigger via the raw socket
socket.trigger('mcp:tool_call', { data: 42 });
expect(handler).toHaveBeenCalledWith({ data: 42 });
});
// TODO: transport.off() passes the original handler reference to socket.off() instead of
// the wrapped handler registered via socket.on(). This means the wrapped handler may still
// fire after off() is called — the deregistration is a no-op in practice. Fix the bug in
// transport.ts first, then replace this todo with a behavioural assertion.
it.todo(
'off removes the handler (pending fix: off() passes original instead of wrapped handler)'
);
});
describe('SocketIOMCPTransportImpl — request / response routing', () => {
it('resolves when a matching response arrives', async () => {
const socket = makeSocket();
const transport = new SocketIOMCPTransportImpl(socket as never);
const req = makeRequest('abc');
const promise = transport.request(req);
// Simulate the backend replying
socket.trigger('mcp:response', makeResponse('abc'));
const response = await promise;
expect(response.result).toEqual({ ok: true });
expect(response.id).toBe('abc');
});
it('rejects when the response contains an error', async () => {
const socket = makeSocket();
const transport = new SocketIOMCPTransportImpl(socket as never);
const req = makeRequest('err-1');
const promise = transport.request(req);
socket.trigger('mcp:response', makeErrorResponse('err-1', 'Not found'));
await expect(promise).rejects.toThrow('Not found');
});
it('rejects with a timeout error when no response arrives within timeoutMs', async () => {
vi.useFakeTimers();
const socket = makeSocket();
const transport = new SocketIOMCPTransportImpl(socket as never);
const req = makeRequest('timeout-1');
const promise = transport.request(req, 1000);
vi.advanceTimersByTime(1001);
await expect(promise).rejects.toThrow(/timeout/i);
vi.useRealTimers();
});
it('cleans up the handler after a successful response', async () => {
const socket = makeSocket();
const transport = new SocketIOMCPTransportImpl(socket as never);
const req = makeRequest('clean-1');
const promise = transport.request(req);
socket.trigger('mcp:response', makeResponse('clean-1'));
await promise;
// A second response with the same id should be ignored (no handler left)
// We verify no throw occurs — the response handler logs a warn and returns.
expect(() => socket.trigger('mcp:response', makeResponse('clean-1'))).not.toThrow();
});
it('rejects immediately when the socket is not connected', async () => {
const socket = makeSocket({ connected: false });
const transport = new SocketIOMCPTransportImpl(socket as never);
await expect(transport.request(makeRequest())).rejects.toThrow('Socket not connected');
});
it('routes concurrent requests independently by id', async () => {
const socket = makeSocket();
const transport = new SocketIOMCPTransportImpl(socket as never);
const p1 = transport.request(makeRequest('r1'));
const p2 = transport.request(makeRequest('r2'));
socket.trigger('mcp:response', makeResponse('r2', { data: 'second' }));
socket.trigger('mcp:response', makeResponse('r1', { data: 'first' }));
const [res1, res2] = await Promise.all([p1, p2]);
expect((res1.result as { data: string }).data).toBe('first');
expect((res2.result as { data: string }).data).toBe('second');
});
});
describe('SocketIOMCPTransportImpl — updateSocket', () => {
it('switches to the new socket and removes old listeners', () => {
const socket1 = makeSocket();
const socket2 = makeSocket();
const transport = new SocketIOMCPTransportImpl(socket1 as never);
transport.updateSocket(socket2 as never);
// Old socket should no longer have the mcp:response listener.
const oldHandlers = socket1._handlers.get('mcp:response') ?? [];
expect(oldHandlers).toHaveLength(0);
// New socket should have it.
const newHandlers = socket2._handlers.get('mcp:response') ?? [];
expect(newHandlers.length).toBeGreaterThan(0);
});
it('sets socket to undefined when called with null', () => {
const socket = makeSocket();
const transport = new SocketIOMCPTransportImpl(socket as never);
transport.updateSocket(null);
expect(transport.connected).toBe(false);
});
});
+121
View File
@@ -0,0 +1,121 @@
/**
* Unit tests for MCP validation utilities
*/
import { describe, expect, it } from 'vitest';
import {
validateId,
validateIdList,
validateOptionalId,
validatePositiveInt,
ValidationError,
} from './validation';
describe('ValidationError', () => {
it('is an instance of Error with name ValidationError', () => {
const err = new ValidationError('bad input');
expect(err).toBeInstanceOf(Error);
expect(err.name).toBe('ValidationError');
expect(err.message).toBe('bad input');
});
});
describe('validateId', () => {
it('accepts a positive integer', () => {
expect(validateId(123, 'chat_id')).toBe(123);
});
it('accepts a negative integer', () => {
expect(validateId(-100, 'chat_id')).toBe(-100);
});
it('accepts a numeric string and returns the parsed integer', () => {
expect(validateId('456', 'user_id')).toBe(456);
});
it('accepts a bare username string and prepends @', () => {
expect(validateId('username', 'user_id')).toBe('@username');
});
it('accepts a username string that already starts with @', () => {
expect(validateId('@username', 'user_id')).toBe('@username');
});
it('throws ValidationError for a non-integer number', () => {
expect(() => validateId(3.14, 'chat_id')).toThrow(ValidationError);
});
it('throws ValidationError for an invalid string that is not a number or username', () => {
expect(() => validateId('!!!', 'chat_id')).toThrow(ValidationError);
});
it('throws ValidationError for null', () => {
expect(() => validateId(null, 'chat_id')).toThrow(ValidationError);
});
it('throws ValidationError for boolean', () => {
expect(() => validateId(true, 'chat_id')).toThrow(ValidationError);
});
});
describe('validateIdList', () => {
it('validates an array of integers', () => {
expect(validateIdList([1, 2, 3], 'ids')).toEqual([1, 2, 3]);
});
it('validates a mixed array of integers and usernames', () => {
expect(validateIdList([42, 'alice'], 'ids')).toEqual([42, '@alice']);
});
it('throws ValidationError when value is not an array', () => {
expect(() => validateIdList('not-an-array', 'ids')).toThrow(ValidationError);
});
it('throws ValidationError when an element is invalid', () => {
expect(() => validateIdList([1, null], 'ids')).toThrow(ValidationError);
});
});
describe('validatePositiveInt', () => {
it('accepts a positive integer', () => {
expect(validatePositiveInt(5, 'msg_id')).toBe(5);
});
it('accepts a positive integer string', () => {
expect(validatePositiveInt('10', 'msg_id')).toBe(10);
});
it('throws ValidationError for zero', () => {
expect(() => validatePositiveInt(0, 'msg_id')).toThrow(ValidationError);
});
it('throws ValidationError for a negative number', () => {
expect(() => validatePositiveInt(-1, 'msg_id')).toThrow(ValidationError);
});
it('throws ValidationError for a float', () => {
expect(() => validatePositiveInt(1.5, 'msg_id')).toThrow(ValidationError);
});
it('throws ValidationError for a non-numeric string', () => {
expect(() => validatePositiveInt('abc', 'msg_id')).toThrow(ValidationError);
});
});
describe('validateOptionalId', () => {
it('returns undefined for undefined input', () => {
expect(validateOptionalId(undefined, 'chat_id')).toBeUndefined();
});
it('returns undefined for null input', () => {
expect(validateOptionalId(null, 'chat_id')).toBeUndefined();
});
it('delegates to validateId for a present value', () => {
expect(validateOptionalId(99, 'chat_id')).toBe(99);
});
it('throws ValidationError when the present value is invalid', () => {
expect(() => validateOptionalId(true, 'chat_id')).toThrow(ValidationError);
});
});
+226
View File
@@ -0,0 +1,226 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
const mockCallCoreCommand = vi.fn();
vi.mock('../coreCommandClient', () => ({
callCoreCommand: (...args: unknown[]) => mockCallCoreCommand(...args),
}));
// ---------------------------------------------------------------------------
// Fixtures
// ---------------------------------------------------------------------------
function makeTunnel(id: string) {
return {
id,
uuid: `uuid-${id}`,
name: `Tunnel ${id}`,
description: 'A test tunnel',
isActive: true,
createdAt: '2026-01-01T00:00:00Z',
updatedAt: '2026-01-01T00:00:00Z',
};
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe('tunnelsApi.createTunnel', () => {
beforeEach(() => {
mockCallCoreCommand.mockReset();
});
it('calls webhooks_create_tunnel with the request body', async () => {
const tunnel = makeTunnel('t-1');
mockCallCoreCommand.mockResolvedValueOnce(tunnel);
const { tunnelsApi } = await import('./tunnelsApi');
const result = await tunnelsApi.createTunnel({ name: 'My Tunnel', description: 'desc' });
expect(mockCallCoreCommand).toHaveBeenCalledWith('openhuman.webhooks_create_tunnel', {
name: 'My Tunnel',
description: 'desc',
});
expect(result.id).toBe('t-1');
expect(result.name).toBe('Tunnel t-1');
});
it('creates a tunnel without an optional description', async () => {
mockCallCoreCommand.mockResolvedValueOnce(makeTunnel('t-2'));
const { tunnelsApi } = await import('./tunnelsApi');
await tunnelsApi.createTunnel({ name: 'No Desc' });
expect(mockCallCoreCommand).toHaveBeenCalledWith('openhuman.webhooks_create_tunnel', {
name: 'No Desc',
});
});
it('propagates rejection from callCoreCommand', async () => {
mockCallCoreCommand.mockRejectedValueOnce(new Error('create failed'));
const { tunnelsApi } = await import('./tunnelsApi');
await expect(tunnelsApi.createTunnel({ name: 'bad' })).rejects.toThrow('create failed');
});
});
describe('tunnelsApi.getTunnels', () => {
beforeEach(() => {
mockCallCoreCommand.mockReset();
});
it('calls webhooks_list_tunnels with no params', async () => {
const tunnels = [makeTunnel('t-1'), makeTunnel('t-2')];
mockCallCoreCommand.mockResolvedValueOnce(tunnels);
const { tunnelsApi } = await import('./tunnelsApi');
const result = await tunnelsApi.getTunnels();
expect(mockCallCoreCommand).toHaveBeenCalledWith('openhuman.webhooks_list_tunnels');
expect(result).toHaveLength(2);
});
it('returns empty array when no tunnels exist', async () => {
mockCallCoreCommand.mockResolvedValueOnce([]);
const { tunnelsApi } = await import('./tunnelsApi');
const result = await tunnelsApi.getTunnels();
expect(result).toEqual([]);
});
});
describe('tunnelsApi.getBandwidthUsage', () => {
beforeEach(() => {
mockCallCoreCommand.mockReset();
});
it('calls webhooks_get_bandwidth and returns budget info', async () => {
mockCallCoreCommand.mockResolvedValueOnce({ remainingBudgetUsd: 4.5 });
const { tunnelsApi } = await import('./tunnelsApi');
const result = await tunnelsApi.getBandwidthUsage();
expect(mockCallCoreCommand).toHaveBeenCalledWith('openhuman.webhooks_get_bandwidth');
expect(result.remainingBudgetUsd).toBe(4.5);
});
});
describe('tunnelsApi.getTunnel', () => {
beforeEach(() => {
mockCallCoreCommand.mockReset();
});
it('calls webhooks_get_tunnel with the tunnel ID', async () => {
mockCallCoreCommand.mockResolvedValueOnce(makeTunnel('t-99'));
const { tunnelsApi } = await import('./tunnelsApi');
const result = await tunnelsApi.getTunnel('t-99');
expect(mockCallCoreCommand).toHaveBeenCalledWith('openhuman.webhooks_get_tunnel', {
id: 't-99',
});
expect(result.id).toBe('t-99');
});
it('propagates rejection when tunnel is not found', async () => {
mockCallCoreCommand.mockRejectedValueOnce(new Error('not found'));
const { tunnelsApi } = await import('./tunnelsApi');
await expect(tunnelsApi.getTunnel('missing')).rejects.toThrow('not found');
});
});
describe('tunnelsApi.updateTunnel', () => {
beforeEach(() => {
mockCallCoreCommand.mockReset();
});
it('calls webhooks_update_tunnel merging id with body fields', async () => {
const updated = { ...makeTunnel('t-1'), name: 'Renamed' };
mockCallCoreCommand.mockResolvedValueOnce(updated);
const { tunnelsApi } = await import('./tunnelsApi');
const result = await tunnelsApi.updateTunnel('t-1', { name: 'Renamed' });
expect(mockCallCoreCommand).toHaveBeenCalledWith('openhuman.webhooks_update_tunnel', {
id: 't-1',
name: 'Renamed',
});
expect(result.name).toBe('Renamed');
});
it('can set isActive to false', async () => {
const deactivated = { ...makeTunnel('t-2'), isActive: false };
mockCallCoreCommand.mockResolvedValueOnce(deactivated);
const { tunnelsApi } = await import('./tunnelsApi');
const result = await tunnelsApi.updateTunnel('t-2', { isActive: false });
expect(mockCallCoreCommand).toHaveBeenCalledWith('openhuman.webhooks_update_tunnel', {
id: 't-2',
isActive: false,
});
expect(result.isActive).toBe(false);
});
it('propagates rejection from callCoreCommand', async () => {
mockCallCoreCommand.mockRejectedValueOnce(new Error('update failed'));
const { tunnelsApi } = await import('./tunnelsApi');
await expect(tunnelsApi.updateTunnel('t-bad', { name: 'x' })).rejects.toThrow('update failed');
});
});
describe('tunnelsApi.deleteTunnel', () => {
beforeEach(() => {
mockCallCoreCommand.mockReset();
});
it('calls webhooks_delete_tunnel with the tunnel ID', async () => {
mockCallCoreCommand.mockResolvedValueOnce(undefined);
const { tunnelsApi } = await import('./tunnelsApi');
await tunnelsApi.deleteTunnel('t-1');
expect(mockCallCoreCommand).toHaveBeenCalledWith('openhuman.webhooks_delete_tunnel', {
id: 't-1',
});
});
it('resolves without a return value on success', async () => {
mockCallCoreCommand.mockResolvedValueOnce(undefined);
const { tunnelsApi } = await import('./tunnelsApi');
const result = await tunnelsApi.deleteTunnel('t-1');
expect(result).toBeUndefined();
});
it('propagates rejection from callCoreCommand', async () => {
mockCallCoreCommand.mockRejectedValueOnce(new Error('delete failed'));
const { tunnelsApi } = await import('./tunnelsApi');
await expect(tunnelsApi.deleteTunnel('t-bad')).rejects.toThrow('delete failed');
});
});
describe('tunnelsApi.ingressUrl', () => {
it('builds the correct ingress URL from backend URL and tunnel UUID', async () => {
const { tunnelsApi } = await import('./tunnelsApi');
const url = tunnelsApi.ingressUrl('https://api.example.com', 'uuid-abc');
expect(url).toBe('https://api.example.com/webhooks/ingress/uuid-abc');
});
it('strips trailing slash from backendUrl before building the URL', async () => {
const { tunnelsApi } = await import('./tunnelsApi');
const url = tunnelsApi.ingressUrl('https://api.example.com/', 'uuid-xyz');
expect(url).toBe('https://api.example.com/webhooks/ingress/uuid-xyz');
});
it('works with localhost URLs', async () => {
const { tunnelsApi } = await import('./tunnelsApi');
const url = tunnelsApi.ingressUrl('http://localhost:5005', 'test-uuid');
expect(url).toBe('http://localhost:5005/webhooks/ingress/test-uuid');
});
});
@@ -0,0 +1,91 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
const mockCallCoreRpc = vi.fn();
vi.mock('./coreRpcClient', () => ({
callCoreRpc: (...args: unknown[]) => mockCallCoreRpc(...args),
}));
describe('coreCommandClient', () => {
beforeEach(() => {
mockCallCoreRpc.mockReset();
});
it('returns result from a successful CoreCommandResponse', async () => {
mockCallCoreRpc.mockResolvedValueOnce({ result: 42, logs: [] });
const { callCoreCommand } = await import('./coreCommandClient');
const out = await callCoreCommand<number>('openhuman.some_method');
expect(out).toBe(42);
});
it('forwards method name to callCoreRpc', async () => {
mockCallCoreRpc.mockResolvedValueOnce({ result: 'ok', logs: [] });
const { callCoreCommand } = await import('./coreCommandClient');
await callCoreCommand('openhuman.webhooks_list_tunnels');
expect(mockCallCoreRpc).toHaveBeenCalledWith({
method: 'openhuman.webhooks_list_tunnels',
params: undefined,
});
});
it('forwards params to callCoreRpc when provided', async () => {
mockCallCoreRpc.mockResolvedValueOnce({ result: { id: 't1' }, logs: [] });
const { callCoreCommand } = await import('./coreCommandClient');
const params = { id: 'tunnel-99', name: 'my-hook' };
await callCoreCommand('openhuman.webhooks_update_tunnel', params);
expect(mockCallCoreRpc).toHaveBeenCalledWith({
method: 'openhuman.webhooks_update_tunnel',
params,
});
});
it('returns object result intact', async () => {
const payload = { tunnelId: 'abc', active: true };
mockCallCoreRpc.mockResolvedValueOnce({ result: payload, logs: ['created'] });
const { callCoreCommand } = await import('./coreCommandClient');
const out = await callCoreCommand<typeof payload>('openhuman.webhooks_create_tunnel', {
name: 'test',
});
expect(out).toEqual(payload);
});
it('propagates rejection from callCoreRpc', async () => {
mockCallCoreRpc.mockRejectedValueOnce(new Error('core offline'));
const { callCoreCommand } = await import('./coreCommandClient');
await expect(callCoreCommand('openhuman.config_get')).rejects.toThrow('core offline');
});
it('handles undefined params gracefully (does not throw)', async () => {
mockCallCoreRpc.mockResolvedValueOnce({ result: null, logs: [] });
const { callCoreCommand } = await import('./coreCommandClient');
await expect(callCoreCommand('openhuman.app_state_snapshot')).resolves.toBeNull();
});
it('returns null result when core responds with null', async () => {
mockCallCoreRpc.mockResolvedValueOnce({ result: null, logs: [] });
const { callCoreCommand } = await import('./coreCommandClient');
const out = await callCoreCommand('openhuman.some_nullable_method');
expect(out).toBeNull();
});
it('returns array result when core responds with an array', async () => {
const list = [{ id: '1' }, { id: '2' }];
mockCallCoreRpc.mockResolvedValueOnce({ result: list, logs: [] });
const { callCoreCommand } = await import('./coreCommandClient');
const out = await callCoreCommand<typeof list>('openhuman.list_items');
expect(out).toEqual(list);
expect(out).toHaveLength(2);
});
});
+235
View File
@@ -0,0 +1,235 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
const mockCallCoreRpc = vi.fn();
vi.mock('./coreRpcClient', () => ({
callCoreRpc: (...args: unknown[]) => mockCallCoreRpc(...args),
}));
// Minimal fixtures -----------------------------------------------------------------
function makeSnapshotResult(overrides: Record<string, unknown> = {}) {
return {
auth: { isAuthenticated: true, userId: 'u-1', user: null, profileId: 'p-1' },
sessionToken: 'tok-abc',
currentUser: null,
onboardingCompleted: false,
analyticsEnabled: true,
localState: { encryptionKey: null, primaryWalletAddress: null, onboardingTasks: null },
runtime: { screenIntelligence: {}, localAi: {}, autocomplete: {}, service: {} },
...overrides,
};
}
function makeTeam(id: string) {
return {
team: {
_id: id,
name: `Team ${id}`,
slug: id,
createdBy: 'u-0',
isPersonal: false,
maxMembers: 10,
subscription: { plan: 'FREE' as const, hasActiveSubscription: false },
usage: { dailyTokenLimit: 0, remainingTokens: 0, activeSessionCount: 0 },
createdAt: '2026-01-01T00:00:00Z',
updatedAt: '2026-01-01T00:00:00Z',
},
role: 'MEMBER' as const,
};
}
function makeMember(id: string) {
return {
_id: id,
user: { _id: `u-${id}` },
role: 'MEMBER' as const,
joinedAt: '2026-01-01T00:00:00Z',
};
}
function makeInvite(id: string) {
return {
_id: id,
code: `code-${id}`,
createdBy: 'u-0',
expiresAt: '2027-01-01T00:00:00Z',
maxUses: 10,
currentUses: 0,
usageHistory: [],
};
}
// Tests ----------------------------------------------------------------------------
describe('coreStateApi.fetchCoreAppSnapshot', () => {
beforeEach(() => {
mockCallCoreRpc.mockReset();
});
it('calls the correct RPC method', async () => {
mockCallCoreRpc.mockResolvedValueOnce({ result: makeSnapshotResult() });
const { fetchCoreAppSnapshot } = await import('./coreStateApi');
await fetchCoreAppSnapshot();
expect(mockCallCoreRpc).toHaveBeenCalledWith({ method: 'openhuman.app_state_snapshot' });
});
it('returns the inner result from the RPC envelope', async () => {
const snapshot = makeSnapshotResult({ sessionToken: 'tok-xyz' });
mockCallCoreRpc.mockResolvedValueOnce({ result: snapshot });
const { fetchCoreAppSnapshot } = await import('./coreStateApi');
const out = await fetchCoreAppSnapshot();
expect(out.sessionToken).toBe('tok-xyz');
expect(out.auth.isAuthenticated).toBe(true);
expect(out.auth.userId).toBe('u-1');
});
it('propagates rejection from callCoreRpc', async () => {
mockCallCoreRpc.mockRejectedValueOnce(new Error('snapshot failed'));
const { fetchCoreAppSnapshot } = await import('./coreStateApi');
await expect(fetchCoreAppSnapshot()).rejects.toThrow('snapshot failed');
});
});
describe('coreStateApi.updateCoreLocalState', () => {
beforeEach(() => {
mockCallCoreRpc.mockReset();
});
it('calls the correct RPC method with params', async () => {
mockCallCoreRpc.mockResolvedValueOnce({});
const { updateCoreLocalState } = await import('./coreStateApi');
const params = { encryptionKey: 'key-123', primaryWalletAddress: '0xABCD' };
await updateCoreLocalState(params);
expect(mockCallCoreRpc).toHaveBeenCalledWith({
method: 'openhuman.app_state_update_local_state',
params,
});
});
it('resolves without a return value on success', async () => {
mockCallCoreRpc.mockResolvedValueOnce({});
const { updateCoreLocalState } = await import('./coreStateApi');
const result = await updateCoreLocalState({ encryptionKey: null });
expect(result).toBeUndefined();
});
it('passes null fields correctly to the RPC', async () => {
mockCallCoreRpc.mockResolvedValueOnce({});
const { updateCoreLocalState } = await import('./coreStateApi');
await updateCoreLocalState({
encryptionKey: null,
primaryWalletAddress: null,
onboardingTasks: null,
});
const call = mockCallCoreRpc.mock.calls[0][0] as { params: unknown };
expect(call.params).toEqual({
encryptionKey: null,
primaryWalletAddress: null,
onboardingTasks: null,
});
});
it('propagates rejection from callCoreRpc', async () => {
mockCallCoreRpc.mockRejectedValueOnce(new Error('update rejected'));
const { updateCoreLocalState } = await import('./coreStateApi');
await expect(updateCoreLocalState({})).rejects.toThrow('update rejected');
});
});
describe('coreStateApi.listTeams', () => {
beforeEach(() => {
mockCallCoreRpc.mockReset();
});
it('calls team_list_teams RPC and returns teams array', async () => {
const teams = [makeTeam('t-1'), makeTeam('t-2')];
mockCallCoreRpc.mockResolvedValueOnce({ result: teams });
const { listTeams } = await import('./coreStateApi');
const out = await listTeams();
expect(mockCallCoreRpc).toHaveBeenCalledWith({ method: 'openhuman.team_list_teams' });
expect(out).toHaveLength(2);
expect(out[0].team._id).toBe('t-1');
});
it('returns empty array when no teams exist', async () => {
mockCallCoreRpc.mockResolvedValueOnce({ result: [] });
const { listTeams } = await import('./coreStateApi');
const out = await listTeams();
expect(out).toEqual([]);
});
});
describe('coreStateApi.getTeamMembers', () => {
beforeEach(() => {
mockCallCoreRpc.mockReset();
});
it('calls team_list_members with teamId param', async () => {
const members = [makeMember('m-1')];
mockCallCoreRpc.mockResolvedValueOnce({ result: members });
const { getTeamMembers } = await import('./coreStateApi');
const out = await getTeamMembers('t-1');
expect(mockCallCoreRpc).toHaveBeenCalledWith({
method: 'openhuman.team_list_members',
params: { teamId: 't-1' },
});
expect(out[0]._id).toBe('m-1');
});
it('returns the inner result array', async () => {
const members = [makeMember('a'), makeMember('b')];
mockCallCoreRpc.mockResolvedValueOnce({ result: members });
const { getTeamMembers } = await import('./coreStateApi');
const out = await getTeamMembers('t-99');
expect(out).toHaveLength(2);
});
});
describe('coreStateApi.getTeamInvites', () => {
beforeEach(() => {
mockCallCoreRpc.mockReset();
});
it('calls team_list_invites with teamId param', async () => {
const invites = [makeInvite('inv-1')];
mockCallCoreRpc.mockResolvedValueOnce({ result: invites });
const { getTeamInvites } = await import('./coreStateApi');
const out = await getTeamInvites('t-1');
expect(mockCallCoreRpc).toHaveBeenCalledWith({
method: 'openhuman.team_list_invites',
params: { teamId: 't-1' },
});
expect(out[0]._id).toBe('inv-1');
});
it('returns empty array when no invites exist', async () => {
mockCallCoreRpc.mockResolvedValueOnce({ result: [] });
const { getTeamInvites } = await import('./coreStateApi');
const out = await getTeamInvites('t-empty');
expect(out).toEqual([]);
});
});
+361
View File
@@ -0,0 +1,361 @@
/**
* @vitest-environment jsdom
*
* Tests for errorReportQueue — module-level error queue with no React/Redux/Sentry deps.
*
* Because the module holds mutable singleton state, we re-import it fresh in each
* describe block via `vi.resetModules()` to isolate tests from each other.
*
* The source module calls `initGlobalListeners()` at load time, which reads `window`
* — the explicit environment directive ensures jsdom is active even when Vitest picks
* a different default for a given run.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
// setup.ts already mocks @sentry/react and ../utils/config globally.
// We rely on those global mocks here.
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function makeReport(
id: string,
overrides: Partial<{
title: string;
message: string;
source: 'react' | 'global' | 'manual';
timestamp: number;
}> = {}
) {
return {
id,
timestamp: overrides.timestamp ?? Date.now(),
source: overrides.source ?? ('manual' as const),
title: overrides.title ?? `Error ${id}`,
message: overrides.message ?? `Message for ${id}`,
sentryEvent: null,
};
}
// ---------------------------------------------------------------------------
// enqueueError / getErrors / dequeueError
// ---------------------------------------------------------------------------
describe('errorReportQueue — enqueue / dequeue / getErrors', () => {
let eq: typeof import('./errorReportQueue');
beforeEach(async () => {
vi.resetModules();
eq = await import('./errorReportQueue');
});
afterEach(() => {
vi.useRealTimers();
});
it('starts with an empty queue', () => {
expect(eq.getErrors()).toEqual([]);
});
it('adds a report and returns it from getErrors', () => {
const report = makeReport('r1');
eq.enqueueError(report);
const queue = eq.getErrors();
expect(queue).toHaveLength(1);
expect(queue[0].id).toBe('r1');
});
it('removes a report via dequeueError', () => {
eq.enqueueError(makeReport('r1'));
eq.enqueueError(makeReport('r2'));
eq.dequeueError('r1');
const queue = eq.getErrors();
expect(queue).toHaveLength(1);
expect(queue[0].id).toBe('r2');
});
it('is a no-op when dequeuing a non-existent ID', () => {
eq.enqueueError(makeReport('r1'));
eq.dequeueError('does-not-exist');
expect(eq.getErrors()).toHaveLength(1);
});
it('preserves insertion order', () => {
eq.enqueueError(makeReport('r1'));
eq.enqueueError(makeReport('r2'));
eq.enqueueError(makeReport('r3'));
const ids = eq.getErrors().map(r => r.id);
expect(ids).toEqual(['r1', 'r2', 'r3']);
});
it('caps queue at 10 items, keeping the most recent', () => {
for (let i = 0; i < 12; i++) {
// Each report has a unique title+message to avoid dedup
eq.enqueueError(makeReport(`r${i}`, { title: `T${i}`, message: `M${i}` }));
}
const queue = eq.getErrors();
expect(queue).toHaveLength(10);
// Oldest two should have been dropped
expect(queue[0].id).toBe('r2');
expect(queue[9].id).toBe('r11');
});
});
// ---------------------------------------------------------------------------
// Deduplication
// ---------------------------------------------------------------------------
describe('errorReportQueue — deduplication', () => {
let eq: typeof import('./errorReportQueue');
beforeEach(async () => {
vi.useFakeTimers();
vi.resetModules();
eq = await import('./errorReportQueue');
});
afterEach(() => {
vi.useRealTimers();
});
it('suppresses a duplicate error within the dedup window (2 s)', () => {
const base = makeReport('r1', { title: 'Same', message: 'Same' });
eq.enqueueError(base);
// Same title + message but different ID — should be deduped
eq.enqueueError({ ...base, id: 'r1-dup' });
expect(eq.getErrors()).toHaveLength(1);
});
it('allows the same error again after the dedup window expires', () => {
const base = makeReport('r1', { title: 'Same', message: 'Same' });
eq.enqueueError(base);
// Advance past the 2000 ms DEDUP_WINDOW_MS
vi.advanceTimersByTime(2001);
eq.enqueueError({ ...base, id: 'r1-after' });
expect(eq.getErrors()).toHaveLength(2);
});
it('allows distinct errors (different title/message) within the dedup window', () => {
eq.enqueueError(makeReport('r1', { title: 'A', message: 'a' }));
eq.enqueueError(makeReport('r2', { title: 'B', message: 'b' }));
expect(eq.getErrors()).toHaveLength(2);
});
});
// ---------------------------------------------------------------------------
// subscribe / notify
// ---------------------------------------------------------------------------
describe('errorReportQueue — subscribe', () => {
let eq: typeof import('./errorReportQueue');
beforeEach(async () => {
vi.resetModules();
eq = await import('./errorReportQueue');
});
it('calls subscriber when a report is enqueued', () => {
const cb = vi.fn();
eq.subscribe(cb);
eq.enqueueError(makeReport('r1'));
expect(cb).toHaveBeenCalledTimes(1);
});
it('calls subscriber when a report is dequeued', () => {
eq.enqueueError(makeReport('r1'));
const cb = vi.fn();
eq.subscribe(cb);
eq.dequeueError('r1');
expect(cb).toHaveBeenCalledTimes(1);
});
it('does not call subscriber after unsubscribe', () => {
const cb = vi.fn();
const unsub = eq.subscribe(cb);
unsub();
eq.enqueueError(makeReport('r1'));
expect(cb).not.toHaveBeenCalled();
});
it('supports multiple simultaneous subscribers', () => {
const cb1 = vi.fn();
const cb2 = vi.fn();
eq.subscribe(cb1);
eq.subscribe(cb2);
eq.enqueueError(makeReport('r1'));
expect(cb1).toHaveBeenCalledTimes(1);
expect(cb2).toHaveBeenCalledTimes(1);
});
it('continues notifying other subscribers if one throws', () => {
const bad = vi.fn(() => {
throw new Error('subscriber boom');
});
const good = vi.fn();
eq.subscribe(bad);
eq.subscribe(good);
expect(() => eq.enqueueError(makeReport('r1'))).not.toThrow();
expect(good).toHaveBeenCalledTimes(1);
});
});
// ---------------------------------------------------------------------------
// tagErrorSource
// ---------------------------------------------------------------------------
describe('errorReportQueue — tagErrorSource', () => {
let eq: typeof import('./errorReportQueue');
beforeEach(async () => {
vi.resetModules();
eq = await import('./errorReportQueue');
});
it('updates source on a queued report matched by sentryEvent.event_id', () => {
const report = {
...makeReport('r1'),
source: 'global' as const,
sentryEvent: {
event_id: 'sentry-abc',
timestamp: Date.now() / 1000,
platform: 'javascript',
environment: 'test',
},
};
eq.enqueueError(report);
eq.tagErrorSource('sentry-abc', 'react', '<ErrorBoundary />');
const updated = eq.getErrors().find(r => r.id === 'r1');
expect(updated?.source).toBe('react');
expect(updated?.componentStack).toBe('<ErrorBoundary />');
});
it('is a no-op when event_id is undefined', () => {
eq.enqueueError(makeReport('r1'));
expect(() => eq.tagErrorSource(undefined, 'react')).not.toThrow();
expect(eq.getErrors()[0].source).toBe('manual');
});
it('is a no-op when event_id does not match any queued report', () => {
eq.enqueueError(makeReport('r1'));
expect(() => eq.tagErrorSource('unknown-id', 'react')).not.toThrow();
expect(eq.getErrors()[0].source).toBe('manual');
});
});
// ---------------------------------------------------------------------------
// registerSentrySender / sendToSentry
// ---------------------------------------------------------------------------
describe('errorReportQueue — sendToSentry', () => {
let eq: typeof import('./errorReportQueue');
beforeEach(async () => {
vi.resetModules();
eq = await import('./errorReportQueue');
});
it('returns false when no sender is registered', () => {
const report = {
...makeReport('r1'),
sentryEvent: {
event_id: 'evt-1',
timestamp: Date.now() / 1000,
platform: 'javascript',
environment: 'test',
},
};
eq.enqueueError(report);
expect(eq.sendToSentry(report)).toBe(false);
});
it('returns false when sentryEvent is null', () => {
const sender = vi.fn();
const report = makeReport('r1');
eq.enqueueError(report);
eq.registerSentrySender(sender);
expect(eq.sendToSentry(report)).toBe(false);
expect(sender).not.toHaveBeenCalled();
});
it('calls the registered sender and removes the report on success', () => {
const sender = vi.fn();
const sentryEvent = {
event_id: 'evt-2',
timestamp: Date.now() / 1000,
platform: 'javascript',
environment: 'test',
};
const report = { ...makeReport('r2'), sentryEvent };
eq.enqueueError(report);
eq.registerSentrySender(sender);
const result = eq.sendToSentry(report);
expect(result).toBe(true);
expect(sender).toHaveBeenCalledWith(sentryEvent);
expect(eq.getErrors().find(r => r.id === 'r2')).toBeUndefined();
});
});
// ---------------------------------------------------------------------------
// buildManualSentryEvent
// ---------------------------------------------------------------------------
describe('errorReportQueue — buildManualSentryEvent', () => {
let eq: typeof import('./errorReportQueue');
beforeEach(async () => {
vi.resetModules();
eq = await import('./errorReportQueue');
});
it('creates an event with the correct error type and value', () => {
const evt = eq.buildManualSentryEvent({ type: 'TypeError', value: 'bad input' });
expect(evt.exception?.values[0].type).toBe('TypeError');
expect(evt.exception?.values[0].value).toBe('bad input');
});
it('sets platform to javascript', () => {
const evt = eq.buildManualSentryEvent({ type: 'Error', value: 'msg' });
expect(evt.platform).toBe('javascript');
});
it('includes optional tags when provided', () => {
const evt = eq.buildManualSentryEvent({ type: 'Error', value: 'msg' }, { context: 'chat' });
expect(evt.tags).toEqual({ context: 'chat' });
});
it('generates a non-empty event_id', () => {
const evt = eq.buildManualSentryEvent({ type: 'Error', value: 'msg' });
expect(evt.event_id).toBeTruthy();
expect(typeof evt.event_id).toBe('string');
});
it('sets timestamp as a unix seconds value (numeric)', () => {
const before = Date.now() / 1000;
const evt = eq.buildManualSentryEvent({ type: 'Error', value: 'msg' });
const after = Date.now() / 1000;
expect(evt.timestamp).toBeGreaterThanOrEqual(before);
expect(evt.timestamp).toBeLessThanOrEqual(after);
});
});
+129
View File
@@ -0,0 +1,129 @@
import { describe, expect, it } from 'vitest';
import {
deriveAesKeyFromMnemonic,
deriveEvmAddressFromMnemonic,
generateMnemonicPhrase,
MNEMONIC_GENERATE_WORD_COUNT,
validateMnemonicPhrase,
} from './cryptoKeys';
// Known-good 12-word BIP39 mnemonic for deterministic assertions.
const KNOWN_MNEMONIC =
'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about';
describe('MNEMONIC_GENERATE_WORD_COUNT', () => {
it('is 12', () => {
expect(MNEMONIC_GENERATE_WORD_COUNT).toBe(12);
});
});
describe('generateMnemonicPhrase', () => {
it('returns a 12-word phrase', () => {
const phrase = generateMnemonicPhrase();
expect(phrase.trim().split(/\s+/)).toHaveLength(12);
});
it('returns a valid BIP39 mnemonic', () => {
const phrase = generateMnemonicPhrase();
expect(validateMnemonicPhrase(phrase)).toBe(true);
});
it('produces a different phrase each call', () => {
const a = generateMnemonicPhrase();
const b = generateMnemonicPhrase();
// Astronomically unlikely to collide; guards against a no-op implementation.
expect(a).not.toBe(b);
});
});
describe('validateMnemonicPhrase', () => {
it('returns true for the known valid mnemonic', () => {
expect(validateMnemonicPhrase(KNOWN_MNEMONIC)).toBe(true);
});
it('returns false for an empty string', () => {
expect(validateMnemonicPhrase('')).toBe(false);
});
it('returns false for a single random word', () => {
expect(validateMnemonicPhrase('abandon')).toBe(false);
});
it('returns false for 12 non-BIP39 words', () => {
expect(
validateMnemonicPhrase('foo bar baz qux quux corge grault garply waldo fred plugh xyzzy')
).toBe(false);
});
it('returns false for an otherwise valid phrase with one word replaced by a non-wordlist word', () => {
const tampered = KNOWN_MNEMONIC.replace('abandon', 'xxxxxxxx');
expect(validateMnemonicPhrase(tampered)).toBe(false);
});
});
describe('deriveAesKeyFromMnemonic', () => {
it('returns a 64-character hex string (256-bit key)', () => {
const key = deriveAesKeyFromMnemonic(KNOWN_MNEMONIC);
expect(key).toMatch(/^[0-9a-f]{64}$/);
});
it('is deterministic for the same mnemonic', () => {
const key1 = deriveAesKeyFromMnemonic(KNOWN_MNEMONIC);
const key2 = deriveAesKeyFromMnemonic(KNOWN_MNEMONIC);
expect(key1).toBe(key2);
});
it('produces a different key for a different mnemonic', () => {
const other = 'zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo wrong';
expect(validateMnemonicPhrase(other)).toBe(true);
const keyA = deriveAesKeyFromMnemonic(KNOWN_MNEMONIC);
const keyB = deriveAesKeyFromMnemonic(other);
expect(keyA).not.toBe(keyB);
});
it('returns a fixed known value for the all-abandon mnemonic', () => {
// Pinned output for salt='openhuman-aes-key-v1', PBKDF2-SHA256, c=100000, dkLen=32.
// If this assertion fails, the KDF parameters or salt have changed — update intentionally.
const key = deriveAesKeyFromMnemonic(KNOWN_MNEMONIC);
expect(key).toBe('dce707ee483afb0a70cb2e076295f9f914e0c62cc097895eabda1c0c1f2f0cb1');
});
});
describe('deriveEvmAddressFromMnemonic', () => {
it('returns a 0x-prefixed 42-character string', () => {
const address = deriveEvmAddressFromMnemonic(KNOWN_MNEMONIC);
expect(address).toMatch(/^0x[0-9a-fA-F]{40}$/);
});
it('is deterministic for the same mnemonic', () => {
const addr1 = deriveEvmAddressFromMnemonic(KNOWN_MNEMONIC);
const addr2 = deriveEvmAddressFromMnemonic(KNOWN_MNEMONIC);
expect(addr1).toBe(addr2);
});
it('returns the well-known address for the all-abandon mnemonic', () => {
// MetaMask / BIP44 m/44'/60'/0'/0/0 for all-abandon is a stable known address.
// Pinned in EIP-55 checksummed form — validates both identity and checksum casing.
const address = deriveEvmAddressFromMnemonic(KNOWN_MNEMONIC);
expect(address).toBe('0x9858EfFD232B4033E47d90003D41EC34EcaEda94');
});
it('returns a different address for a different mnemonic', () => {
const other = 'zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo wrong';
const addrA = deriveEvmAddressFromMnemonic(KNOWN_MNEMONIC);
const addrB = deriveEvmAddressFromMnemonic(other);
expect(addrA).not.toBe(addrB);
});
it('produces a checksummed address (EIP-55)', () => {
const address = deriveEvmAddressFromMnemonic(KNOWN_MNEMONIC);
// At least some characters must be uppercase (otherwise it would just be lowercase hex).
// EIP-55 checksummed addresses contain mixed case by design.
const hex = address.slice(2);
// Not all-lowercase and not all-uppercase → mixed casing applied.
const hasUpper = hex !== hex.toLowerCase();
const hasLower = hex !== hex.toUpperCase();
expect(hasUpper && hasLower).toBe(true);
});
});
+118
View File
@@ -0,0 +1,118 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { toolExecutionTimeoutMsFromEnv, withTimeout } from './withTimeout';
// Provide TOOL_TIMEOUT_SECS that the global setup mock omits.
vi.mock('./config', async importOriginal => {
const actual = await importOriginal<typeof import('./config')>();
return { ...actual, TOOL_TIMEOUT_SECS: 120 };
});
describe('withTimeout', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it('resolves with the promise value when it settles before the timeout', async () => {
const promise = Promise.resolve('ok');
const result = await withTimeout(promise, 5000, 'fast-op');
expect(result).toBe('ok');
});
it('rejects with a timeout error when the timeout elapses first', async () => {
// withTimeout creates an internal timeoutPromise that rejects. When
// Promise.race settles via that rejection, the internal promise itself
// has no handler yet — Node surfaces it as an unhandled rejection warning.
// We suppress it by attaching a catch to the overall race before advancing
// the timer, then asserting on the stored error.
let capturedError: unknown;
const never = new Promise<never>(() => {});
const racePromise = withTimeout(never, 3000, 'slow-op').catch((e: unknown) => {
capturedError = e;
// Swallow here — we assert manually below.
});
await vi.advanceTimersByTimeAsync(3001);
await racePromise; // wait for the catch branch to complete
expect(capturedError).toBeInstanceOf(Error);
expect((capturedError as Error).message).toBe('slow-op timed out after 3s');
});
it('error message rounds timeout to seconds', async () => {
let capturedError: unknown;
const never = new Promise<never>(() => {});
const racePromise = withTimeout(never, 2500, 'half-sec').catch((e: unknown) => {
capturedError = e;
});
await vi.advanceTimersByTimeAsync(3000);
await racePromise;
expect(capturedError).toBeInstanceOf(Error);
// Math.round(2500 / 1000) === 3
expect((capturedError as Error).message).toBe('half-sec timed out after 3s');
});
it('passes through a rejection from the underlying promise', async () => {
const failing = Promise.reject(new Error('upstream failure'));
failing.catch(() => {}); // suppress premature unhandled warning
await expect(withTimeout(failing, 5000, 'fail-op')).rejects.toThrow('upstream failure');
});
it('does not fire the timeout when the promise rejects quickly', async () => {
const err = new Error('quick rejection');
const failing = Promise.reject(err);
failing.catch(() => {}); // suppress premature unhandled warning
const racePromise = withTimeout(failing, 5000, 'quick-fail');
await expect(racePromise).rejects.toThrow('quick rejection');
// Advance well past the timeout — timer should already be cleared.
await vi.advanceTimersByTimeAsync(10000);
});
it('bypasses the timeout when timeoutMs <= 0 and returns the raw promise', async () => {
const promise = Promise.resolve(42);
const result = await withTimeout(promise, 0, 'zero-timeout');
expect(result).toBe(42);
});
it('bypasses the timeout for negative timeoutMs', async () => {
const promise = Promise.resolve('neg');
const result = await withTimeout(promise, -1, 'negative-timeout');
expect(result).toBe('neg');
});
it('resolves with complex return types', async () => {
const payload = { id: 1, data: [true, 'hello'] };
const result = await withTimeout(Promise.resolve(payload), 1000, 'complex');
expect(result).toEqual(payload);
});
it('clears the timer when the promise resolves (no dangling timer)', async () => {
const clearSpy = vi.spyOn(globalThis, 'clearTimeout');
await withTimeout(Promise.resolve('done'), 5000, 'cleanup-check');
expect(clearSpy).toHaveBeenCalled();
});
});
describe('toolExecutionTimeoutMsFromEnv', () => {
it('returns a positive number of milliseconds', () => {
const ms = toolExecutionTimeoutMsFromEnv();
expect(ms).toBeGreaterThan(0);
expect(Number.isFinite(ms)).toBe(true);
});
it('returns 120 000 ms matching the mocked TOOL_TIMEOUT_SECS of 120', () => {
const ms = toolExecutionTimeoutMsFromEnv();
// TOOL_TIMEOUT_SECS is mocked to 120 by this file's vi.mock above.
expect(ms).toBe(120_000);
});
});
+290
View File
@@ -390,3 +390,293 @@ pub async fn autocomplete_start_cli(
"result": start.value,
}))
}
#[cfg(test)]
mod tests {
use super::*;
// ── autocomplete_status ────────────────────────────────────────────────────
//
// TODO: These tests share the process-global autocomplete::global_engine()
// singleton (via autocomplete_status / autocomplete_stop / autocomplete_start).
// They are currently stable because start() always errors on non-macOS, keeping
// the engine in an idle state. Once macOS support lands -- or if concurrent tests
// transition the engine -- races on the global state will cause flakiness.
//
// Fix when that happens: serialize engine-touching tests with a
// process-wide tokio::sync::Mutex guard (or the `serial_test` crate), or
// refactor to accept an injected engine instance instead of going through
// global_engine().
/// Happy path: `autocomplete_status` always succeeds and produces exactly
/// two log lines with the expected key tokens.
#[tokio::test]
async fn status_returns_outcome_with_two_log_lines() {
let outcome = autocomplete_status()
.await
.expect("autocomplete_status must not return Err");
assert_eq!(
outcome.logs.len(),
2,
"expected exactly 2 log lines, got: {:?}",
outcome.logs
);
assert!(
outcome.logs[0].contains("autocomplete status fetched"),
"first log should confirm fetch: {:?}",
outcome.logs[0]
);
assert!(
outcome.logs[1].contains("[autocomplete] status"),
"second log should contain the structured prefix: {:?}",
outcome.logs[1]
);
}
/// The status payload has the expected boolean/string fields and a non-empty phase.
#[tokio::test]
async fn status_payload_has_expected_fields() {
let outcome = autocomplete_status()
.await
.expect("autocomplete_status must not return Err");
let status = &outcome.value;
// Phase must be a non-empty string (default is "idle").
assert!(
!status.phase.is_empty(),
"phase must not be empty, got: {:?}",
status.phase
);
// debounce_ms is always set to a positive value by the engine default (120 ms).
assert!(
status.debounce_ms > 0,
"debounce_ms must be positive, got {}",
status.debounce_ms
);
}
// ── autocomplete_stop ──────────────────────────────────────────────────────
/// Happy path: stopping a not-yet-running engine reports `stopped: true`
/// and produces two log lines.
#[tokio::test]
async fn stop_without_reason_returns_stopped_true_and_two_logs() {
let outcome = autocomplete_stop(None)
.await
.expect("autocomplete_stop must not return Err");
assert!(
outcome.value.stopped,
"stopped must be true even when engine was already idle"
);
assert_eq!(
outcome.logs.len(),
2,
"expected 2 log lines, got: {:?}",
outcome.logs
);
assert!(
outcome.logs[0].contains("autocomplete stopped"),
"first log should confirm stop: {:?}",
outcome.logs[0]
);
}
/// When a `reason` is supplied, the structured log line must include it.
#[tokio::test]
async fn stop_with_reason_includes_reason_in_log() {
let payload = Some(AutocompleteStopParams {
reason: Some("test-shutdown".to_string()),
});
let outcome = autocomplete_stop(payload)
.await
.expect("autocomplete_stop must not return Err");
let structured_log = &outcome.logs[1];
assert!(
structured_log.contains("test-shutdown"),
"structured log must contain the supplied reason; got: {:?}",
structured_log
);
}
/// When no reason is supplied, the structured log line must record "none".
#[tokio::test]
async fn stop_without_reason_logs_none_as_reason() {
let outcome = autocomplete_stop(None)
.await
.expect("autocomplete_stop must not return Err");
let structured_log = &outcome.logs[1];
assert!(
structured_log.contains("reason=none"),
"structured log must record reason=none when no reason is given; got: {:?}",
structured_log
);
}
// ── autocomplete_start (non-macOS) ─────────────────────────────────────────
/// On Linux/Windows `autocomplete_start` must return an `Err` because
/// the engine only supports macOS. This exercises the error path of the
/// ops wrapper without needing OS accessibility permissions.
#[cfg(not(target_os = "macos"))]
#[tokio::test]
async fn start_returns_err_on_non_macos() {
let result = autocomplete_start(AutocompleteStartParams { debounce_ms: None }).await;
assert!(
result.is_err(),
"autocomplete_start must fail on non-macOS; got Ok"
);
let msg = result.unwrap_err();
assert!(
msg.contains("macOS"),
"error message must mention macOS; got: {msg:?}"
);
}
// ── autocomplete_start_cli (non-spawn, non-serve path, non-macOS) ──────────
/// The plain `autocomplete_start_cli` path (neither --spawn nor --serve)
/// propagates the engine's start error on non-macOS platforms.
#[cfg(not(target_os = "macos"))]
#[tokio::test]
async fn start_cli_plain_path_returns_err_on_non_macos() {
let opts = AutocompleteStartCliOptions {
debounce_ms: None,
serve: false,
spawn: false,
};
let result = autocomplete_start_cli(opts).await;
assert!(
result.is_err(),
"start_cli plain path must propagate start failure on non-macOS; got Ok"
);
}
// ── AutocompleteHistoryParams struct ──────────────────────────────────────
/// `AutocompleteHistoryParams` with an explicit limit round-trips through
/// JSON correctly — field name and value are preserved.
#[test]
fn history_params_serialise_round_trip() {
let params = AutocompleteHistoryParams { limit: Some(7) };
let json = serde_json::to_value(&params).expect("serialise ok");
assert_eq!(json["limit"], 7);
let back: AutocompleteHistoryParams = serde_json::from_value(json).expect("deserialise ok");
assert_eq!(back.limit, Some(7));
}
/// `AutocompleteHistoryParams` with no limit serialises to JSON `null` for
/// the `limit` field.
#[test]
fn history_params_none_limit_serialises_to_null() {
let params = AutocompleteHistoryParams { limit: None };
let json = serde_json::to_value(&params).expect("serialise ok");
assert!(json["limit"].is_null());
}
// ── AutocompleteClearHistoryResult struct ─────────────────────────────────
/// `AutocompleteClearHistoryResult` round-trips through JSON and the
/// `cleared` field is preserved.
#[test]
fn clear_history_result_serialise_round_trip() {
let result = AutocompleteClearHistoryResult { cleared: 42 };
let json = serde_json::to_value(&result).expect("serialise ok");
assert_eq!(json["cleared"], 42);
let back: AutocompleteClearHistoryResult =
serde_json::from_value(json).expect("deserialise ok");
assert_eq!(back.cleared, 42);
}
// ── autocomplete_history (integration) ───────────────────────────────────
//
// NOTE: These tests operate against the real on-disk KV store via
// MemoryClient::new_local() (resolves to default_root_openhuman_dir()).
// They are marked #[ignore] to prevent wiping a contributor's autocomplete
// history on every `cargo test` run and to avoid non-deterministic results.
// Run explicitly with: cargo test -- --ignored
/// `autocomplete_history` against a fresh (possibly empty) local KV store
/// must succeed and produce exactly two log lines — one confirmation and
/// one structured log. The result entries count may be 0 or more.
#[tokio::test]
#[ignore = "operates on real on-disk KV store; run with --ignored to opt in"]
async fn history_returns_outcome_with_two_log_lines() {
let payload = AutocompleteHistoryParams { limit: Some(5) };
let outcome = autocomplete_history(payload)
.await
.expect("autocomplete_history must not return Err");
assert_eq!(
outcome.logs.len(),
2,
"expected exactly 2 log lines; got: {:?}",
outcome.logs
);
assert!(
outcome.logs[0].contains("autocomplete history listed"),
"first log must confirm listing; got: {:?}",
outcome.logs[0]
);
assert!(
outcome.logs[1].contains("requested_limit=5"),
"structured log must record requested_limit; got: {:?}",
outcome.logs[1]
);
// entries must be a valid (possibly empty) vec
let _ = &outcome.value.entries;
}
/// When `limit` is `None`, the default of 20 is applied and appears in the log.
#[tokio::test]
#[ignore = "operates on real on-disk KV store; run with --ignored to opt in"]
async fn history_default_limit_appears_in_log() {
let payload = AutocompleteHistoryParams { limit: None };
let outcome = autocomplete_history(payload)
.await
.expect("autocomplete_history must not return Err");
assert!(
outcome.logs[1].contains("requested_limit=20"),
"default limit of 20 must appear in log; got: {:?}",
outcome.logs[1]
);
}
// ── autocomplete_clear_history (integration) ──────────────────────────────
/// `autocomplete_clear_history` on an already-empty or populated store must
/// succeed, return a non-negative cleared count, and emit exactly two log lines.
#[tokio::test]
#[ignore = "operates on real on-disk KV store; run with --ignored to opt in"]
async fn clear_history_returns_outcome_with_two_log_lines() {
let outcome = autocomplete_clear_history()
.await
.expect("autocomplete_clear_history must not return Err");
assert_eq!(
outcome.logs.len(),
2,
"expected exactly 2 log lines; got: {:?}",
outcome.logs
);
assert!(
outcome.logs[0].contains("autocomplete history cleared"),
"first log must confirm clear; got: {:?}",
outcome.logs[0]
);
assert!(
outcome.logs[1].contains("cleared_entries="),
"structured log must contain cleared_entries; got: {:?}",
outcome.logs[1]
);
// cleared is a usize — always non-negative by type
let _ = outcome.value.cleared;
}
}
+141
View File
@@ -2278,6 +2278,147 @@ async fn notification_settings_roundtrip_and_disabled_ingest_skip() {
rpc_join.abort();
}
#[tokio::test]
async fn credentials_crud_roundtrip() {
// Tests the provider-credential lifecycle over the JSON-RPC transport:
// store → list → list-filtered → remove → verify-gone
//
// Provider credentials are stored locally (auth-profiles.json) and require
// no upstream network calls, so no mock session/JWT is needed.
let _env_lock = json_rpc_e2e_env_lock();
let tmp = tempdir().expect("tempdir");
let home = tmp.path();
let openhuman_home = home.join(".openhuman");
let _home_guard = EnvVarGuard::set_to_path("HOME", home);
let _workspace_guard = EnvVarGuard::unset("OPENHUMAN_WORKSPACE");
let _backend_url_guard = EnvVarGuard::unset("BACKEND_URL");
let _vite_backend_guard = EnvVarGuard::unset("VITE_BACKEND_URL");
// A mock upstream is required so config validation passes and api_url is
// well-formed, even though provider-credential calls don't hit the network.
let (mock_addr, mock_join) = serve_on_ephemeral(mock_upstream_router()).await;
let mock_origin = format!("http://{}", mock_addr);
write_min_config(&openhuman_home, &mock_origin);
let (rpc_addr, rpc_join) = serve_on_ephemeral(build_core_http_router(false)).await;
let rpc_base = format!("http://{}", rpc_addr);
tokio::time::sleep(Duration::from_millis(100)).await;
// ── 1. store a provider credential ──────────────────────────────────────
let store = post_json_rpc(
&rpc_base,
5001,
"openhuman.auth_store_provider_credentials",
json!({
"provider": "openai",
"profile": "default",
"token": "sk-e2e-test-key",
"setActive": true
}),
)
.await;
// assert_no_jsonrpc_error returns the JSON-RPC `result` field which is the
// RpcOutcome envelope: {"logs": [...], "result": { <AuthProfileSummary> }}.
let store_outer = assert_no_jsonrpc_error(&store, "auth_store_provider_credentials");
let store_result = store_outer.get("result").unwrap_or(store_outer);
assert_eq!(
store_result.get("provider").and_then(Value::as_str),
Some("openai"),
"stored profile should have provider=openai: {store_result}"
);
assert_eq!(
store_result.get("profileName").and_then(Value::as_str),
Some("default"),
"stored profile should have profileName=default: {store_result}"
);
assert_eq!(
store_result.get("hasToken").and_then(Value::as_bool),
Some(true),
"stored profile should report hasToken=true: {store_result}"
);
// ── 2. list all provider credentials — should find openai ───────────────
let list_all = post_json_rpc(
&rpc_base,
5002,
"openhuman.auth_list_provider_credentials",
json!({}),
)
.await;
let list_outer = assert_no_jsonrpc_error(&list_all, "auth_list_provider_credentials (all)");
let list_result = list_outer.get("result").unwrap_or(list_outer);
let profiles = list_result
.as_array()
.unwrap_or_else(|| panic!("expected array from list: {list_result}"));
assert_eq!(profiles.len(), 1, "expected exactly one stored credential");
assert_eq!(
profiles[0].get("provider").and_then(Value::as_str),
Some("openai")
);
// ── 3. list filtered by provider name ───────────────────────────────────
let list_filtered = post_json_rpc(
&rpc_base,
5003,
"openhuman.auth_list_provider_credentials",
json!({ "provider": "openai" }),
)
.await;
let filtered_outer =
assert_no_jsonrpc_error(&list_filtered, "auth_list_provider_credentials (filtered)");
let filtered_result = filtered_outer.get("result").unwrap_or(filtered_outer);
let filtered_profiles = filtered_result
.as_array()
.unwrap_or_else(|| panic!("expected array from filtered list: {filtered_result}"));
assert_eq!(
filtered_profiles.len(),
1,
"filter by openai should return exactly one entry"
);
// ── 4. remove the stored credential ─────────────────────────────────────
let remove = post_json_rpc(
&rpc_base,
5004,
"openhuman.auth_remove_provider_credentials",
json!({
"provider": "openai",
"profile": "default"
}),
)
.await;
let remove_outer = assert_no_jsonrpc_error(&remove, "auth_remove_provider_credentials");
let remove_result = remove_outer.get("result").unwrap_or(remove_outer);
assert_eq!(
remove_result.get("removed").and_then(Value::as_bool),
Some(true),
"remove should report removed=true: {remove_result}"
);
// ── 5. verify the credential is gone ────────────────────────────────────
let list_after = post_json_rpc(
&rpc_base,
5005,
"openhuman.auth_list_provider_credentials",
json!({}),
)
.await;
let after_outer =
assert_no_jsonrpc_error(&list_after, "auth_list_provider_credentials (after remove)");
let after_result = after_outer.get("result").unwrap_or(after_outer);
let after_profiles = after_result
.as_array()
.unwrap_or_else(|| panic!("expected array after remove: {after_result}"));
assert!(
after_profiles.is_empty(),
"credentials list should be empty after remove, got {after_profiles:?}"
);
mock_join.abort();
rpc_join.abort();
}
/// End-to-end coverage for `openhuman.skills_uninstall`.
///
/// Validates that the RPC method is registered, wire-decodes