Compare commits

...

3 Commits

16 changed files with 1431 additions and 28 deletions

View File

@@ -5,4 +5,4 @@ save = false
"@techniker-me" = "https://registry-node.techniker.me"
[test]
preload = ["./test/setup.ts"]
timeout = 5000

View File

@@ -18,6 +18,9 @@
"prelint:fix": "bun format",
"lint": "eslint src/**/*.ts",
"lint:fix": "eslint src/**/*.ts --fix",
"postlint:fix": "bun run typecheck",
"postlint": "bun run typecheck",
"typecheck": "tsc --noEmit",
"build:node": "bun build src/index.ts --outdir dist/node --target node --format esm --production",
"build:browser": "bun build src/index.ts --outdir dist/browser --target browser --format esm --production",
"build:types": "tsc --emitDeclarationOnly --outDir dist/types",

View File

@@ -19,14 +19,12 @@ export type Member = {
sessionId: string;
screenName: string;
role: string;
streams: [
{
streams: {
type: string;
uri: string;
audioState: string;
videoState: string;
}
];
}[];
state: string;
lastUpdate: number;
};
@@ -36,6 +34,16 @@ type GetChannelParams = {
channelId?: string;
};
type KillChannelResponse = {
status: string;
killedMembers: Member[];
};
type ForkChannelResponse = {
status: string;
members: Member[];
};
export class ChannelError extends Error {
constructor(
message: string,
@@ -46,16 +54,6 @@ export class ChannelError extends Error {
}
}
type KillChannelResponse = {
status: string;
killedMembers: Member[];
};
type ForkChannelResponse = {
status: string;
members: Member[];
};
export class Channels {
private readonly _httpRequests: PCastHttpRequests;
private readonly _channelsByAlias: Map<ChannelAlias, Channel> = new Map();
@@ -195,22 +193,23 @@ export class Channels {
}
public async getPublishSourceStreamId(channelId: string, retryCount: number = 3): Promise<string | null> {
const retryCountRemaining = retryCount || 3;
const channelMembers = await this.getMembers(channelId);
console.log('channelMembers [%o] retryCount [%d]', channelMembers, retryCount);
if (channelMembers.length === 0) {
if (retryCountRemaining > 0) {
return this.getPublishSourceStreamId(channelId, retryCountRemaining - 1);
if (retryCount > 0) {
return this.getPublishSourceStreamId(channelId, retryCount - 1);
}
return null;
}
const presenter = channelMembers.find(member => member.role === 'Presenter');
if (!presenter) {
if (retryCountRemaining > 0) {
return this.getPublishSourceStreamId(channelId, retryCountRemaining - 1);
if (retryCount > 0) {
return this.getPublishSourceStreamId(channelId, retryCount - 1);
}
return null;
@@ -218,7 +217,7 @@ export class Channels {
const publishSourceStreamIdRegExp = /pcast:\/\/.*\/([^?]*)/;
return presenter.streams[0].uri.match(publishSourceStreamIdRegExp)?.[1] ?? null;
return presenter.streams[0]?.uri?.match(publishSourceStreamIdRegExp)?.[1] ?? null;
}
public async fork(sourceChannelId: string, destinationChannelId: string): Promise<ForkChannelResponse> {

View File

@@ -10,10 +10,10 @@ export class Streams {
}
public async publishUri(mediaUri: string, token: string) {
const mediaType = mediaUri.split('.')?.at(-1);
const mediaType = this.extractMediaType(mediaUri);
if (!mediaType) {
throw new Error('Invalid media URI no media type found');
throw new Error('Invalid media URI: no media type found');
}
const response = await this._httpRequests.request<IResponse<string>>(HttpMethod.PUT, `/stream/publish/uri/${mediaType}`, {
@@ -26,4 +26,28 @@ export class Streams {
return response;
}
private extractMediaType(uri: string): string | undefined {
try {
const url = new URL(uri);
const pathname = url.pathname;
const lastDotIndex = pathname.lastIndexOf('.');
if (lastDotIndex === -1 || lastDotIndex === pathname.length - 1) {
return undefined;
}
return pathname.slice(lastDotIndex + 1).toLowerCase();
} catch {
// Fallback for non-URL strings (e.g., relative paths)
const cleanUri = uri.split('?')[0]?.split('#')[0];
const lastDotIndex = cleanUri?.lastIndexOf('.');
if (lastDotIndex === -1 || lastDotIndex === cleanUri?.length || lastDotIndex === undefined) {
return undefined;
}
return cleanUri?.slice(lastDotIndex + 1).toLowerCase() ?? undefined;
}
}
}

11
test/mocks/Channel.ts Normal file
View File

@@ -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'
};

15
test/mocks/Member.ts Normal file
View File

@@ -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()
};

233
test/unit/PCastApi.test.ts Normal file
View File

@@ -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');
});
});
});
});

View File

@@ -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> = {}): 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]);
});
});
});

View File

@@ -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');
});
});
});

View File

@@ -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);
});
});
});

View File

@@ -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');
});
});
});

View File

@@ -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);
});
});
});

View File

@@ -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);
});
});
});

View File

@@ -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);
});
});
});

View File

@@ -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);
});
});
});

View File

@@ -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<Response>) => {
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<typeof responseData>(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<string>(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');
}
});
});
});