From b8ce14c2598c3c9e6b99d5cc75abcfb3a8834cad Mon Sep 17 00:00:00 2001 From: saravanakumardb1 Date: Sat, 21 Mar 2026 10:56:49 -0700 Subject: [PATCH] test(broadcast-client): cover deep links and client --- packages/broadcast-client/src/deep-link.ts | 40 ++-- packages/broadcast-client/src/index.test.ts | 219 ++++++++++++++++++++ 2 files changed, 240 insertions(+), 19 deletions(-) create mode 100644 packages/broadcast-client/src/index.test.ts diff --git a/packages/broadcast-client/src/deep-link.ts b/packages/broadcast-client/src/deep-link.ts index 83a30a2f..f9abf520 100644 --- a/packages/broadcast-client/src/deep-link.ts +++ b/packages/broadcast-client/src/deep-link.ts @@ -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 = {}; - + // 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 = {}; - + 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; diff --git a/packages/broadcast-client/src/index.test.ts b/packages/broadcast-client/src/index.test.ts new file mode 100644 index 00000000..f4464ddf --- /dev/null +++ b/packages/broadcast-client/src/index.test.ts @@ -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'); + }); +});