test(broadcast-client): cover deep links and client
This commit is contained in:
parent
5fe891f472
commit
b8ce14c259
@ -37,40 +37,42 @@ export class DeepLinkRouter {
|
||||
parseDeepLink(url: string): DeepLinkRoute | null {
|
||||
try {
|
||||
const urlObj = new URL(url);
|
||||
|
||||
|
||||
// Handle app-specific URLs: myapp://screen/params
|
||||
if (urlObj.protocol !== 'http:' && urlObj.protocol !== 'https:') {
|
||||
const pathParts = urlObj.pathname.split('/').filter(Boolean);
|
||||
const screen = pathParts[0] || 'home';
|
||||
const routeParts = [urlObj.host, ...urlObj.pathname.split('/').filter(Boolean)].filter(
|
||||
Boolean
|
||||
);
|
||||
const screen = routeParts[0] || 'home';
|
||||
const params: Record<string, string> = {};
|
||||
|
||||
|
||||
// Parse query params
|
||||
urlObj.searchParams.forEach((value, key) => {
|
||||
params[key] = value;
|
||||
});
|
||||
|
||||
|
||||
return { screen, params };
|
||||
}
|
||||
|
||||
|
||||
// Handle web URLs with deep link params
|
||||
const deepLinkParam = urlObj.searchParams.get('dl');
|
||||
if (deepLinkParam) {
|
||||
return this.parseDeepLink(deepLinkParam);
|
||||
}
|
||||
|
||||
|
||||
// Handle path-based routing: /screen/params
|
||||
const pathParts = urlObj.pathname.split('/').filter(Boolean);
|
||||
if (pathParts.length > 0) {
|
||||
const screen = pathParts[0];
|
||||
const params: Record<string, string> = {};
|
||||
|
||||
|
||||
urlObj.searchParams.forEach((value, key) => {
|
||||
params[key] = value;
|
||||
});
|
||||
|
||||
|
||||
return { screen, params };
|
||||
}
|
||||
|
||||
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
@ -82,17 +84,17 @@ export class DeepLinkRouter {
|
||||
*/
|
||||
handle(route: DeepLinkRoute): boolean {
|
||||
const handler = this.handlers.get(route.screen);
|
||||
|
||||
|
||||
if (handler) {
|
||||
handler(route);
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
if (this.fallbackHandler) {
|
||||
this.fallbackHandler(route);
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
console.warn(`[DeepLink] No handler for screen: ${route.screen}`);
|
||||
return false;
|
||||
}
|
||||
@ -121,17 +123,17 @@ export function createBroadcastDeepLink(
|
||||
): string {
|
||||
const url = new URL(baseUrl);
|
||||
url.pathname = `/${screen}`;
|
||||
|
||||
|
||||
if (params) {
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
url.searchParams.set(key, value);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
if (broadcastId) {
|
||||
url.searchParams.set('broadcastId', broadcastId);
|
||||
}
|
||||
|
||||
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
@ -142,17 +144,17 @@ export const DeepLinkScreens = {
|
||||
// Broadcasts
|
||||
BROADCAST_DETAIL: 'broadcast',
|
||||
ANNOUNCEMENTS: 'announcements',
|
||||
|
||||
|
||||
// Surveys
|
||||
SURVEY: 'survey',
|
||||
SURVEY_LIST: 'surveys',
|
||||
|
||||
|
||||
// Product-specific (examples)
|
||||
SETTINGS: 'settings',
|
||||
PROFILE: 'profile',
|
||||
UPGRADE: 'upgrade',
|
||||
SUPPORT: 'support',
|
||||
|
||||
|
||||
// Fallback
|
||||
HOME: 'home',
|
||||
} as const;
|
||||
|
||||
219
packages/broadcast-client/src/index.test.ts
Normal file
219
packages/broadcast-client/src/index.test.ts
Normal file
@ -0,0 +1,219 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
DeepLinkRouter,
|
||||
DeepLinkScreens,
|
||||
createBroadcastClient,
|
||||
createBroadcastDeepLink,
|
||||
createUseBroadcast,
|
||||
} from './index.js';
|
||||
|
||||
const fetchMock = vi.fn();
|
||||
|
||||
function jsonResponse(body: unknown, status = 200) {
|
||||
return {
|
||||
ok: status >= 200 && status < 300,
|
||||
status,
|
||||
json: () => Promise.resolve(body),
|
||||
text: () => Promise.resolve(JSON.stringify(body)),
|
||||
};
|
||||
}
|
||||
|
||||
describe('createBroadcastClient', () => {
|
||||
beforeEach(() => {
|
||||
fetchMock.mockReset();
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('lists messages with default segment and auth headers', async () => {
|
||||
fetchMock.mockResolvedValueOnce(jsonResponse({ messages: [{ id: 'm1' }] }));
|
||||
|
||||
const client = createBroadcastClient({
|
||||
baseUrl: 'http://localhost:4003/api',
|
||||
productId: 'testapp',
|
||||
getAuthToken: () => 'token-123',
|
||||
platform: 'web',
|
||||
appVersion: '1.2.3',
|
||||
osVersion: 'macOS 15',
|
||||
});
|
||||
|
||||
const result = await client.listMessages();
|
||||
|
||||
expect(result).toEqual({ messages: [{ id: 'm1' }] });
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'http://localhost:4003/api/broadcasts',
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({
|
||||
Authorization: 'Bearer token-123',
|
||||
'x-product-id': 'testapp',
|
||||
'x-platform': 'web',
|
||||
'x-app-version': '1.2.3',
|
||||
'x-os-version': 'macOS 15',
|
||||
'x-user-segments': 'free',
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('supports async auth token resolution and optional headers', async () => {
|
||||
fetchMock.mockResolvedValueOnce(jsonResponse({ messages: [] }));
|
||||
|
||||
const client = createBroadcastClient({
|
||||
baseUrl: 'http://localhost:4003/api',
|
||||
productId: 'testapp',
|
||||
getAuthToken: async () => 'async-token',
|
||||
platform: 'ios',
|
||||
appVersion: '2.0.0',
|
||||
osVersion: '18.0',
|
||||
countryCode: 'US',
|
||||
regionCode: 'CA',
|
||||
userSegments: ['pro', 'beta'],
|
||||
});
|
||||
|
||||
await client.listMessages();
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'http://localhost:4003/api/broadcasts',
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({
|
||||
Authorization: 'Bearer async-token',
|
||||
'x-country-code': 'US',
|
||||
'x-region-code': 'CA',
|
||||
'x-user-segments': 'pro,beta',
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('throws a descriptive error when the API fails', async () => {
|
||||
fetchMock.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 500,
|
||||
text: () => Promise.resolve('boom'),
|
||||
});
|
||||
|
||||
const client = createBroadcastClient({
|
||||
baseUrl: 'http://localhost:4003/api',
|
||||
productId: 'testapp',
|
||||
getAuthToken: () => 'token-123',
|
||||
platform: 'web',
|
||||
appVersion: '1.2.3',
|
||||
osVersion: 'macOS 15',
|
||||
});
|
||||
|
||||
await expect(client.markRead('message-1')).rejects.toThrow('Broadcast API error: 500 boom');
|
||||
});
|
||||
|
||||
it('polls messages and returns a cleanup function', async () => {
|
||||
vi.useFakeTimers();
|
||||
fetchMock.mockResolvedValue(jsonResponse({ messages: [] }));
|
||||
|
||||
const client = createBroadcastClient({
|
||||
baseUrl: 'http://localhost:4003/api',
|
||||
productId: 'testapp',
|
||||
getAuthToken: () => 'token-123',
|
||||
platform: 'web',
|
||||
appVersion: '1.2.3',
|
||||
osVersion: 'macOS 15',
|
||||
});
|
||||
|
||||
const stopPolling = client.pollMessages(1000);
|
||||
await vi.advanceTimersByTimeAsync(3000);
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(3);
|
||||
|
||||
stopPolling();
|
||||
await vi.advanceTimersByTimeAsync(2000);
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('returns the same client from createUseBroadcast', () => {
|
||||
const client = createBroadcastClient({
|
||||
baseUrl: 'http://localhost:4003/api',
|
||||
productId: 'testapp',
|
||||
getAuthToken: () => 'token-123',
|
||||
platform: 'web',
|
||||
appVersion: '1.2.3',
|
||||
osVersion: 'macOS 15',
|
||||
});
|
||||
|
||||
const useBroadcast = createUseBroadcast(client);
|
||||
|
||||
expect(useBroadcast()).toEqual({ client });
|
||||
});
|
||||
});
|
||||
|
||||
describe('DeepLinkRouter', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('parses custom app deep links using the host as the screen', () => {
|
||||
const router = new DeepLinkRouter();
|
||||
|
||||
expect(router.parseDeepLink('myapp://broadcast?broadcastId=b1&variant=test')).toEqual({
|
||||
screen: 'broadcast',
|
||||
params: {
|
||||
broadcastId: 'b1',
|
||||
variant: 'test',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('parses nested deep links from web URLs', () => {
|
||||
const router = new DeepLinkRouter();
|
||||
|
||||
expect(
|
||||
router.parseDeepLink('https://app.bytelyst.dev/open?dl=myapp%3A%2F%2Fsurvey%3Fid%3Ds1')
|
||||
).toEqual({
|
||||
screen: 'survey',
|
||||
params: { id: 's1' },
|
||||
});
|
||||
});
|
||||
|
||||
it('dispatches to registered handlers and falls back when needed', () => {
|
||||
const router = new DeepLinkRouter();
|
||||
const primaryHandler = vi.fn();
|
||||
const fallbackHandler = vi.fn();
|
||||
|
||||
router.register(DeepLinkScreens.BROADCAST_DETAIL, primaryHandler);
|
||||
router.setFallback(fallbackHandler);
|
||||
|
||||
expect(router.handle({ screen: DeepLinkScreens.BROADCAST_DETAIL, params: { id: 'b1' } })).toBe(
|
||||
true
|
||||
);
|
||||
expect(primaryHandler).toHaveBeenCalledWith({
|
||||
screen: DeepLinkScreens.BROADCAST_DETAIL,
|
||||
params: { id: 'b1' },
|
||||
});
|
||||
|
||||
expect(router.handle({ screen: 'unknown' })).toBe(true);
|
||||
expect(fallbackHandler).toHaveBeenCalledWith({ screen: 'unknown' });
|
||||
});
|
||||
|
||||
it('returns false and warns when processing an invalid URL without a fallback', () => {
|
||||
const router = new DeepLinkRouter();
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
|
||||
expect(router.process('not-a-url')).toBe(false);
|
||||
expect(warnSpy).toHaveBeenCalledWith('[DeepLink] Failed to parse: not-a-url');
|
||||
});
|
||||
});
|
||||
|
||||
describe('createBroadcastDeepLink', () => {
|
||||
it('builds deep links with params and broadcastId', () => {
|
||||
expect(
|
||||
createBroadcastDeepLink(
|
||||
'https://app.bytelyst.dev',
|
||||
DeepLinkScreens.ANNOUNCEMENTS,
|
||||
{ tab: 'latest' },
|
||||
'b42'
|
||||
)
|
||||
).toBe('https://app.bytelyst.dev/announcements?tab=latest&broadcastId=b42');
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user