import { afterEach, describe, expect, it, vi } from 'vitest' import { defineComponent, nextTick } from 'vue' import { mount } from '@vue/test-utils' import { usePresenceHeartbeat } from '../../composables/usePresenceHeartbeat' class MemoryStorage implements Pick { private readonly values = new Map() getItem(key: string): string | null { return this.values.get(key) ?? null } setItem(key: string, value: string): void { this.values.set(key, value) } } function mountHeartbeat(options: Parameters[0]) { let heartbeat: ReturnType | undefined const wrapper = mount(defineComponent({ setup() { heartbeat = usePresenceHeartbeat(options) return () => null }, })) return { wrapper, get heartbeat() { return heartbeat! } } } describe('usePresenceHeartbeat', () => { afterEach(() => { vi.useRealTimers() vi.restoreAllMocks() }) it('should reuse one sessionStorage client id and send the integrated scope immediately', async () => { const storage = new MemoryStorage() const send = vi.fn().mockResolvedValue(undefined) const first = mountHeartbeat({ scope: 'LEAF', enabled: true, storage, createClientId: () => 'tab-1', send, }) await nextTick() expect(send).toHaveBeenCalledWith({ client_id: 'tab-1', scope: 'LEAF' }) first.wrapper.unmount() send.mockClear() const second = mountHeartbeat({ scope: 'HMDB', enabled: true, storage, createClientId: () => 'tab-2', send, }) await nextTick() expect({ clientId: second.heartbeat.clientId, payload: send.mock.calls[0]?.[0] }).toEqual({ clientId: 'tab-1', payload: { client_id: 'tab-1', scope: 'HMDB' }, }) second.wrapper.unmount() }) it('should heartbeat every 30 seconds only while the page is visible', async () => { vi.useFakeTimers() const send = vi.fn().mockResolvedValue(undefined) const visibility = { value: 'visible' } vi.spyOn(document, 'visibilityState', 'get').mockImplementation( () => visibility.value as DocumentVisibilityState, ) const { wrapper } = mountHeartbeat({ scope: 'platform', enabled: true, storage: new MemoryStorage(), createClientId: () => 'platform-tab', send, }) await nextTick() expect(send).toHaveBeenCalledTimes(1) await vi.advanceTimersByTimeAsync(30_000) expect(send).toHaveBeenCalledTimes(2) visibility.value = 'hidden' document.dispatchEvent(new Event('visibilitychange')) await vi.advanceTimersByTimeAsync(60_000) expect(send).toHaveBeenCalledTimes(2) visibility.value = 'visible' document.dispatchEvent(new Event('visibilitychange')) await nextTick() expect(send).toHaveBeenCalledTimes(3) wrapper.unmount() await vi.advanceTimersByTimeAsync(60_000) expect(send).toHaveBeenCalledTimes(3) }) it('should make no request when the caller is standalone', async () => { vi.useFakeTimers() const send = vi.fn().mockResolvedValue(undefined) const { wrapper } = mountHeartbeat({ scope: 'LEAF', enabled: false, storage: new MemoryStorage(), createClientId: () => 'standalone-tab', send, }) await nextTick() await vi.advanceTimersByTimeAsync(90_000) expect(send).not.toHaveBeenCalled() wrapper.unmount() }) it('should forward the platform revision response to the runtime observer', async () => { const onResponse = vi.fn() const response = { frontend_revision: 'sha256:new' } const { wrapper } = mountHeartbeat({ scope: 'LEAF', enabled: true, storage: new MemoryStorage(), createClientId: () => 'leaf-tab', send: vi.fn().mockResolvedValue(response), onResponse, }) await nextTick() expect(onResponse).toHaveBeenCalledWith(response) wrapper.unmount() }) it('should keep a page-local id when sessionStorage is unavailable', async () => { const storage = { getItem: vi.fn(() => { throw new Error('blocked') }), setItem: vi.fn(() => { throw new Error('blocked') }), } const send = vi.fn().mockResolvedValue(undefined) const first = mountHeartbeat({ scope: 'LEAF', enabled: true, storage, createClientId: () => 'fallback-tab', send, }) await nextTick() first.wrapper.unmount() const second = mountHeartbeat({ scope: 'HMDB', enabled: true, storage, createClientId: () => 'different-tab', send, }) await nextTick() expect(second.heartbeat.clientId).toBe('fallback-tab') second.wrapper.unmount() }) })