mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
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:
co-authored by
Jwalin Shah
Steven Enamakel
parent
9b1cfccb53
commit
95504d4b09
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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(¶ms).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(¶ms).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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user