Compare commits
3 Commits
55da02e98d
...
58a938d0e7
| Author | SHA1 | Date | |
|---|---|---|---|
| 58a938d0e7 | |||
| 4673547b95 | |||
| f1c1d3ec92 |
@@ -5,4 +5,4 @@ save = false
|
||||
"@techniker-me" = "https://registry-node.techniker.me"
|
||||
|
||||
[test]
|
||||
preload = ["./test/setup.ts"]
|
||||
timeout = 5000
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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
11
test/mocks/Channel.ts
Normal 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
15
test/mocks/Member.ts
Normal 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
233
test/unit/PCastApi.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
352
test/unit/apis/Channels.test.ts
Normal file
352
test/unit/apis/Channels.test.ts
Normal 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]);
|
||||
});
|
||||
});
|
||||
});
|
||||
56
test/unit/apis/PCastRequests.test.ts
Normal file
56
test/unit/apis/PCastRequests.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
72
test/unit/apis/Reporting/ReportKind.test.ts
Normal file
72
test/unit/apis/Reporting/ReportKind.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
194
test/unit/apis/Reporting/Reporting.test.ts
Normal file
194
test/unit/apis/Reporting/Reporting.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
72
test/unit/apis/Reporting/ViewingReportKind.test.ts
Normal file
72
test/unit/apis/Reporting/ViewingReportKind.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
87
test/unit/apis/Stream.test.ts
Normal file
87
test/unit/apis/Stream.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
31
test/unit/lang/assertUnreachable.test.ts
Normal file
31
test/unit/lang/assertUnreachable.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
41
test/unit/net/http/HttpMethod.test.ts
Normal file
41
test/unit/net/http/HttpMethod.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
213
test/unit/net/http/HttpRequests.test.ts
Normal file
213
test/unit/net/http/HttpRequests.test.ts
Normal 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');
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user