import { TestBed } from '@angular/core/testing';
import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
import { McpClientService } from './mcp-client.service';
import { QueuedRequest, MultipleChoiceQuestion } from '@ask-me-mcp/askme-shared';
describe('McpClientService', () => {
let service: McpClientService;
let httpMock: HttpTestingController;
let mockEventSource: any;
// Mock EventSource
beforeEach(() => {
mockEventSource = {
onmessage: null,
onerror: null,
onopen: null,
close: jest.fn(),
readyState: 0 // CONNECTING
};
// Define EventSource constants
(global as any).EventSource = jest.fn(() => mockEventSource);
(global as any).EventSource.CONNECTING = 0;
(global as any).EventSource.OPEN = 1;
(global as any).EventSource.CLOSED = 2;
});
beforeEach(() => {
TestBed.configureTestingModule({
imports: [HttpClientTestingModule],
providers: [McpClientService]
});
service = TestBed.inject(McpClientService);
httpMock = TestBed.inject(HttpTestingController);
});
afterEach(() => {
httpMock.verify();
service.disconnect();
});
describe('connect', () => {
it('should establish SSE connection', () => {
service.connect('demo');
expect(EventSource).toHaveBeenCalledWith(
'http://localhost:3000/mcp/browser-events'
);
expect(service.connected()).toBe(false); // Not connected until onopen
// Simulate connection open
mockEventSource.readyState = 1; // OPEN
mockEventSource.onopen({});
expect(service.connected()).toBe(true);
});
it('should handle SSE messages', (done) => {
service.connect('demo');
const request: QueuedRequest = {
id: 'req-1',
question: 'Test question',
timestamp: new Date(),
status: 'active',
type: 'single-question'
};
const message = {
type: 'new_request',
data: request
};
// Subscribe to requests observable
service.requests.subscribe(requests => {
if (requests.length > 0) {
expect(requests).toHaveLength(1);
expect(requests[0]).toEqual(expect.objectContaining({
id: request.id,
question: request.question,
status: 'active'
}));
done();
}
});
// Simulate SSE message
mockEventSource.onmessage({
data: JSON.stringify(message)
});
});
it('should handle reconnection on error', () => {
jest.useFakeTimers();
service.connect('demo');
expect(EventSource).toHaveBeenCalledTimes(1);
// Simulate error
mockEventSource.onerror({});
// Should attempt reconnection after delay
jest.advanceTimersByTime(5000);
expect(EventSource).toHaveBeenCalledTimes(2);
jest.useRealTimers();
});
});
describe('disconnect', () => {
it('should close SSE connection', () => {
service.connect('demo');
service.disconnect();
expect(mockEventSource.close).toHaveBeenCalled();
expect(service.connected()).toBe(false);
});
it('should clear requests', (done) => {
service.connect('demo');
// Add a request first
const request: QueuedRequest = {
id: 'req-1',
question: 'Test question',
timestamp: new Date(),
status: 'active',
type: 'single-question'
};
mockEventSource.onmessage({
data: JSON.stringify({
type: 'new_request',
data: request
})
});
// Wait for request to be processed, then disconnect
setTimeout(() => {
service.disconnect();
service.requests.subscribe(requests => {
expect(requests).toHaveLength(0);
done();
});
}, 10);
});
});
describe('sendResponse', () => {
it('should send single question response via HTTP POST', () => {
const requestId = 'req-1';
const response = 'Test response';
service.sendResponse(requestId, response).subscribe();
const req = httpMock.expectOne('http://localhost:3000/mcp/response');
expect(req.request.method).toBe('POST');
expect(req.request.body).toEqual({
requestId,
response,
type: 'single-question'
});
req.flush({ success: true });
});
});
describe('sendMultipleChoiceResponse', () => {
it('should send multiple choice response via HTTP POST', () => {
const requestId = 'req-1';
const questions: MultipleChoiceQuestion[] = [
{
id: 'q1',
text: 'Test question',
options: [
{ id: 'opt1', text: 'Option 1', selected: true, comment: 'Test comment' }
]
}
];
service.sendMultipleChoiceResponse(requestId, questions).subscribe();
const req = httpMock.expectOne('http://localhost:3000/mcp/response');
expect(req.request.method).toBe('POST');
expect(req.request.body).toEqual({
requestId,
type: 'multiple-choice',
questions
});
req.flush({ success: true });
});
});
describe('error handling', () => {
it('should handle malformed SSE messages', () => {
service.connect('demo');
// Should not throw
mockEventSource.onmessage({
data: 'invalid json'
});
// Service should continue working
expect(service.connected()).toBe(false); // Not connected yet in this test
});
it('should handle HTTP errors', () => {
service.sendResponse('req-1', 'Test').subscribe({
error: (err) => {
expect(err.status).toBe(500);
}
});
const req = httpMock.expectOne('http://localhost:3000/mcp/response');
req.flush('Server error', { status: 500, statusText: 'Internal Server Error' });
});
});
describe('timeout handling', () => {
it('should handle request timeout messages', () => {
service.connect('demo');
// First add a request
const newRequestMessage = {
type: 'new_request',
data: {
id: 'req-1',
question: 'Test question',
timestamp: new Date().toISOString(),
type: 'single-question'
}
};
mockEventSource.onmessage({
data: JSON.stringify(newRequestMessage)
});
// Verify request was added
service.requests.subscribe(requests => {
expect(requests).toHaveLength(1);
});
// Now send timeout message
const timeoutMessage = {
type: 'request_timeout',
data: {
requestId: 'req-1',
message: 'Request timed out after 5 minutes - no response received'
}
};
mockEventSource.onmessage({
data: JSON.stringify(timeoutMessage)
});
// Verify request was cleared
service.requests.subscribe(requests => {
expect(requests).toHaveLength(0);
});
});
it('should handle request cancellation messages', () => {
service.connect('demo');
// First add a request
const newRequestMessage = {
type: 'new_request',
data: {
id: 'req-1',
question: 'Test question',
timestamp: new Date().toISOString(),
type: 'single-question'
}
};
mockEventSource.onmessage({
data: JSON.stringify(newRequestMessage)
});
// Verify request was added
service.requests.subscribe(requests => {
expect(requests).toHaveLength(1);
});
// Now send cancellation message
const cancellationMessage = {
type: 'request_cancelled',
data: {
requestId: 'req-1',
message: 'Request was cancelled by the client'
}
};
mockEventSource.onmessage({
data: JSON.stringify(cancellationMessage)
});
// Verify request was cleared
service.requests.subscribe(requests => {
expect(requests).toHaveLength(0);
});
});
});
});