From 58a938d0e7de8ead420d6fd86c75f0e9d05cb761 Mon Sep 17 00:00:00 2001 From: Alexander Zinn Date: Sun, 7 Dec 2025 04:41:41 -0500 Subject: [PATCH] Add mock data and unit tests for Channels and Members APIs --- test/mocks/Channel.ts | 11 + test/mocks/Member.ts | 15 + test/unit/PCastApi.test.ts | 233 ++++++++++++ test/unit/apis/Channels.test.ts | 352 ++++++++++++++++++ test/unit/apis/PCastRequests.test.ts | 56 +++ test/unit/apis/Reporting/ReportKind.test.ts | 72 ++++ test/unit/apis/Reporting/Reporting.test.ts | 194 ++++++++++ .../apis/Reporting/ViewingReportKind.test.ts | 72 ++++ test/unit/apis/Stream.test.ts | 87 +++++ test/unit/lang/assertUnreachable.test.ts | 31 ++ test/unit/net/http/HttpMethod.test.ts | 41 ++ test/unit/net/http/HttpRequests.test.ts | 213 +++++++++++ 12 files changed, 1377 insertions(+) create mode 100644 test/mocks/Channel.ts create mode 100644 test/mocks/Member.ts create mode 100644 test/unit/PCastApi.test.ts create mode 100644 test/unit/apis/Channels.test.ts create mode 100644 test/unit/apis/PCastRequests.test.ts create mode 100644 test/unit/apis/Reporting/ReportKind.test.ts create mode 100644 test/unit/apis/Reporting/Reporting.test.ts create mode 100644 test/unit/apis/Reporting/ViewingReportKind.test.ts create mode 100644 test/unit/apis/Stream.test.ts create mode 100644 test/unit/lang/assertUnreachable.test.ts create mode 100644 test/unit/net/http/HttpMethod.test.ts create mode 100644 test/unit/net/http/HttpRequests.test.ts diff --git a/test/mocks/Channel.ts b/test/mocks/Channel.ts new file mode 100644 index 0000000..375f1de --- /dev/null +++ b/test/mocks/Channel.ts @@ -0,0 +1,11 @@ +export const mockChannel = { + channelId: 'some-region#some-application-id#some-channel-name.s3bS4Udv1y5G', + alias: 'some-channel-name', + name: 'some-channel-name', + description: 'some-channel-description', + type: 'default', + options: [], + streamKey: '2GKJt4TP9vrJuHFhJNRm5lUJ1tGHW5LUXWWGlvz1L7S9PHVFGql82sOPI1AxbDQUeGZALVG3DuJAs63IC7agZEyljEIPM1a3b4', + created: '2024-01-01', + lastUpdated: '2024-01-01' +}; diff --git a/test/mocks/Member.ts b/test/mocks/Member.ts new file mode 100644 index 0000000..0533ef1 --- /dev/null +++ b/test/mocks/Member.ts @@ -0,0 +1,15 @@ +export const mockMember = { + sessionId: 'session-123', + screenName: 'Test User', + role: 'Presenter', + streams: [ + { + type: 'video', + uri: 'pcast://example.com/stream-id-123', + audioState: 'active', + videoState: 'active' + } + ], + state: 'active', + lastUpdate: Date.now() +}; diff --git a/test/unit/PCastApi.test.ts b/test/unit/PCastApi.test.ts new file mode 100644 index 0000000..0956251 --- /dev/null +++ b/test/unit/PCastApi.test.ts @@ -0,0 +1,233 @@ +import {describe, expect, it, beforeAll, afterAll} from 'bun:test'; +import {PCastApi} from '../../src/PCastApi'; +import {Channels} from '../../src/apis/Channels'; +import {Streams} from '../../src/apis/Stream'; +import {Reporting} from '../../src/apis/Reporting/Reporting'; +import type {ApplicationCredentials} from '../../src/apis/PCastRequests'; +import {mockChannel} from '../mocks/Channel'; +import {mockMember} from '../mocks/Member'; + +describe('PCastApi', () => { + let originalFetch: typeof globalThis.fetch; + + const mockCredentials: ApplicationCredentials = { + id: 'test-app-id', + secret: 'test-app-secret' + }; + + // Set up mock fetch before all tests to handle Channels.initialize() + beforeAll(() => { + originalFetch = globalThis.fetch; + globalThis.fetch = () => + Promise.resolve({ + ok: true, + status: 200, + headers: new Headers({'content-type': 'application/json'}), + json: async () => ({status: 'ok', channels: [], members: []}) + } as Response); + }); + + afterAll(() => { + globalThis.fetch = originalFetch; + }); + + describe('when creating an instance', () => { + it('should create a PCastApi with the factory method', () => { + const api = PCastApi.create('https://pcast.example.com', mockCredentials); + expect(api).toBeInstanceOf(PCastApi); + }); + + it('should automatically append /pcast to URI if not present', () => { + const api = PCastApi.create('https://pcast.example.com', mockCredentials); + expect(api).toBeInstanceOf(PCastApi); + }); + + it('should not duplicate /pcast suffix if already present', () => { + const api = PCastApi.create('https://pcast.example.com/pcast', mockCredentials); + expect(api).toBeInstanceOf(PCastApi); + }); + + it('should handle URI with trailing slash', () => { + const api = PCastApi.create('https://pcast.example.com/', mockCredentials); + expect(api).toBeInstanceOf(PCastApi); + }); + }); + + describe('when accessing sub-APIs', () => { + it('should expose channels API', () => { + const api = PCastApi.create('https://pcast.example.com', mockCredentials); + expect(api.channels).toBeInstanceOf(Channels); + }); + + it('should expose streams API', () => { + const api = PCastApi.create('https://pcast.example.com', mockCredentials); + expect(api.streams).toBeInstanceOf(Streams); + }); + + it('should expose reporting API', () => { + const api = PCastApi.create('https://pcast.example.com', mockCredentials); + expect(api.reporting).toBeInstanceOf(Reporting); + }); + + it('should return the same channels instance on multiple accesses', () => { + const api = PCastApi.create('https://pcast.example.com', mockCredentials); + expect(api.channels).toBe(api.channels); + }); + + it('should return the same streams instance on multiple accesses', () => { + const api = PCastApi.create('https://pcast.example.com', mockCredentials); + expect(api.streams).toBe(api.streams); + }); + + it('should return the same reporting instance on multiple accesses', () => { + const api = PCastApi.create('https://pcast.example.com', mockCredentials); + expect(api.reporting).toBe(api.reporting); + }); + }); + + describe('legacy API methods', () => { + describe('when calling createChannel', () => { + it('should delegate to channels.create', async () => { + globalThis.fetch = () => + Promise.resolve({ + ok: true, + headers: new Headers({'content-type': 'application/json'}), + json: async () => ({status: 'ok', channel: {...mockChannel, name: 'Test Channel'}}) + } as Response); + + const api = PCastApi.create('https://pcast.example.com', mockCredentials); + const result = await api.createChannel('Test Channel', 'Description', ['opt1']); + + expect(result.name).toBe('Test Channel'); + }); + }); + + describe('when calling getChannelInfoByAlias', () => { + it('should delegate to channels.get with alias parameter', async () => { + globalThis.fetch = () => + Promise.resolve({ + ok: true, + headers: new Headers({'content-type': 'application/json'}), + json: async () => ({status: 'ok', channels: [{...mockChannel, alias: 'my-alias'}]}) + } as Response); + + const api = PCastApi.create('https://pcast.example.com', mockCredentials); + const result = await api.getChannelInfoByAlias('my-alias'); + + expect(result?.alias).toBe('my-alias'); + }); + }); + + describe('when calling getChannelInfoByChannelId', () => { + it('should delegate to channels.get with channelId parameter', async () => { + globalThis.fetch = () => + Promise.resolve({ + ok: true, + headers: new Headers({'content-type': 'application/json'}), + json: async () => ({status: 'ok', channels: [{...mockChannel, channelId: 'specific-id'}]}) + } as Response); + + const api = PCastApi.create('https://pcast.example.com', mockCredentials); + const result = await api.getChannelInfoByChannelId('specific-id'); + + expect(result?.channelId).toBe('specific-id'); + }); + }); + + describe('when calling getChannelPublisherCount', () => { + it('should delegate to channels.getPublisherCount', async () => { + globalThis.fetch = () => + Promise.resolve({ + ok: true, + headers: new Headers({'content-type': 'text/plain'}), + text: async () => '5' + } as Response); + + const api = PCastApi.create('https://pcast.example.com', mockCredentials); + const result = await api.getChannelPublisherCount('channel-123'); + + expect(result).toBe(5); + }); + }); + + describe('when calling getChannelMembers', () => { + it('should delegate to channels.getMembers', async () => { + globalThis.fetch = () => + Promise.resolve({ + ok: true, + headers: new Headers({'content-type': 'application/json'}), + json: async () => ({status: 'ok', members: [{...mockMember, sessionId: 'sess-123'}]}) + } as Response); + + const api = PCastApi.create('https://pcast.example.com', mockCredentials); + const result = await api.getChannelMembers('channel-123'); + + expect(result).toHaveLength(1); + expect(result[0].sessionId).toBe('sess-123'); + }); + }); + + describe('when calling deleteChannel', () => { + it('should delegate to channels.delete with channelId', async () => { + globalThis.fetch = () => + Promise.resolve({ + ok: true, + headers: new Headers({'content-type': 'application/json'}), + json: async () => ({status: 'ok'}) + } as Response); + + const api = PCastApi.create('https://pcast.example.com', mockCredentials); + const result = await api.deleteChannel('channel-to-delete'); + + expect(result.status).toBe('ok'); + }); + }); + + describe('when calling generateViewingReport', () => { + it('should delegate to reporting.generateReport with correct parameters', async () => { + globalThis.fetch = () => + Promise.resolve({ + ok: true, + headers: new Headers({'content-type': 'text/plain'}), + text: async () => 'report-id-abc' + } as Response); + + const api = PCastApi.create('https://pcast.example.com', mockCredentials); + const startDate = new Date('2024-01-01T00:00:00Z'); + const endDate = new Date('2024-01-02T00:00:00Z'); + + const result = await api.generateViewingReport('RealTime', startDate, endDate, {tags: ['tag1']}); + + expect(result).toBe('report-id-abc'); + }); + + it('should handle HLS viewing report kind', async () => { + globalThis.fetch = () => + Promise.resolve({ + ok: true, + headers: new Headers({'content-type': 'text/plain'}), + text: async () => 'report-id' + } as Response); + + const api = PCastApi.create('https://pcast.example.com', mockCredentials); + const result = await api.generateViewingReport('HLS', new Date('2024-01-01'), new Date('2024-01-02')); + + expect(result).toBe('report-id'); + }); + + it('should handle DASH viewing report kind', async () => { + globalThis.fetch = () => + Promise.resolve({ + ok: true, + headers: new Headers({'content-type': 'text/plain'}), + text: async () => 'report-id' + } as Response); + + const api = PCastApi.create('https://pcast.example.com', mockCredentials); + const result = await api.generateViewingReport('DASH', new Date('2024-01-01'), new Date('2024-01-02')); + + expect(result).toBe('report-id'); + }); + }); + }); +}); diff --git a/test/unit/apis/Channels.test.ts b/test/unit/apis/Channels.test.ts new file mode 100644 index 0000000..2b784c0 --- /dev/null +++ b/test/unit/apis/Channels.test.ts @@ -0,0 +1,352 @@ +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]); + }); + }); +}); diff --git a/test/unit/apis/PCastRequests.test.ts b/test/unit/apis/PCastRequests.test.ts new file mode 100644 index 0000000..da9f859 --- /dev/null +++ b/test/unit/apis/PCastRequests.test.ts @@ -0,0 +1,56 @@ +import {describe, expect, it} from 'bun:test'; +import {PCastHttpRequests, type ApplicationCredentials} from '../../../src/apis/PCastRequests'; + +describe('PCastHttpRequests', () => { + const mockCredentials: ApplicationCredentials = { + id: 'test-app-id', + secret: 'test-app-secret' + }; + + describe('when instantiating with valid credentials', () => { + it('should create an instance successfully', () => { + const requests = new PCastHttpRequests('https://pcast.example.com', mockCredentials); + + expect(requests).toBeInstanceOf(PCastHttpRequests); + }); + + it('should expose the application id as tenancy', () => { + const requests = new PCastHttpRequests('https://pcast.example.com', mockCredentials); + + expect(requests.tenancy).toBe('test-app-id'); + }); + }); + + describe('when providing custom options', () => { + it('should accept a custom request timeout duration', () => { + const requests = new PCastHttpRequests('https://pcast.example.com', mockCredentials, { + requestTimeoutDuration: 5000 + }); + + expect(requests).toBeInstanceOf(PCastHttpRequests); + }); + }); + + describe('when encoding authorization credentials', () => { + it('should generate correct base64 encoded "id:secret" string', () => { + const expectedCredentials = 'test-app-id:test-app-secret'; + const expectedBase64 = typeof btoa === 'function' ? btoa(expectedCredentials) : Buffer.from(expectedCredentials, 'utf-8').toString('base64'); + + expect(expectedBase64).toBe('dGVzdC1hcHAtaWQ6dGVzdC1hcHAtc2VjcmV0'); + }); + }); +}); + +describe('ApplicationCredentials', () => { + describe('when defining credentials', () => { + it('should require both id and secret properties', () => { + const credentials: ApplicationCredentials = { + id: 'my-app', + secret: 'my-secret' + }; + + expect(credentials.id).toBe('my-app'); + expect(credentials.secret).toBe('my-secret'); + }); + }); +}); diff --git a/test/unit/apis/Reporting/ReportKind.test.ts b/test/unit/apis/Reporting/ReportKind.test.ts new file mode 100644 index 0000000..982c5ff --- /dev/null +++ b/test/unit/apis/Reporting/ReportKind.test.ts @@ -0,0 +1,72 @@ +import {describe, expect, it} from 'bun:test'; +import {ReportKind, ReportKindMapping, type ReportKindType} from '../../../../src/apis/Reporting/ReportKind'; + +describe('ReportKind', () => { + describe('when accessing enum values', () => { + it('should have Publishing as 0', () => { + expect(ReportKind.Publishing).toBe(0); + }); + + it('should have Viewing as 1', () => { + expect(ReportKind.Viewing).toBe(1); + }); + + it('should have Ingest as 2', () => { + expect(ReportKind.Ingest).toBe(2); + }); + }); +}); + +describe('ReportKindMapping', () => { + describe('when converting from type string to enum', () => { + it('should convert "Publishing" to ReportKind.Publishing', () => { + const result = ReportKindMapping.convertReportKindTypeToReportKind('Publishing'); + expect(result).toBe(ReportKind.Publishing); + }); + + it('should convert "Viewing" to ReportKind.Viewing', () => { + const result = ReportKindMapping.convertReportKindTypeToReportKind('Viewing'); + expect(result).toBe(ReportKind.Viewing); + }); + + it('should convert "IngestReport" to ReportKind.Ingest', () => { + const result = ReportKindMapping.convertReportKindTypeToReportKind('IngestReport'); + expect(result).toBe(ReportKind.Ingest); + }); + }); + + describe('when converting from enum to type string', () => { + it('should convert ReportKind.Publishing to "Publishing"', () => { + const result = ReportKindMapping.convertReportKindToReportKindType(ReportKind.Publishing); + expect(result).toBe('Publishing'); + }); + + it('should convert ReportKind.Viewing to "Viewing"', () => { + const result = ReportKindMapping.convertReportKindToReportKindType(ReportKind.Viewing); + expect(result).toBe('Viewing'); + }); + + it('should convert ReportKind.Ingest to "IngestReport"', () => { + const result = ReportKindMapping.convertReportKindToReportKindType(ReportKind.Ingest); + expect(result).toBe('IngestReport'); + }); + }); + + describe('when performing roundtrip conversions', () => { + const reportKindTypes: ReportKindType[] = ['Publishing', 'Viewing', 'IngestReport']; + + it.each(reportKindTypes)('should preserve %s through type->enum->type conversion', kindType => { + const enumValue = ReportKindMapping.convertReportKindTypeToReportKind(kindType); + const backToType = ReportKindMapping.convertReportKindToReportKindType(enumValue); + expect(backToType).toBe(kindType); + }); + + const reportKinds = [ReportKind.Publishing, ReportKind.Viewing, ReportKind.Ingest]; + + it.each(reportKinds)('should preserve enum %d through enum->type->enum conversion', kindEnum => { + const typeValue = ReportKindMapping.convertReportKindToReportKindType(kindEnum); + const backToEnum = ReportKindMapping.convertReportKindTypeToReportKind(typeValue); + expect(backToEnum).toBe(kindEnum); + }); + }); +}); diff --git a/test/unit/apis/Reporting/Reporting.test.ts b/test/unit/apis/Reporting/Reporting.test.ts new file mode 100644 index 0000000..008e9f7 --- /dev/null +++ b/test/unit/apis/Reporting/Reporting.test.ts @@ -0,0 +1,194 @@ +import {describe, expect, it, beforeEach, mock} from 'bun:test'; +import {Reporting, type PublishingReportOptions, type ViewingReportOptions, type IngestBufferUnderrunOptions} from '../../../../src/apis/Reporting/Reporting'; +import {ReportKind} from '../../../../src/apis/Reporting/ReportKind'; +import {ViewingReportKind} from '../../../../src/apis/Reporting/ViewingReportKind'; +import type {PCastHttpRequests} from '../../../../src/apis/PCastRequests'; +import {HttpMethod} from '../../../../src/net/http/HttpMethod'; + +describe('Reporting', () => { + let mockHttpRequests: PCastHttpRequests; + let reporting: Reporting; + + beforeEach(() => { + mockHttpRequests = { + request: mock(() => Promise.resolve('report-id-123')) + } as unknown as PCastHttpRequests; + + reporting = new Reporting(mockHttpRequests); + }); + + describe('when generating a publishing report', () => { + const publishingOptions: PublishingReportOptions = { + start: '2024-01-01T00:00:00Z', + end: '2024-01-02T00:00:00Z', + applicationIds: ['app-123'], + channelIds: ['channel-456'] + }; + + it('should send PUT request to publishing endpoint', async () => { + await reporting.generateReport(ReportKind.Publishing, publishingOptions); + + expect(mockHttpRequests.request).toHaveBeenCalledWith( + HttpMethod.PUT, + '/reporting/publishing', + expect.objectContaining({ + body: expect.any(String) + }) + ); + }); + + it('should include all options in the request body', async () => { + await reporting.generateReport(ReportKind.Publishing, publishingOptions); + + const [, , options] = (mockHttpRequests.request as any).mock.calls[0]; + const body = JSON.parse(options.body); + + expect(body.publishingReport.start).toBe('2024-01-01T00:00:00Z'); + expect(body.publishingReport.end).toBe('2024-01-02T00:00:00Z'); + expect(body.publishingReport.applicationIds).toEqual(['app-123']); + expect(body.publishingReport.channelIds).toEqual(['channel-456']); + }); + + it('should return the report ID', async () => { + const result = await reporting.generateReport(ReportKind.Publishing, publishingOptions); + + expect(result).toBe('report-id-123'); + }); + }); + + describe('when generating a viewing report', () => { + const viewingOptions: ViewingReportOptions = { + kind: ViewingReportKind.RealTime, + start: '2024-01-01T00:00:00Z', + end: '2024-01-02T00:00:00Z', + sessionIds: ['session-123'] + }; + + it('should send PUT request to viewing endpoint', async () => { + await reporting.generateReport(ReportKind.Viewing, viewingOptions); + + expect(mockHttpRequests.request).toHaveBeenCalledWith(HttpMethod.PUT, '/reporting/viewing', expect.any(Object)); + }); + + it('should convert ViewingReportKind enum to type string', async () => { + await reporting.generateReport(ReportKind.Viewing, viewingOptions); + + const [, , options] = (mockHttpRequests.request as any).mock.calls[0]; + const body = JSON.parse(options.body); + + expect(body.viewingReport.kind).toBe('RealTime'); + }); + + it('should handle HLS viewing report kind', async () => { + const hlsOptions: ViewingReportOptions = { + ...viewingOptions, + kind: ViewingReportKind.HLS + }; + + await reporting.generateReport(ReportKind.Viewing, hlsOptions); + + const [, , options] = (mockHttpRequests.request as any).mock.calls[0]; + const body = JSON.parse(options.body); + + expect(body.viewingReport.kind).toBe('HLS'); + }); + + it('should handle DASH viewing report kind', async () => { + const dashOptions: ViewingReportOptions = { + ...viewingOptions, + kind: ViewingReportKind.DASH + }; + + await reporting.generateReport(ReportKind.Viewing, dashOptions); + + const [, , options] = (mockHttpRequests.request as any).mock.calls[0]; + const body = JSON.parse(options.body); + + expect(body.viewingReport.kind).toBe('DASH'); + }); + + it('should default tags and originTags to empty arrays when not provided', async () => { + await reporting.generateReport(ReportKind.Viewing, viewingOptions); + + const [, , options] = (mockHttpRequests.request as any).mock.calls[0]; + const body = JSON.parse(options.body); + + expect(body.viewingReport.tags).toEqual([]); + expect(body.viewingReport.originTags).toEqual([]); + }); + + it('should preserve provided tags and originTags', async () => { + const optionsWithTags: ViewingReportOptions = { + ...viewingOptions, + tags: ['tag1', 'tag2'], + originTags: ['origin1'] + }; + + await reporting.generateReport(ReportKind.Viewing, optionsWithTags); + + const [, , options] = (mockHttpRequests.request as any).mock.calls[0]; + const body = JSON.parse(options.body); + + expect(body.viewingReport.tags).toEqual(['tag1', 'tag2']); + expect(body.viewingReport.originTags).toEqual(['origin1']); + }); + }); + + describe('when generating an ingest buffer underrun report', () => { + const ingestOptions: IngestBufferUnderrunOptions = { + kind: 'BufferUnderrun', + start: '2024-01-01T00:00:00Z', + end: '2024-01-02T00:00:00Z', + ingestIds: ['ingest-123'] + }; + + it('should send PUT request to ingest endpoint', async () => { + await reporting.generateReport(ReportKind.Ingest, ingestOptions); + + expect(mockHttpRequests.request).toHaveBeenCalledWith(HttpMethod.PUT, '/reporting/ingest', expect.any(Object)); + }); + + it('should include BufferUnderrun kind in request body', async () => { + await reporting.generateReport(ReportKind.Ingest, ingestOptions); + + const [, , options] = (mockHttpRequests.request as any).mock.calls[0]; + const body = JSON.parse(options.body); + + expect(body.ingestReport.kind).toBe('BufferUnderrun'); + }); + + it('should include ingestIds in request body', async () => { + await reporting.generateReport(ReportKind.Ingest, ingestOptions); + + const [, , options] = (mockHttpRequests.request as any).mock.calls[0]; + const body = JSON.parse(options.body); + + expect(body.ingestReport.ingestIds).toEqual(['ingest-123']); + }); + }); + + describe('when calling requestPublishingReport directly', () => { + it('should throw error when start date is missing', async () => { + const invalidOptions = {end: '2024-01-02T00:00:00Z'} as PublishingReportOptions; + + await expect(reporting.requestPublishingReport(invalidOptions)).rejects.toThrow('requires a start and end Date'); + }); + + it('should throw error when end date is missing', async () => { + const invalidOptions = {start: '2024-01-01T00:00:00Z'} as PublishingReportOptions; + + await expect(reporting.requestPublishingReport(invalidOptions)).rejects.toThrow('requires a start and end Date'); + }); + + it('should succeed with valid start and end dates', async () => { + const validOptions: PublishingReportOptions = { + start: '2024-01-01T00:00:00Z', + end: '2024-01-02T00:00:00Z' + }; + + const result = await reporting.requestPublishingReport(validOptions); + + expect(result).toBe('report-id-123'); + }); + }); +}); diff --git a/test/unit/apis/Reporting/ViewingReportKind.test.ts b/test/unit/apis/Reporting/ViewingReportKind.test.ts new file mode 100644 index 0000000..a189367 --- /dev/null +++ b/test/unit/apis/Reporting/ViewingReportKind.test.ts @@ -0,0 +1,72 @@ +import {describe, expect, it} from 'bun:test'; +import {ViewingReportKind, ViewingReportKindMapping, type ViewingReportKindType} from '../../../../src/apis/Reporting/ViewingReportKind'; + +describe('ViewingReportKind', () => { + describe('when accessing enum values', () => { + it('should have RealTime as 0', () => { + expect(ViewingReportKind.RealTime).toBe(0); + }); + + it('should have HLS as 1', () => { + expect(ViewingReportKind.HLS).toBe(1); + }); + + it('should have DASH as 2', () => { + expect(ViewingReportKind.DASH).toBe(2); + }); + }); +}); + +describe('ViewingReportKindMapping', () => { + describe('when converting from type string to enum', () => { + it('should convert "RealTime" to ViewingReportKind.RealTime', () => { + const result = ViewingReportKindMapping.convertViewingReportKindTypeToViewingReportKind('RealTime'); + expect(result).toBe(ViewingReportKind.RealTime); + }); + + it('should convert "HLS" to ViewingReportKind.HLS', () => { + const result = ViewingReportKindMapping.convertViewingReportKindTypeToViewingReportKind('HLS'); + expect(result).toBe(ViewingReportKind.HLS); + }); + + it('should convert "DASH" to ViewingReportKind.DASH', () => { + const result = ViewingReportKindMapping.convertViewingReportKindTypeToViewingReportKind('DASH'); + expect(result).toBe(ViewingReportKind.DASH); + }); + }); + + describe('when converting from enum to type string', () => { + it('should convert ViewingReportKind.RealTime to "RealTime"', () => { + const result = ViewingReportKindMapping.convertViewingReportKindToViewingReportKindType(ViewingReportKind.RealTime); + expect(result).toBe('RealTime'); + }); + + it('should convert ViewingReportKind.HLS to "HLS"', () => { + const result = ViewingReportKindMapping.convertViewingReportKindToViewingReportKindType(ViewingReportKind.HLS); + expect(result).toBe('HLS'); + }); + + it('should convert ViewingReportKind.DASH to "DASH"', () => { + const result = ViewingReportKindMapping.convertViewingReportKindToViewingReportKindType(ViewingReportKind.DASH); + expect(result).toBe('DASH'); + }); + }); + + describe('when performing roundtrip conversions', () => { + const viewingReportKindTypes: ViewingReportKindType[] = ['RealTime', 'HLS', 'DASH']; + + it.each(viewingReportKindTypes)('should preserve %s through type->enum->type conversion', kindType => { + const enumValue = ViewingReportKindMapping.convertViewingReportKindTypeToViewingReportKind(kindType); + const backToType = ViewingReportKindMapping.convertViewingReportKindToViewingReportKindType(enumValue); + expect(backToType).toBe(kindType); + }); + + const viewingReportKinds = [ViewingReportKind.RealTime, ViewingReportKind.HLS, ViewingReportKind.DASH]; + + it.each(viewingReportKinds)('should preserve enum %d through enum->type->enum conversion', kindEnum => { + const typeValue = ViewingReportKindMapping.convertViewingReportKindToViewingReportKindType(kindEnum); + const backToEnum = ViewingReportKindMapping.convertViewingReportKindTypeToViewingReportKind(typeValue); + expect(backToEnum).toBe(kindEnum); + }); + }); +}); diff --git a/test/unit/apis/Stream.test.ts b/test/unit/apis/Stream.test.ts new file mode 100644 index 0000000..59a774f --- /dev/null +++ b/test/unit/apis/Stream.test.ts @@ -0,0 +1,87 @@ +import {describe, expect, it, beforeEach, mock} from 'bun:test'; +import {Streams} from '../../../src/apis/Stream'; +import type {PCastHttpRequests} from '../../../src/apis/PCastRequests'; +import {HttpMethod} from '../../../src/net/http/HttpMethod'; + +describe('Streams', () => { + let mockHttpRequests: PCastHttpRequests; + let streams: Streams; + + beforeEach(() => { + mockHttpRequests = { + request: mock(() => Promise.resolve({status: 'ok'})) + } as unknown as PCastHttpRequests; + + streams = new Streams(mockHttpRequests); + }); + + describe('when publishing a URI', () => { + it('should send PUT request with media URI and token', async () => { + const mediaUri = 'https://example.com/video.mp4'; + const token = 'auth-token-123'; + + await streams.publishUri(mediaUri, token); + + expect(mockHttpRequests.request).toHaveBeenCalledWith( + HttpMethod.PUT, + '/stream/publish/uri/mp4', + expect.objectContaining({ + body: JSON.stringify({ + token, + uri: mediaUri, + options: [] + }) + }) + ); + }); + + it('should extract media type from URI extension', async () => { + await streams.publishUri('https://example.com/live/stream.m3u8', 'token'); + + expect(mockHttpRequests.request).toHaveBeenCalledWith(HttpMethod.PUT, '/stream/publish/uri/m3u8', expect.any(Object)); + }); + + it('should handle various media types', async () => { + const testCases = [ + {uri: 'https://example.com/video.webm', expectedType: 'webm'}, + {uri: 'https://example.com/video.mkv', expectedType: 'mkv'}, + {uri: 'https://example.com/video.ts', expectedType: 'ts'}, + {uri: 'https://example.com/video.flv', expectedType: 'flv'} + ]; + + for (const {uri, expectedType} of testCases) { + (mockHttpRequests.request as any).mockClear(); + await streams.publishUri(uri, 'token'); + + expect(mockHttpRequests.request).toHaveBeenCalledWith(HttpMethod.PUT, `/stream/publish/uri/${expectedType}`, expect.any(Object)); + } + }); + + it('should throw error when URI has no file extension', async () => { + const invalidUri = 'https://example.com/stream'; + + await expect(streams.publishUri(invalidUri, 'token')).rejects.toThrow('Invalid media URI'); + }); + + it('should handle URIs with query parameters', async () => { + await streams.publishUri('https://example.com/video.mp4?token=abc&quality=hd', 'token'); + + expect(mockHttpRequests.request).toHaveBeenCalledWith(HttpMethod.PUT, '/stream/publish/uri/mp4', expect.any(Object)); + }); + + it('should handle URIs with fragments', async () => { + await streams.publishUri('https://example.com/video.webm#start=10', 'token'); + + expect(mockHttpRequests.request).toHaveBeenCalledWith(HttpMethod.PUT, '/stream/publish/uri/webm', expect.any(Object)); + }); + + it('should return the API response', async () => { + const mockResponse = {status: 'ok', streamId: 'stream-123'}; + (mockHttpRequests.request as any).mockResolvedValue(mockResponse); + + const result = await streams.publishUri('https://example.com/video.mp4', 'token'); + + expect(result).toEqual(mockResponse); + }); + }); +}); diff --git a/test/unit/lang/assertUnreachable.test.ts b/test/unit/lang/assertUnreachable.test.ts new file mode 100644 index 0000000..def23cd --- /dev/null +++ b/test/unit/lang/assertUnreachable.test.ts @@ -0,0 +1,31 @@ +import {describe, expect, it} from 'bun:test'; +import assertUnreachable from '../../../src/lang/assertUnreachable'; + +describe('assertUnreachable', () => { + describe('when called with an unexpected value', () => { + it('should throw an error containing the value', () => { + const unreachableValue = 'unexpected' as never; + + expect(() => assertUnreachable(unreachableValue)).toThrow('Unreachable code: unexpected'); + }); + + it('should handle numeric values in the error message', () => { + const numericValue = 42 as never; + + expect(() => assertUnreachable(numericValue)).toThrow('Unreachable code: 42'); + }); + + it('should always throw and never return', () => { + const value = 'test' as never; + let didThrow = false; + + try { + assertUnreachable(value); + } catch { + didThrow = true; + } + + expect(didThrow).toBe(true); + }); + }); +}); diff --git a/test/unit/net/http/HttpMethod.test.ts b/test/unit/net/http/HttpMethod.test.ts new file mode 100644 index 0000000..f0e3b83 --- /dev/null +++ b/test/unit/net/http/HttpMethod.test.ts @@ -0,0 +1,41 @@ +import {describe, expect, it} from 'bun:test'; +import {HttpMethod} from '../../../../src/net/http/HttpMethod'; + +describe('HttpMethod', () => { + describe('when accessing enum values', () => { + it('should return "GET" for GET method', () => { + expect(HttpMethod.GET).toBe('GET'); + }); + + it('should return "POST" for POST method', () => { + expect(HttpMethod.POST).toBe('POST'); + }); + + it('should return "PUT" for PUT method', () => { + expect(HttpMethod.PUT).toBe('PUT'); + }); + + it('should return "PATCH" for PATCH method', () => { + expect(HttpMethod.PATCH).toBe('PATCH'); + }); + + it('should return "DELETE" for DELETE method', () => { + expect(HttpMethod.DELETE).toBe('DELETE'); + }); + + it('should return "OPTIONS" for OPTIONS method', () => { + expect(HttpMethod.OPTIONS).toBe('OPTIONS'); + }); + + it('should return "HEAD" for HEAD method', () => { + expect(HttpMethod.HEAD).toBe('HEAD'); + }); + }); + + describe('when enumerating all methods', () => { + it('should contain exactly 7 HTTP methods', () => { + const methodCount = Object.keys(HttpMethod).length; + expect(methodCount).toBe(7); + }); + }); +}); diff --git a/test/unit/net/http/HttpRequests.test.ts b/test/unit/net/http/HttpRequests.test.ts new file mode 100644 index 0000000..bb1db9a --- /dev/null +++ b/test/unit/net/http/HttpRequests.test.ts @@ -0,0 +1,213 @@ +import {describe, expect, it, beforeEach, afterEach, mock} from 'bun:test'; +import {HttpRequests, HttpRequestError} from '../../../../src/net/http/HttpRequests'; +import {HttpMethod} from '../../../../src/net/http/HttpMethod'; + +describe('HttpRequestError', () => { + describe('when created with only a message', () => { + it('should set the message and name properties', () => { + const error = new HttpRequestError('Test error'); + + expect(error.message).toBe('Test error'); + expect(error.name).toBe('HttpRequestError'); + }); + + it('should leave optional properties undefined', () => { + const error = new HttpRequestError('Test error'); + + expect(error.status).toBeUndefined(); + expect(error.statusText).toBeUndefined(); + expect(error.originalError).toBeUndefined(); + }); + }); + + describe('when created with all properties', () => { + it('should store all provided values', () => { + const originalError = new Error('Original'); + const error = new HttpRequestError('Test error', 404, 'Not Found', originalError); + + expect(error.message).toBe('Test error'); + expect(error.status).toBe(404); + expect(error.statusText).toBe('Not Found'); + expect(error.originalError).toBe(originalError); + }); + }); +}); + +describe('HttpRequests', () => { + let originalFetch: typeof globalThis.fetch; + + const createHttpRequests = (options: {requestTimeoutDuration?: number} = {}) => { + const baseHeaders = new Headers({ + 'Content-Type': 'application/json', + Accept: 'application/json' + }); + return new HttpRequests('https://api.example.com', baseHeaders, options); + }; + + const mockFetch = (response: Partial) => { + const mockResponse = { + ok: true, + status: 200, + statusText: 'OK', + headers: new Headers({'content-type': 'application/json'}), + json: async () => ({}), + text: async () => '', + ...response + } as Response; + + globalThis.fetch = mock(() => Promise.resolve(mockResponse)); + return globalThis.fetch; + }; + + beforeEach(() => { + originalFetch = globalThis.fetch; + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + describe('when making a GET request', () => { + it('should return parsed JSON response on success', async () => { + const responseData = {status: 'ok', data: 'test'}; + mockFetch({json: async () => responseData}); + + const httpRequests = createHttpRequests(); + const result = await httpRequests.request(HttpMethod.GET, '/test'); + + expect(result).toEqual(responseData); + }); + + it('should strip any body from the request', async () => { + const fetchMock = mockFetch({json: async () => ({})}); + + const httpRequests = createHttpRequests(); + await httpRequests.request(HttpMethod.GET, '/test', {body: JSON.stringify({key: 'value'})}); + + const [, options] = (fetchMock as any).mock.calls[0]; + expect(options.body).toBeUndefined(); + }); + + it('should construct full URL from base URI and path', async () => { + const fetchMock = mockFetch({json: async () => ({})}); + + const httpRequests = createHttpRequests(); + await httpRequests.request(HttpMethod.GET, '/api/v1/resource'); + + const [url] = (fetchMock as any).mock.calls[0]; + expect(url).toBe('https://api.example.com/api/v1/resource'); + }); + }); + + describe('when making a POST request', () => { + it('should include the body in the request', async () => { + const fetchMock = mockFetch({json: async () => ({status: 'ok'})}); + + const httpRequests = createHttpRequests(); + const body = {key: 'value'}; + await httpRequests.request(HttpMethod.POST, '/test', {body: JSON.stringify(body)}); + + const [, options] = (fetchMock as any).mock.calls[0]; + expect(options.body).toBe(JSON.stringify(body)); + }); + + it('should set the correct HTTP method', async () => { + const fetchMock = mockFetch({json: async () => ({})}); + + const httpRequests = createHttpRequests(); + await httpRequests.request(HttpMethod.POST, '/test', {body: '{}'}); + + const [, options] = (fetchMock as any).mock.calls[0]; + expect(options.method).toBe('POST'); + }); + }); + + describe('when making a PUT request with an object body', () => { + it('should stringify the body automatically', async () => { + const fetchMock = mockFetch({json: async () => ({status: 'ok'})}); + + const httpRequests = createHttpRequests(); + const body = {key: 'value', nested: {foo: 'bar'}}; + await httpRequests.request(HttpMethod.PUT, '/test', {body: body as any}); + + const [, options] = (fetchMock as any).mock.calls[0]; + expect(options.body).toBe(JSON.stringify(body)); + }); + }); + + describe('when the response content-type is text/plain', () => { + it('should return text response instead of JSON', async () => { + mockFetch({ + headers: new Headers({'content-type': 'text/plain'}), + text: async () => 'plain text response' + }); + + const httpRequests = createHttpRequests(); + const result = await httpRequests.request(HttpMethod.GET, '/test'); + + expect(result).toBe('plain text response'); + }); + }); + + describe('when the server returns a non-ok response', () => { + it('should throw HttpRequestError with status details', async () => { + mockFetch({ + ok: false, + status: 404, + statusText: 'Not Found' + }); + + const httpRequests = createHttpRequests(); + + await expect(httpRequests.request(HttpMethod.GET, '/test')).rejects.toThrow(HttpRequestError); + }); + + it('should include status code and status text in error', async () => { + mockFetch({ + ok: false, + status: 500, + statusText: 'Internal Server Error' + }); + + const httpRequests = createHttpRequests(); + + try { + await httpRequests.request(HttpMethod.GET, '/test'); + expect.unreachable('Should have thrown'); + } catch (e) { + expect(e).toBeInstanceOf(HttpRequestError); + const error = e as HttpRequestError; + expect(error.status).toBe(500); + expect(error.statusText).toBe('Internal Server Error'); + } + }); + }); + + describe('when a network error occurs', () => { + it('should wrap the error in HttpRequestError', async () => { + globalThis.fetch = mock(() => Promise.reject(new Error('Network error'))); + + const httpRequests = createHttpRequests(); + + await expect(httpRequests.request(HttpMethod.GET, '/test')).rejects.toThrow('Request failed: Network error'); + }); + }); + + describe('when the request times out', () => { + it('should throw HttpRequestError with timeout message', async () => { + const abortError = new Error('Aborted'); + abortError.name = 'AbortError'; + globalThis.fetch = mock(() => Promise.reject(abortError)); + + const httpRequests = createHttpRequests({requestTimeoutDuration: 100}); + + try { + await httpRequests.request(HttpMethod.GET, '/test'); + expect.unreachable('Should have thrown'); + } catch (e) { + expect(e).toBeInstanceOf(HttpRequestError); + expect((e as HttpRequestError).message).toContain('Request timeout after'); + } + }); + }); +});