mirror of
https://github.com/bitechdev/ResolveSpec.git
synced 2026-03-12 16:36:51 +00:00
- Introduced ResolveSpecClient for REST API interactions. - Added WebSocketClient for real-time communication. - Created types and utility functions for both clients. - Removed deprecated types and example files. - Configured TypeScript and Vite for building the library.
337 lines
10 KiB
TypeScript
337 lines
10 KiB
TypeScript
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
import { WebSocketClient, getWebSocketClient } from '../websocketspec/client';
|
|
import type { WebSocketClientConfig } from '../websocketspec/types';
|
|
|
|
// Mock uuid
|
|
vi.mock('uuid', () => ({
|
|
v4: vi.fn(() => 'mock-uuid-1234'),
|
|
}));
|
|
|
|
// Mock WebSocket
|
|
class MockWebSocket {
|
|
static OPEN = 1;
|
|
static CLOSED = 3;
|
|
|
|
url: string;
|
|
readyState = MockWebSocket.OPEN;
|
|
onopen: ((ev: any) => void) | null = null;
|
|
onclose: ((ev: any) => void) | null = null;
|
|
onmessage: ((ev: any) => void) | null = null;
|
|
onerror: ((ev: any) => void) | null = null;
|
|
|
|
private sentMessages: string[] = [];
|
|
|
|
constructor(url: string) {
|
|
this.url = url;
|
|
// Simulate async open
|
|
setTimeout(() => {
|
|
this.onopen?.({});
|
|
}, 0);
|
|
}
|
|
|
|
send(data: string) {
|
|
this.sentMessages.push(data);
|
|
}
|
|
|
|
close() {
|
|
this.readyState = MockWebSocket.CLOSED;
|
|
this.onclose?.({ code: 1000, reason: 'Normal closure' } as any);
|
|
}
|
|
|
|
getSentMessages(): any[] {
|
|
return this.sentMessages.map((m) => JSON.parse(m));
|
|
}
|
|
|
|
simulateMessage(data: any) {
|
|
this.onmessage?.({ data: JSON.stringify(data) });
|
|
}
|
|
}
|
|
|
|
let mockWsInstance: MockWebSocket | null = null;
|
|
|
|
beforeEach(() => {
|
|
mockWsInstance = null;
|
|
(globalThis as any).WebSocket = class extends MockWebSocket {
|
|
constructor(url: string) {
|
|
super(url);
|
|
mockWsInstance = this;
|
|
}
|
|
};
|
|
(globalThis as any).WebSocket.OPEN = MockWebSocket.OPEN;
|
|
(globalThis as any).WebSocket.CLOSED = MockWebSocket.CLOSED;
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
describe('WebSocketClient', () => {
|
|
const wsConfig: WebSocketClientConfig = {
|
|
url: 'ws://localhost:8080',
|
|
reconnect: false,
|
|
heartbeatInterval: 60000,
|
|
};
|
|
|
|
it('should connect and set state to connected', async () => {
|
|
const client = new WebSocketClient(wsConfig);
|
|
await client.connect();
|
|
expect(client.getState()).toBe('connected');
|
|
expect(client.isConnected()).toBe(true);
|
|
client.disconnect();
|
|
});
|
|
|
|
it('should disconnect and set state to disconnected', async () => {
|
|
const client = new WebSocketClient(wsConfig);
|
|
await client.connect();
|
|
client.disconnect();
|
|
expect(client.getState()).toBe('disconnected');
|
|
expect(client.isConnected()).toBe(false);
|
|
});
|
|
|
|
it('should send read request', async () => {
|
|
const client = new WebSocketClient(wsConfig);
|
|
await client.connect();
|
|
|
|
const readPromise = client.read('users', {
|
|
schema: 'public',
|
|
filters: [{ column: 'active', operator: 'eq', value: true }],
|
|
limit: 10,
|
|
});
|
|
|
|
// Simulate server response
|
|
const sent = mockWsInstance!.getSentMessages();
|
|
expect(sent.length).toBe(1);
|
|
expect(sent[0].operation).toBe('read');
|
|
expect(sent[0].entity).toBe('users');
|
|
expect(sent[0].options.filters[0].column).toBe('active');
|
|
|
|
mockWsInstance!.simulateMessage({
|
|
id: sent[0].id,
|
|
type: 'response',
|
|
success: true,
|
|
data: [{ id: 1 }],
|
|
timestamp: new Date().toISOString(),
|
|
});
|
|
|
|
const result = await readPromise;
|
|
expect(result).toEqual([{ id: 1 }]);
|
|
|
|
client.disconnect();
|
|
});
|
|
|
|
it('should send create request', async () => {
|
|
const client = new WebSocketClient(wsConfig);
|
|
await client.connect();
|
|
|
|
const createPromise = client.create('users', { name: 'Test' }, { schema: 'public' });
|
|
|
|
const sent = mockWsInstance!.getSentMessages();
|
|
expect(sent[0].operation).toBe('create');
|
|
expect(sent[0].data.name).toBe('Test');
|
|
|
|
mockWsInstance!.simulateMessage({
|
|
id: sent[0].id,
|
|
type: 'response',
|
|
success: true,
|
|
data: { id: 1, name: 'Test' },
|
|
timestamp: new Date().toISOString(),
|
|
});
|
|
|
|
const result = await createPromise;
|
|
expect(result.name).toBe('Test');
|
|
|
|
client.disconnect();
|
|
});
|
|
|
|
it('should send update request with record_id', async () => {
|
|
const client = new WebSocketClient(wsConfig);
|
|
await client.connect();
|
|
|
|
const updatePromise = client.update('users', '1', { name: 'Updated' });
|
|
|
|
const sent = mockWsInstance!.getSentMessages();
|
|
expect(sent[0].operation).toBe('update');
|
|
expect(sent[0].record_id).toBe('1');
|
|
|
|
mockWsInstance!.simulateMessage({
|
|
id: sent[0].id,
|
|
type: 'response',
|
|
success: true,
|
|
data: { id: 1, name: 'Updated' },
|
|
timestamp: new Date().toISOString(),
|
|
});
|
|
|
|
await updatePromise;
|
|
client.disconnect();
|
|
});
|
|
|
|
it('should send delete request', async () => {
|
|
const client = new WebSocketClient(wsConfig);
|
|
await client.connect();
|
|
|
|
const deletePromise = client.delete('users', '1');
|
|
|
|
const sent = mockWsInstance!.getSentMessages();
|
|
expect(sent[0].operation).toBe('delete');
|
|
expect(sent[0].record_id).toBe('1');
|
|
|
|
mockWsInstance!.simulateMessage({
|
|
id: sent[0].id,
|
|
type: 'response',
|
|
success: true,
|
|
timestamp: new Date().toISOString(),
|
|
});
|
|
|
|
await deletePromise;
|
|
client.disconnect();
|
|
});
|
|
|
|
it('should reject on failed request', async () => {
|
|
const client = new WebSocketClient(wsConfig);
|
|
await client.connect();
|
|
|
|
const readPromise = client.read('users');
|
|
|
|
const sent = mockWsInstance!.getSentMessages();
|
|
mockWsInstance!.simulateMessage({
|
|
id: sent[0].id,
|
|
type: 'response',
|
|
success: false,
|
|
error: { code: 'not_found', message: 'Not found' },
|
|
timestamp: new Date().toISOString(),
|
|
});
|
|
|
|
await expect(readPromise).rejects.toThrow('Not found');
|
|
client.disconnect();
|
|
});
|
|
|
|
it('should handle subscriptions', async () => {
|
|
const client = new WebSocketClient(wsConfig);
|
|
await client.connect();
|
|
|
|
const callback = vi.fn();
|
|
const subPromise = client.subscribe('users', callback, {
|
|
schema: 'public',
|
|
});
|
|
|
|
const sent = mockWsInstance!.getSentMessages();
|
|
expect(sent[0].type).toBe('subscription');
|
|
expect(sent[0].operation).toBe('subscribe');
|
|
|
|
mockWsInstance!.simulateMessage({
|
|
id: sent[0].id,
|
|
type: 'response',
|
|
success: true,
|
|
data: { subscription_id: 'sub-1' },
|
|
timestamp: new Date().toISOString(),
|
|
});
|
|
|
|
const subId = await subPromise;
|
|
expect(subId).toBe('sub-1');
|
|
expect(client.getSubscriptions()).toHaveLength(1);
|
|
|
|
// Simulate notification
|
|
mockWsInstance!.simulateMessage({
|
|
type: 'notification',
|
|
operation: 'create',
|
|
subscription_id: 'sub-1',
|
|
entity: 'users',
|
|
data: { id: 2, name: 'New' },
|
|
timestamp: new Date().toISOString(),
|
|
});
|
|
|
|
expect(callback).toHaveBeenCalledTimes(1);
|
|
expect(callback.mock.calls[0][0].data.id).toBe(2);
|
|
|
|
client.disconnect();
|
|
});
|
|
|
|
it('should handle unsubscribe', async () => {
|
|
const client = new WebSocketClient(wsConfig);
|
|
await client.connect();
|
|
|
|
// Subscribe first
|
|
const subPromise = client.subscribe('users', vi.fn());
|
|
let sent = mockWsInstance!.getSentMessages();
|
|
mockWsInstance!.simulateMessage({
|
|
id: sent[0].id,
|
|
type: 'response',
|
|
success: true,
|
|
data: { subscription_id: 'sub-1' },
|
|
timestamp: new Date().toISOString(),
|
|
});
|
|
await subPromise;
|
|
|
|
// Unsubscribe
|
|
const unsubPromise = client.unsubscribe('sub-1');
|
|
sent = mockWsInstance!.getSentMessages();
|
|
mockWsInstance!.simulateMessage({
|
|
id: sent[sent.length - 1].id,
|
|
type: 'response',
|
|
success: true,
|
|
timestamp: new Date().toISOString(),
|
|
});
|
|
|
|
await unsubPromise;
|
|
expect(client.getSubscriptions()).toHaveLength(0);
|
|
|
|
client.disconnect();
|
|
});
|
|
|
|
it('should emit events', async () => {
|
|
const client = new WebSocketClient(wsConfig);
|
|
const connectCb = vi.fn();
|
|
const stateChangeCb = vi.fn();
|
|
|
|
client.on('connect', connectCb);
|
|
client.on('stateChange', stateChangeCb);
|
|
|
|
await client.connect();
|
|
|
|
expect(connectCb).toHaveBeenCalledTimes(1);
|
|
expect(stateChangeCb).toHaveBeenCalled();
|
|
|
|
client.off('connect');
|
|
client.disconnect();
|
|
});
|
|
|
|
it('should reject when sending without connection', async () => {
|
|
const client = new WebSocketClient(wsConfig);
|
|
await expect(client.read('users')).rejects.toThrow('WebSocket is not connected');
|
|
});
|
|
|
|
it('should handle pong messages without error', async () => {
|
|
const client = new WebSocketClient(wsConfig);
|
|
await client.connect();
|
|
|
|
// Should not throw
|
|
mockWsInstance!.simulateMessage({ type: 'pong' });
|
|
|
|
client.disconnect();
|
|
});
|
|
|
|
it('should handle malformed messages gracefully', async () => {
|
|
const client = new WebSocketClient({ ...wsConfig, debug: false });
|
|
await client.connect();
|
|
|
|
// Simulate non-JSON message
|
|
mockWsInstance!.onmessage?.({ data: 'not-json' } as any);
|
|
|
|
client.disconnect();
|
|
});
|
|
});
|
|
|
|
describe('getWebSocketClient singleton', () => {
|
|
it('returns same instance for same url', () => {
|
|
const a = getWebSocketClient({ url: 'ws://ws-singleton:8080' });
|
|
const b = getWebSocketClient({ url: 'ws://ws-singleton:8080' });
|
|
expect(a).toBe(b);
|
|
});
|
|
|
|
it('returns different instances for different urls', () => {
|
|
const a = getWebSocketClient({ url: 'ws://ws-singleton-a:8080' });
|
|
const b = getWebSocketClient({ url: 'ws://ws-singleton-b:8080' });
|
|
expect(a).not.toBe(b);
|
|
});
|
|
});
|