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 => ({...mockChannel, ...overrides}); const createMockMember = (overrides: Partial = {}): 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]); }); }); });