353 lines
14 KiB
TypeScript
353 lines
14 KiB
TypeScript
import {describe, expect, it, beforeEach, mock} from 'bun:test';
|
|
import {Channels, ChannelError, type Channel, type Member} from '../../../src/apis/Channels';
|
|
import type {PCastHttpRequests} from '../../../src/apis/PCastRequests';
|
|
import {HttpMethod} from '../../../src/net/http/HttpMethod';
|
|
import {mockChannel} from '../../mocks/Channel';
|
|
import {mockMember} from '../../mocks/Member';
|
|
|
|
describe('Channels', () => {
|
|
let mockHttpRequests: PCastHttpRequests;
|
|
let channels: Channels;
|
|
|
|
const createMockChannel = (overrides: Partial<Channel> = {}): Channel => ({...mockChannel, ...overrides});
|
|
|
|
const createMockMember = (overrides: Partial<Member> = {}): Member => ({...mockMember, ...overrides});
|
|
|
|
beforeEach(() => {
|
|
mockHttpRequests = {
|
|
request: mock(() => Promise.resolve({status: 'ok', channels: []}))
|
|
} as unknown as PCastHttpRequests;
|
|
|
|
channels = new Channels(mockHttpRequests);
|
|
});
|
|
|
|
describe('ChannelError', () => {
|
|
describe('when creating an error', () => {
|
|
it('should set the message and code properties', () => {
|
|
const error = new ChannelError('Something went wrong', 'TEST_ERROR');
|
|
|
|
expect(error.message).toBe('Something went wrong');
|
|
expect(error.code).toBe('TEST_ERROR');
|
|
expect(error.name).toBe('ChannelError');
|
|
});
|
|
|
|
it('should be an instance of Error', () => {
|
|
const error = new ChannelError('Test', 'CODE');
|
|
|
|
expect(error).toBeInstanceOf(Error);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('when creating a channel', () => {
|
|
it('should send a PUT request with channel data', async () => {
|
|
const mockChannel = createMockChannel();
|
|
(mockHttpRequests.request as any).mockResolvedValue({status: 'ok', channel: mockChannel});
|
|
|
|
const result = await channels.create('Test Channel', 'A test channel', ['option1']);
|
|
|
|
expect(mockHttpRequests.request).toHaveBeenCalledWith(HttpMethod.PUT, '/channel', expect.objectContaining({body: expect.any(String)}));
|
|
expect(result).toEqual(mockChannel);
|
|
});
|
|
|
|
it('should throw ChannelError when name is empty', async () => {
|
|
await expect(channels.create('', 'Description')).rejects.toThrow(ChannelError);
|
|
await expect(channels.create('', 'Description')).rejects.toThrow('Channel name cannot be empty');
|
|
});
|
|
|
|
it('should throw ChannelError when name is only whitespace', async () => {
|
|
await expect(channels.create(' ', 'Description')).rejects.toThrow(ChannelError);
|
|
});
|
|
|
|
it('should throw ChannelError when description is empty', async () => {
|
|
await expect(channels.create('Name', '')).rejects.toThrow('Channel description cannot be empty');
|
|
});
|
|
|
|
it('should throw ChannelError when options is not an array', async () => {
|
|
await expect(channels.create('Name', 'Desc', 'invalid' as any)).rejects.toThrow('Channel options must be an array');
|
|
});
|
|
|
|
it('should throw ChannelError when response is missing channel data', async () => {
|
|
(mockHttpRequests.request as any).mockResolvedValue({status: 'ok'});
|
|
|
|
await expect(channels.create('Name', 'Description')).rejects.toThrow('Invalid response format - missing channel data');
|
|
});
|
|
|
|
it('should cache the created channel by alias', async () => {
|
|
const mockChannel = createMockChannel({alias: 'new-channel'});
|
|
(mockHttpRequests.request as any).mockResolvedValue({status: 'ok', channel: mockChannel});
|
|
|
|
await channels.create('New Channel', 'Description');
|
|
|
|
// Now when we call get, it should use the cached value
|
|
const cachedChannel = await channels.get({alias: 'new-channel'});
|
|
expect(cachedChannel).toEqual(mockChannel);
|
|
});
|
|
});
|
|
|
|
describe('when listing channels', () => {
|
|
it('should return all channels from the API', async () => {
|
|
const mockChannels = [createMockChannel({channelId: '1'}), createMockChannel({channelId: '2'})];
|
|
(mockHttpRequests.request as any).mockResolvedValue({status: 'ok', channels: mockChannels});
|
|
|
|
const result = await channels.list();
|
|
|
|
expect(mockHttpRequests.request).toHaveBeenCalledWith(HttpMethod.GET, '/channels');
|
|
expect(result).toEqual(mockChannels);
|
|
});
|
|
|
|
it('should throw ChannelError when response is missing channels array', async () => {
|
|
(mockHttpRequests.request as any).mockResolvedValue({status: 'ok'});
|
|
|
|
await expect(channels.list()).rejects.toThrow('Invalid response format - missing channels data');
|
|
});
|
|
|
|
it('should update the internal cache with all channels', async () => {
|
|
const mockChannels = [createMockChannel({alias: 'channel-a'}), createMockChannel({alias: 'channel-b'})];
|
|
(mockHttpRequests.request as any).mockResolvedValue({status: 'ok', channels: mockChannels});
|
|
|
|
await channels.list();
|
|
|
|
// Verify cache was updated by checking get returns cached value
|
|
const cached = await channels.get({alias: 'channel-a'});
|
|
expect(cached?.alias).toBe('channel-a');
|
|
});
|
|
});
|
|
|
|
describe('when getting a channel', () => {
|
|
it('should throw ChannelError when neither alias nor channelId is provided', async () => {
|
|
await expect(channels.get({})).rejects.toThrow('Either alias or channelId must be provided');
|
|
});
|
|
|
|
it('should return cached channel when available', async () => {
|
|
const mockChannel = createMockChannel({alias: 'cached-channel'});
|
|
(mockHttpRequests.request as any).mockResolvedValue({status: 'ok', channels: [mockChannel]});
|
|
|
|
// First call populates cache
|
|
await channels.list();
|
|
// Second call should use cache
|
|
const result = await channels.get({alias: 'cached-channel'});
|
|
|
|
expect(result).toEqual(mockChannel);
|
|
});
|
|
|
|
it('should fetch from API and find by channelId', async () => {
|
|
const mockChannel = createMockChannel({channelId: 'target-id'});
|
|
(mockHttpRequests.request as any).mockResolvedValue({status: 'ok', channels: [mockChannel]});
|
|
|
|
const result = await channels.get({channelId: 'target-id'});
|
|
|
|
expect(result).toEqual(mockChannel);
|
|
});
|
|
|
|
it('should return undefined when channel is not found', async () => {
|
|
(mockHttpRequests.request as any).mockResolvedValue({status: 'ok', channels: []});
|
|
|
|
const result = await channels.get({alias: 'non-existent'});
|
|
|
|
expect(result).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe('when getting publisher count', () => {
|
|
it('should return the count as a number', async () => {
|
|
(mockHttpRequests.request as any).mockResolvedValue('42');
|
|
|
|
const result = await channels.getPublisherCount('channel-123');
|
|
|
|
expect(mockHttpRequests.request).toHaveBeenCalledWith(HttpMethod.GET, '/channel/channel-123/publishers/count');
|
|
expect(result).toBe(42);
|
|
});
|
|
|
|
it('should URL encode the channel ID', async () => {
|
|
(mockHttpRequests.request as any).mockResolvedValue('0');
|
|
|
|
await channels.getPublisherCount('channel/with/slashes');
|
|
|
|
expect(mockHttpRequests.request).toHaveBeenCalledWith(HttpMethod.GET, '/channel/channel%2Fwith%2Fslashes/publishers/count');
|
|
});
|
|
});
|
|
|
|
describe('when getting channel members', () => {
|
|
it('should return the members array', async () => {
|
|
const mockMembers = [createMockMember()];
|
|
(mockHttpRequests.request as any).mockResolvedValue({status: 'ok', members: mockMembers});
|
|
|
|
const result = await channels.getMembers('channel-123');
|
|
|
|
expect(result).toEqual(mockMembers);
|
|
});
|
|
|
|
it('should throw ChannelError when channelId is empty', async () => {
|
|
await expect(channels.getMembers('')).rejects.toThrow('Channel ID cannot be empty');
|
|
});
|
|
|
|
it('should throw ChannelError when response is missing members', async () => {
|
|
(mockHttpRequests.request as any).mockResolvedValue({status: 'ok'});
|
|
|
|
await expect(channels.getMembers('channel-123')).rejects.toThrow('Invalid response format for channel members');
|
|
});
|
|
});
|
|
|
|
describe('when getting members by channel alias', () => {
|
|
it('should resolve channel and return members', async () => {
|
|
const mockChannel = createMockChannel({alias: 'my-alias', channelId: 'resolved-id'});
|
|
const mockMembers = [createMockMember()];
|
|
|
|
(mockHttpRequests.request as any).mockResolvedValueOnce({status: 'ok', channels: [mockChannel]}).mockResolvedValueOnce({status: 'ok', members: mockMembers});
|
|
|
|
const result = await channels.getMembersByChannelAlias('my-alias');
|
|
|
|
expect(result).toEqual(mockMembers);
|
|
});
|
|
|
|
it('should throw ChannelError when channel is not found', async () => {
|
|
(mockHttpRequests.request as any).mockResolvedValue({status: 'ok', channels: []});
|
|
|
|
await expect(channels.getMembersByChannelAlias('non-existent')).rejects.toThrow('Channel not found: non-existent');
|
|
});
|
|
});
|
|
|
|
describe('when deleting a channel', () => {
|
|
it('should send DELETE request with channelId', async () => {
|
|
(mockHttpRequests.request as any).mockResolvedValue({status: 'ok'});
|
|
|
|
await channels.delete({channelId: 'channel-to-delete'});
|
|
|
|
expect(mockHttpRequests.request).toHaveBeenCalledWith(HttpMethod.DELETE, '/channel/channel-to-delete');
|
|
});
|
|
|
|
it('should resolve alias to channelId before deleting', async () => {
|
|
const mockChannel = createMockChannel({alias: 'alias-to-delete', channelId: 'resolved-channel-id'});
|
|
(mockHttpRequests.request as any).mockResolvedValueOnce({status: 'ok', channels: [mockChannel]}).mockResolvedValueOnce({status: 'ok'});
|
|
|
|
await channels.delete({alias: 'alias-to-delete'});
|
|
|
|
expect(mockHttpRequests.request).toHaveBeenLastCalledWith(HttpMethod.DELETE, '/channel/resolved-channel-id');
|
|
});
|
|
|
|
it('should throw ChannelError when neither channelId nor alias is provided', async () => {
|
|
await expect(channels.delete({})).rejects.toThrow('Deleting a channel requires either a channelId or alias');
|
|
});
|
|
|
|
it('should throw ChannelError when channel is not found by alias', async () => {
|
|
(mockHttpRequests.request as any).mockResolvedValue({status: 'ok', channels: []});
|
|
|
|
await expect(channels.delete({alias: 'non-existent'})).rejects.toThrow('Unable to find room to delete');
|
|
});
|
|
|
|
it('should throw ChannelError when delete fails', async () => {
|
|
(mockHttpRequests.request as any).mockResolvedValue({status: 'error'});
|
|
|
|
await expect(channels.delete({channelId: 'channel-123'})).rejects.toThrow('Failed to delete channel');
|
|
});
|
|
|
|
it('should remove channel from cache after successful delete', async () => {
|
|
const mockChannel = createMockChannel({alias: 'cached-alias'});
|
|
(mockHttpRequests.request as any).mockResolvedValueOnce({status: 'ok', channels: [mockChannel]}).mockResolvedValueOnce({status: 'ok'});
|
|
|
|
// Populate cache
|
|
await channels.list();
|
|
// Delete by alias
|
|
await channels.delete({alias: 'cached-alias'});
|
|
|
|
// Verify cache was cleared
|
|
(mockHttpRequests.request as any).mockResolvedValue({status: 'ok', channels: []});
|
|
const result = await channels.get({alias: 'cached-alias'});
|
|
expect(result).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe('when getting publish source stream id', () => {
|
|
it('should extract stream id from presenter URI', async () => {
|
|
const mockMember = createMockMember({
|
|
role: 'Presenter',
|
|
streams: [{type: 'video', uri: 'pcast://example.com/stream-id-abc123', audioState: 'active', videoState: 'active'}]
|
|
});
|
|
(mockHttpRequests.request as any).mockResolvedValue({status: 'ok', members: [mockMember]});
|
|
|
|
const result = await channels.getPublishSourceStreamId('channel-123');
|
|
|
|
expect(result).toBe('stream-id-abc123');
|
|
});
|
|
|
|
it('should return null when no members exist after retries', async () => {
|
|
(mockHttpRequests.request as any).mockResolvedValue({status: 'ok', members: []});
|
|
|
|
const result = await channels.getPublishSourceStreamId('channel-123', 1);
|
|
|
|
expect(result).toBeNull();
|
|
});
|
|
|
|
it('should return null when no presenter is found after retries', async () => {
|
|
const mockMember = createMockMember({role: 'Viewer'});
|
|
(mockHttpRequests.request as any).mockResolvedValue({status: 'ok', members: [mockMember]});
|
|
|
|
const result = await channels.getPublishSourceStreamId('channel-123', 1);
|
|
|
|
expect(result).toBeNull();
|
|
});
|
|
|
|
it('should retry when no presenter is found', async () => {
|
|
const mockMember = createMockMember({role: 'Viewer'});
|
|
const presenterMember = createMockMember({role: 'Presenter'});
|
|
|
|
(mockHttpRequests.request as any).mockResolvedValueOnce({status: 'ok', members: [mockMember]}).mockResolvedValueOnce({status: 'ok', members: [presenterMember]});
|
|
|
|
const result = await channels.getPublishSourceStreamId('channel-123', 2);
|
|
|
|
expect(result).not.toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('when forking a channel', () => {
|
|
it('should send PUT request with source and destination channel ids', async () => {
|
|
(mockHttpRequests.request as any).mockResolvedValue({status: 'ok', members: []});
|
|
|
|
await channels.fork('source-channel', 'dest-channel');
|
|
|
|
expect(mockHttpRequests.request).toHaveBeenCalledWith(HttpMethod.PUT, '/channel/dest-channel/fork/source-channel');
|
|
});
|
|
|
|
it('should throw ChannelError when fork fails', async () => {
|
|
(mockHttpRequests.request as any).mockResolvedValue({status: 'error'});
|
|
|
|
await expect(channels.fork('source', 'dest')).rejects.toThrow('Failed to fork channel');
|
|
});
|
|
|
|
it('should return the response on success', async () => {
|
|
const mockResponse = {status: 'ok', members: [createMockMember()]};
|
|
(mockHttpRequests.request as any).mockResolvedValue(mockResponse);
|
|
|
|
const result = await channels.fork('source', 'dest');
|
|
|
|
expect(result).toEqual(mockResponse);
|
|
});
|
|
});
|
|
|
|
describe('when killing a channel', () => {
|
|
it('should send PUT request to kill endpoint', async () => {
|
|
(mockHttpRequests.request as any).mockResolvedValue({status: 'ok', killedMembers: []});
|
|
|
|
await channels.killChannel('channel-123');
|
|
|
|
expect(mockHttpRequests.request).toHaveBeenCalledWith(HttpMethod.PUT, '/channel/channel-123/kill');
|
|
});
|
|
|
|
it('should throw ChannelError when kill fails', async () => {
|
|
(mockHttpRequests.request as any).mockResolvedValue({status: 'error'});
|
|
|
|
await expect(channels.killChannel('channel-123')).rejects.toThrow('Failed to kill channel');
|
|
});
|
|
|
|
it('should return killed members on success', async () => {
|
|
const killedMember = createMockMember();
|
|
(mockHttpRequests.request as any).mockResolvedValue({status: 'ok', killedMembers: [killedMember]});
|
|
|
|
const result = await channels.killChannel('channel-123');
|
|
|
|
expect(result.killedMembers).toEqual([killedMember]);
|
|
});
|
|
});
|
|
});
|