/** * Event-bridge tests for the cookie store: same-tab `window` CustomEvent and cross-tab * BroadcastChannel. Runs in jsdom (default). */ import React from 'react' import { act, render, renderHook, waitFor } from '@baseapp-frontend/test' import ClientCookies from 'js-cookie' import useCookie, { CookieProvider } from '..' import { ACCESS_KEY_NAME, REFRESH_KEY_NAME } from '../../../constants/jwt' import { CURRENT_PROFILE_KEY_NAME } from '../../../constants/profile' import { removeCookie, setCookie } from '../../../functions/cookie' import { COOKIE_CHANGE_EVENT } from '../constants' jest.mock('js-cookie', () => ({ get: jest.fn(), set: jest.fn(), remove: jest.fn(), })) const mockedCookies = ClientCookies as jest.Mocked // jsdom doesn't expose BroadcastChannel here. Stub matching real semantics: same-name // channels receive each other's posts; a channel never receives its own. class MockBroadcastChannel { static subscribers = new Map>() name: string onmessage: ((event: { data: unknown }) => void) | null = null constructor(name: string) { this.name = name if (!MockBroadcastChannel.subscribers.has(name)) { MockBroadcastChannel.subscribers.set(name, new Set()) } MockBroadcastChannel.subscribers.get(name)!.add(this) } postMessage(data: unknown) { const subs = MockBroadcastChannel.subscribers.get(this.name) if (!subs) return subs.forEach((sub) => { if (sub !== this && sub.onmessage) sub.onmessage({ data }) }) } close() { MockBroadcastChannel.subscribers.get(this.name)?.delete(this) } } beforeAll(() => { ;(globalThis as unknown as { BroadcastChannel?: typeof MockBroadcastChannel }).BroadcastChannel = MockBroadcastChannel }) afterAll(() => { delete (globalThis as { BroadcastChannel?: unknown }).BroadcastChannel }) describe('useCookie event-bridge sync', () => { beforeEach(() => { jest.clearAllMocks() }) const wrapWith = (initialCookies?: Record) => ({ children }: { children: React.ReactNode }) => ( {children} ) it('propagates setCookie into the in-tree store', async () => { const { result } = renderHook(() => useCookie(), { wrapper: wrapWith({ [ACCESS_KEY_NAME]: 'initial-token' }), }) expect(result.current.cookies?.[ACCESS_KEY_NAME]).toBe('initial-token') act(() => { setCookie(ACCESS_KEY_NAME, 'updated-token') }) await waitFor(() => expect(result.current.cookies?.[ACCESS_KEY_NAME]).toBe('updated-token')) expect(mockedCookies.set).toHaveBeenCalledWith(ACCESS_KEY_NAME, 'updated-token', {}) }) it('propagates removeCookie into the in-tree store', async () => { const { result } = renderHook(() => useCookie(), { wrapper: wrapWith({ [ACCESS_KEY_NAME]: 'initial-token' }), }) expect(result.current.cookies?.[ACCESS_KEY_NAME]).toBe('initial-token') act(() => { removeCookie(ACCESS_KEY_NAME) }) await waitFor(() => expect(result.current.cookies?.[ACCESS_KEY_NAME]).toBeUndefined()) expect(mockedCookies.remove).toHaveBeenCalledWith(ACCESS_KEY_NAME) }) it('handles multiple consecutive setCookie writes (last write wins for same key)', async () => { const { result } = renderHook(() => useCookie(), { wrapper: wrapWith({ [ACCESS_KEY_NAME]: 'initial' }), }) act(() => { setCookie(ACCESS_KEY_NAME, 'one') setCookie(ACCESS_KEY_NAME, 'two') setCookie(ACCESS_KEY_NAME, 'three') }) await waitFor(() => expect(result.current.cookies?.[ACCESS_KEY_NAME]).toBe('three')) }) it('reflects writes for different keys independently', async () => { const { result } = renderHook(() => useCookie(), { wrapper: wrapWith({ [ACCESS_KEY_NAME]: 'a', [REFRESH_KEY_NAME]: 'r' }), }) act(() => { setCookie(ACCESS_KEY_NAME, 'a-new') setCookie(REFRESH_KEY_NAME, 'r-new') }) await waitFor(() => expect(result.current.cookies?.[ACCESS_KEY_NAME]).toBe('a-new')) expect(result.current.cookies?.[REFRESH_KEY_NAME]).toBe('r-new') }) it('reflects writes for non-default keys (e.g., CURRENT_PROFILE_KEY_NAME)', async () => { const { result } = renderHook(() => useCookie>(), { wrapper: wrapWith(), }) act(() => { setCookie(CURRENT_PROFILE_KEY_NAME, '{"id":"profile-1"}') }) await waitFor(() => expect(result.current.cookies?.[CURRENT_PROFILE_KEY_NAME]).toBe('{"id":"profile-1"}'), ) }) it('cleans up the listener on Provider unmount; future setCookie does not error', () => { const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation() const removeListenerSpy = jest.spyOn(window, 'removeEventListener') const { unmount } = render( child , ) unmount() expect(removeListenerSpy).toHaveBeenCalledWith(COOKIE_CHANGE_EVENT, expect.any(Function)) expect(() => setCookie(ACCESS_KEY_NAME, 'after-unmount')).not.toThrow() expect(consoleErrorSpy).not.toHaveBeenCalled() removeListenerSpy.mockRestore() consoleErrorSpy.mockRestore() }) it('does not throw when setCookie is called with no Provider mounted', () => { const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation() expect(() => setCookie(ACCESS_KEY_NAME, 'value')).not.toThrow() expect(() => removeCookie(ACCESS_KEY_NAME)).not.toThrow() expect(consoleErrorSpy).not.toHaveBeenCalled() consoleErrorSpy.mockRestore() }) it('updates two CookieProviders mounted in parallel from a single setCookie', async () => { const { result: r1 } = renderHook(() => useCookie(), { wrapper: ({ children }) => ( {children} ), }) const { result: r2 } = renderHook(() => useCookie(), { wrapper: ({ children }) => ( {children} ), }) expect(r1.current.cookies?.[ACCESS_KEY_NAME]).toBe('a1') expect(r2.current.cookies?.[ACCESS_KEY_NAME]).toBe('a2') act(() => { setCookie(ACCESS_KEY_NAME, 'shared') }) await waitFor(() => expect(r1.current.cookies?.[ACCESS_KEY_NAME]).toBe('shared')) await waitFor(() => expect(r2.current.cookies?.[ACCESS_KEY_NAME]).toBe('shared')) }) it('attaches a listener exactly once per Provider mount', () => { const addListenerSpy = jest.spyOn(window, 'addEventListener') render( child , ) const cookieListenerCalls = addListenerSpy.mock.calls.filter( ([type]) => type === COOKIE_CHANGE_EVENT, ) expect(cookieListenerCalls).toHaveLength(1) addListenerSpy.mockRestore() }) it('synchronises across tabs via BroadcastChannel', async () => { const { result } = renderHook(() => useCookie(), { wrapper: wrapWith({ [ACCESS_KEY_NAME]: 'initial-token' }), }) expect(result.current.cookies?.[ACCESS_KEY_NAME]).toBe('initial-token') const externalChannel = new BroadcastChannel(COOKIE_CHANGE_EVENT) externalChannel.postMessage({ type: 'set', key: ACCESS_KEY_NAME, value: 'from-other-tab' }) await waitFor(() => expect(result.current.cookies?.[ACCESS_KEY_NAME]).toBe('from-other-tab')) externalChannel.postMessage({ type: 'remove', key: ACCESS_KEY_NAME }) await waitFor(() => expect(result.current.cookies?.[ACCESS_KEY_NAME]).toBeUndefined()) externalChannel.close() }) it('closes its BroadcastChannel on Provider unmount', async () => { const { result, unmount } = renderHook(() => useCookie(), { wrapper: wrapWith({ [ACCESS_KEY_NAME]: 'initial' }), }) expect(result.current.cookies?.[ACCESS_KEY_NAME]).toBe('initial') unmount() const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation() const externalChannel = new BroadcastChannel(COOKIE_CHANGE_EVENT) externalChannel.postMessage({ type: 'set', key: ACCESS_KEY_NAME, value: 'after-unmount' }) externalChannel.close() await new Promise((resolve) => setTimeout(resolve, 10)) expect(consoleErrorSpy).not.toHaveBeenCalled() consoleErrorSpy.mockRestore() }) })