441 lines
15 KiB
TypeScript
441 lines
15 KiB
TypeScript
import { test, expect, describe, beforeEach } from "bun:test";
|
|
import { SFUSignalingService } from "../../src/services/SFUSignalingService.ts";
|
|
import { SFUClientManager } from "../../src/services/SFUClientManager.ts";
|
|
import type { ISFUSignalingMessage } from "../../src/interfaces/ISFUTypes.ts";
|
|
|
|
// Mock MediaServerManager
|
|
class MockMediaServerManager {
|
|
private mockRtpCapabilities = {
|
|
codecs: [
|
|
{ mimeType: 'video/VP8', clockRate: 90000 },
|
|
{ mimeType: 'audio/opus', clockRate: 48000 }
|
|
]
|
|
};
|
|
|
|
private mockTransport = {
|
|
id: 'transport-123',
|
|
iceParameters: { usernameFragment: 'test', password: 'test' },
|
|
iceCandidates: [],
|
|
dtlsParameters: { fingerprints: [{ algorithm: 'sha-256', value: 'test' }] },
|
|
connect: () => Promise.resolve(),
|
|
produce: () => Promise.resolve({ id: 'producer-123', kind: 'video' }),
|
|
consume: () => Promise.resolve({ id: 'consumer-123', kind: 'video', rtpParameters: {} }),
|
|
close: () => {}
|
|
};
|
|
|
|
private mockRouter = {
|
|
canConsume: () => true
|
|
};
|
|
|
|
getRtpCapabilities() {
|
|
return this.mockRtpCapabilities;
|
|
}
|
|
|
|
async createWebRtcTransport() {
|
|
return this.mockTransport;
|
|
}
|
|
|
|
getRouter() {
|
|
return this.mockRouter;
|
|
}
|
|
|
|
getWorker() {
|
|
return { pid: 12345 };
|
|
}
|
|
}
|
|
|
|
// Mock WebSocket
|
|
class MockWebSocket {
|
|
sentMessages: string[] = [];
|
|
|
|
send(data: string) {
|
|
this.sentMessages.push(data);
|
|
}
|
|
|
|
getLastMessage() {
|
|
return this.sentMessages.length > 0 ?
|
|
JSON.parse(this.sentMessages[this.sentMessages.length - 1]) : null;
|
|
}
|
|
|
|
getAllMessages() {
|
|
return this.sentMessages.map(msg => JSON.parse(msg));
|
|
}
|
|
}
|
|
|
|
describe("SFUSignalingService", () => {
|
|
let signalingService: SFUSignalingService;
|
|
let clientManager: SFUClientManager;
|
|
let mediaServer: MockMediaServerManager;
|
|
|
|
beforeEach(() => {
|
|
clientManager = new SFUClientManager();
|
|
mediaServer = new MockMediaServerManager();
|
|
signalingService = new SFUSignalingService(mediaServer as any, clientManager);
|
|
});
|
|
|
|
test("should handle client connections", () => {
|
|
const mockWs = new MockWebSocket();
|
|
|
|
const clientId = signalingService.handleConnection(mockWs, "publisher");
|
|
|
|
expect(typeof clientId).toBe("string");
|
|
expect(clientId.length).toBeGreaterThan(0);
|
|
|
|
const client = clientManager.getClient(clientId);
|
|
expect(client).toBeTruthy();
|
|
expect(client?.role).toBe("publisher");
|
|
expect(client?.ws).toBe(mockWs);
|
|
|
|
const lastMessage = mockWs.getLastMessage();
|
|
expect(lastMessage.type).toBe("join");
|
|
expect(lastMessage.data.clientId).toBe(clientId);
|
|
expect(lastMessage.data.role).toBe("publisher");
|
|
});
|
|
|
|
test("should handle client disconnection", () => {
|
|
const mockWs = new MockWebSocket();
|
|
|
|
const clientId = signalingService.handleConnection(mockWs, "subscriber");
|
|
expect(clientManager.getClient(clientId)).toBeTruthy();
|
|
|
|
signalingService.handleDisconnection(clientId);
|
|
expect(clientManager.getClient(clientId)).toBeUndefined();
|
|
});
|
|
|
|
test("should handle getRouterRtpCapabilities message", async () => {
|
|
const mockWs = new MockWebSocket();
|
|
const clientId = signalingService.handleConnection(mockWs, "publisher");
|
|
|
|
const message: ISFUSignalingMessage = {
|
|
type: "getRouterRtpCapabilities"
|
|
};
|
|
|
|
await signalingService.handleMessage(clientId, message);
|
|
|
|
const lastMessage = mockWs.getLastMessage();
|
|
expect(lastMessage.type).toBe("routerRtpCapabilities");
|
|
expect(lastMessage.data.rtpCapabilities).toBeTruthy();
|
|
expect(lastMessage.data.rtpCapabilities.codecs).toHaveLength(2);
|
|
});
|
|
|
|
test("should handle createWebRtcTransport message", async () => {
|
|
const mockWs = new MockWebSocket();
|
|
const clientId = signalingService.handleConnection(mockWs, "publisher");
|
|
|
|
const message: ISFUSignalingMessage = {
|
|
type: "createWebRtcTransport"
|
|
};
|
|
|
|
await signalingService.handleMessage(clientId, message);
|
|
|
|
const lastMessage = mockWs.getLastMessage();
|
|
expect(lastMessage.type).toBe("webRtcTransportCreated");
|
|
expect(lastMessage.data.id).toBe("transport-123");
|
|
expect(lastMessage.data.iceParameters).toBeTruthy();
|
|
expect(lastMessage.data.dtlsParameters).toBeTruthy();
|
|
|
|
// Verify transport was set on client
|
|
const client = clientManager.getClient(clientId);
|
|
expect(client?.transport).toBeTruthy();
|
|
});
|
|
|
|
test("should handle connectWebRtcTransport message", async () => {
|
|
const mockWs = new MockWebSocket();
|
|
const clientId = signalingService.handleConnection(mockWs, "publisher");
|
|
|
|
// First create transport
|
|
await signalingService.handleMessage(clientId, { type: "createWebRtcTransport" });
|
|
|
|
// Then connect it
|
|
const connectMessage: ISFUSignalingMessage = {
|
|
type: "connectWebRtcTransport",
|
|
data: {
|
|
dtlsParameters: { fingerprints: [{ algorithm: 'sha-256', value: 'test' }] }
|
|
}
|
|
};
|
|
|
|
await signalingService.handleMessage(clientId, connectMessage);
|
|
|
|
const lastMessage = mockWs.getLastMessage();
|
|
expect(lastMessage.type).toBe("webRtcTransportConnected");
|
|
});
|
|
|
|
test("should handle produce message for publisher", async () => {
|
|
const mockWs = new MockWebSocket();
|
|
const clientId = signalingService.handleConnection(mockWs, "publisher");
|
|
|
|
// Create transport first
|
|
await signalingService.handleMessage(clientId, { type: "createWebRtcTransport" });
|
|
|
|
// Mock producer creation
|
|
const mockProducer = { id: 'producer-123', kind: 'video' };
|
|
const client = clientManager.getClient(clientId);
|
|
if (client?.transport) {
|
|
(client.transport as any).produce = () => Promise.resolve(mockProducer);
|
|
}
|
|
|
|
const produceMessage: ISFUSignalingMessage = {
|
|
type: "produce",
|
|
data: {
|
|
kind: "video",
|
|
rtpParameters: { codecs: [], encodings: [] }
|
|
}
|
|
};
|
|
|
|
await signalingService.handleMessage(clientId, produceMessage);
|
|
|
|
const messages = mockWs.getAllMessages();
|
|
const producedMessage = messages.find(msg => msg.type === 'produced');
|
|
expect(producedMessage).toBeTruthy();
|
|
expect(producedMessage.data.producerId).toBe('producer-123');
|
|
});
|
|
|
|
test("should reject produce message for subscriber", async () => {
|
|
const mockWs = new MockWebSocket();
|
|
const clientId = signalingService.handleConnection(mockWs, "subscriber");
|
|
|
|
await signalingService.handleMessage(clientId, { type: "createWebRtcTransport" });
|
|
|
|
const produceMessage: ISFUSignalingMessage = {
|
|
type: "produce",
|
|
data: {
|
|
kind: "video",
|
|
rtpParameters: { codecs: [], encodings: [] }
|
|
}
|
|
};
|
|
|
|
await signalingService.handleMessage(clientId, produceMessage);
|
|
|
|
const lastMessage = mockWs.getLastMessage();
|
|
expect(lastMessage.type).toBe("error");
|
|
expect(lastMessage.data.message).toContain("Only publishers can produce");
|
|
});
|
|
|
|
test("should handle consume message for subscriber", async () => {
|
|
// First create a publisher with a producer
|
|
const publisherWs = new MockWebSocket();
|
|
const publisherId = signalingService.handleConnection(publisherWs, "publisher");
|
|
await signalingService.handleMessage(publisherId, { type: "createWebRtcTransport" });
|
|
|
|
// Mock producer
|
|
const mockProducer = { id: 'producer-123', kind: 'video' };
|
|
clientManager.setClientProducer(publisherId, mockProducer as any);
|
|
|
|
// Now create subscriber
|
|
const subscriberWs = new MockWebSocket();
|
|
const subscriberId = signalingService.handleConnection(subscriberWs, "subscriber");
|
|
await signalingService.handleMessage(subscriberId, { type: "createWebRtcTransport" });
|
|
|
|
// Mock consumer creation
|
|
const mockConsumer = {
|
|
id: 'consumer-123',
|
|
kind: 'video',
|
|
rtpParameters: { codecs: [], encodings: [] }
|
|
};
|
|
const subscriberClient = clientManager.getClient(subscriberId);
|
|
if (subscriberClient?.transport) {
|
|
(subscriberClient.transport as any).consume = () => Promise.resolve(mockConsumer);
|
|
}
|
|
|
|
const consumeMessage: ISFUSignalingMessage = {
|
|
type: "consume",
|
|
data: {
|
|
producerId: "producer-123",
|
|
rtpCapabilities: { codecs: [] }
|
|
}
|
|
};
|
|
|
|
await signalingService.handleMessage(subscriberId, consumeMessage);
|
|
|
|
const lastMessage = subscriberWs.getLastMessage();
|
|
expect(lastMessage.type).toBe("consumed");
|
|
expect(lastMessage.data.consumerId).toBe('consumer-123');
|
|
expect(lastMessage.data.producerId).toBe('producer-123');
|
|
});
|
|
|
|
test("should reject consume message for publisher", async () => {
|
|
const mockWs = new MockWebSocket();
|
|
const clientId = signalingService.handleConnection(mockWs, "publisher");
|
|
|
|
await signalingService.handleMessage(clientId, { type: "createWebRtcTransport" });
|
|
|
|
const consumeMessage: ISFUSignalingMessage = {
|
|
type: "consume",
|
|
data: {
|
|
producerId: "producer-123",
|
|
rtpCapabilities: { codecs: [] }
|
|
}
|
|
};
|
|
|
|
await signalingService.handleMessage(clientId, consumeMessage);
|
|
|
|
const lastMessage = mockWs.getLastMessage();
|
|
expect(lastMessage.type).toBe("error");
|
|
expect(lastMessage.data.message).toContain("Only subscribers can consume");
|
|
});
|
|
|
|
test("should handle resume message", async () => {
|
|
const mockWs = new MockWebSocket();
|
|
const clientId = signalingService.handleConnection(mockWs, "subscriber");
|
|
|
|
// Mock consumer
|
|
const mockConsumer = {
|
|
id: 'consumer-123',
|
|
resume: () => Promise.resolve()
|
|
};
|
|
clientManager.addClientConsumer(clientId, mockConsumer as any);
|
|
|
|
const resumeMessage: ISFUSignalingMessage = {
|
|
type: "resume",
|
|
data: { consumerId: "consumer-123" }
|
|
};
|
|
|
|
await signalingService.handleMessage(clientId, resumeMessage);
|
|
|
|
const lastMessage = mockWs.getLastMessage();
|
|
expect(lastMessage.type).toBe("resumed");
|
|
expect(lastMessage.data.consumerId).toBe("consumer-123");
|
|
});
|
|
|
|
test("should handle pause message", async () => {
|
|
const mockWs = new MockWebSocket();
|
|
const clientId = signalingService.handleConnection(mockWs, "subscriber");
|
|
|
|
// Mock consumer
|
|
const mockConsumer = {
|
|
id: 'consumer-123',
|
|
pause: () => Promise.resolve()
|
|
};
|
|
clientManager.addClientConsumer(clientId, mockConsumer as any);
|
|
|
|
const pauseMessage: ISFUSignalingMessage = {
|
|
type: "pause",
|
|
data: { consumerId: "consumer-123" }
|
|
};
|
|
|
|
await signalingService.handleMessage(clientId, pauseMessage);
|
|
|
|
const lastMessage = mockWs.getLastMessage();
|
|
expect(lastMessage.type).toBe("paused");
|
|
expect(lastMessage.data.consumerId).toBe("consumer-123");
|
|
});
|
|
|
|
test("should handle getProducers message", async () => {
|
|
// Create publisher with producer
|
|
const publisherWs = new MockWebSocket();
|
|
const publisherId = signalingService.handleConnection(publisherWs, "publisher");
|
|
|
|
const mockProducer = { id: 'producer-123', kind: 'video' };
|
|
clientManager.setClientProducer(publisherId, mockProducer as any);
|
|
|
|
// Create subscriber requesting producers
|
|
const subscriberWs = new MockWebSocket();
|
|
const subscriberId = signalingService.handleConnection(subscriberWs, "subscriber");
|
|
|
|
const getProducersMessage: ISFUSignalingMessage = {
|
|
type: "getProducers"
|
|
};
|
|
|
|
await signalingService.handleMessage(subscriberId, getProducersMessage);
|
|
|
|
const lastMessage = subscriberWs.getLastMessage();
|
|
expect(lastMessage.type).toBe("producers");
|
|
expect(lastMessage.data.producers).toHaveLength(1);
|
|
expect(lastMessage.data.producers[0].id).toBe('producer-123');
|
|
expect(lastMessage.data.producers[0].kind).toBe('video');
|
|
});
|
|
|
|
test("should notify subscribers when new producer joins", async () => {
|
|
// Create subscribers first
|
|
const subscriber1Ws = new MockWebSocket();
|
|
const subscriber1Id = signalingService.handleConnection(subscriber1Ws, "subscriber");
|
|
|
|
const subscriber2Ws = new MockWebSocket();
|
|
const subscriber2Id = signalingService.handleConnection(subscriber2Ws, "subscriber");
|
|
|
|
// Clear initial join messages
|
|
subscriber1Ws.sentMessages = [];
|
|
subscriber2Ws.sentMessages = [];
|
|
|
|
// Create publisher and produce
|
|
const publisherWs = new MockWebSocket();
|
|
const publisherId = signalingService.handleConnection(publisherWs, "publisher");
|
|
await signalingService.handleMessage(publisherId, { type: "createWebRtcTransport" });
|
|
|
|
// Mock producer creation and produce
|
|
const mockProducer = { id: 'producer-123', kind: 'video' };
|
|
const publisherClient = clientManager.getClient(publisherId);
|
|
if (publisherClient?.transport) {
|
|
(publisherClient.transport as any).produce = () => Promise.resolve(mockProducer);
|
|
}
|
|
|
|
await signalingService.handleMessage(publisherId, {
|
|
type: "produce",
|
|
data: { kind: "video", rtpParameters: { codecs: [], encodings: [] } }
|
|
});
|
|
|
|
// Check that subscribers were notified
|
|
const sub1LastMessage = subscriber1Ws.getLastMessage();
|
|
const sub2LastMessage = subscriber2Ws.getLastMessage();
|
|
|
|
expect(sub1LastMessage?.type).toBe("newProducer");
|
|
expect(sub1LastMessage?.data.producerId).toBe('producer-123');
|
|
|
|
expect(sub2LastMessage?.type).toBe("newProducer");
|
|
expect(sub2LastMessage?.data.producerId).toBe('producer-123');
|
|
});
|
|
|
|
test("should handle messages from unknown clients gracefully", async () => {
|
|
const message: ISFUSignalingMessage = {
|
|
type: "getRouterRtpCapabilities"
|
|
};
|
|
|
|
expect(async () => {
|
|
await signalingService.handleMessage("unknown-client-id", message);
|
|
}).not.toThrow();
|
|
});
|
|
|
|
test("should handle unknown message types", async () => {
|
|
const mockWs = new MockWebSocket();
|
|
const clientId = signalingService.handleConnection(mockWs, "publisher");
|
|
|
|
const unknownMessage: any = {
|
|
type: "unknownMessageType"
|
|
};
|
|
|
|
await signalingService.handleMessage(clientId, unknownMessage);
|
|
|
|
// Should not crash, just log a warning
|
|
expect(true).toBe(true); // Test passes if no exception thrown
|
|
});
|
|
|
|
test("should provide service statistics", () => {
|
|
// Create some clients
|
|
const publisher1Ws = new MockWebSocket();
|
|
const publisher1Id = signalingService.handleConnection(publisher1Ws, "publisher");
|
|
|
|
const subscriber1Ws = new MockWebSocket();
|
|
const subscriber1Id = signalingService.handleConnection(subscriber1Ws, "subscriber");
|
|
|
|
const subscriber2Ws = new MockWebSocket();
|
|
const subscriber2Id = signalingService.handleConnection(subscriber2Ws, "subscriber");
|
|
|
|
// Add some producers and consumers
|
|
const mockProducer = { id: 'producer-123', kind: 'video' };
|
|
const mockConsumer1 = { id: 'consumer-123', kind: 'video' };
|
|
const mockConsumer2 = { id: 'consumer-456', kind: 'video' };
|
|
|
|
clientManager.setClientProducer(publisher1Id, mockProducer as any);
|
|
clientManager.addClientConsumer(subscriber1Id, mockConsumer1 as any);
|
|
clientManager.addClientConsumer(subscriber2Id, mockConsumer2 as any);
|
|
|
|
const stats = signalingService.getStats();
|
|
|
|
expect(stats.clients).toBe(3);
|
|
expect(stats.publishers).toBe(1);
|
|
expect(stats.subscribers).toBe(2);
|
|
expect(stats.producers).toBe(1);
|
|
expect(stats.consumers).toBe(2);
|
|
expect(stats.mediaServer.workerId).toBe(12345);
|
|
});
|
|
}); |