test(broadcast-client): cover deep links and client

This commit is contained in:
saravanakumardb1 2026-03-21 10:56:49 -07:00
parent 5fe891f472
commit b8ce14c259
2 changed files with 240 additions and 19 deletions

View File

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

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