import { act, render, screen } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { ApiProvider, createApiClient, createVerifiedSession, type ApiClient } from '../api/index.ts' import { fakeLocation, jsonResponse, ME, stubFetch } from '../test/fetchStub.ts' import { cacheFallbackHeader } from '../sw/routes.ts' import { createNetworkStatus } from './networkStatus.ts' import { OfflinePill } from './OfflinePill.tsx' const copy = 'Offline — showing saved data' let onLine: boolean beforeEach(() => { onLine = true vi.spyOn(navigator, 'onLine', 'get').mockImplementation(() => onLine) }) afterEach(() => { vi.restoreAllMocks() vi.unstubAllGlobals() }) function goOffline() { onLine = false window.dispatchEvent(new Event('offline')) } function goOnline() { onLine = true window.dispatchEvent(new Event('online')) } function renderPill(): ApiClient { const api = createApiClient(fakeLocation(), createNetworkStatus(window), createVerifiedSession(localStorage)) render( , ) return api } describe('OfflinePill', () => { it('is hidden while online', () => { renderPill() expect(screen.queryByRole('status')).toBeNull() }) it('shows while the browser is offline when it renders', () => { onLine = false renderPill() expect(screen.getByRole('status')).toHaveTextContent(copy) }) it('appears on the offline event and disappears on the online event', () => { renderPill() act(goOffline) expect(screen.getByRole('status')).toHaveTextContent(copy) act(goOnline) expect(screen.queryByRole('status')).toBeNull() }) it('appears when a request fails with a network error', async () => { const api = renderPill() vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new TypeError('Failed to fetch'))) await act(() => expect(api.apiFetch('/api/v1/plan')).rejects.toThrow(TypeError)) expect(screen.getByRole('status')).toHaveTextContent(copy) act(goOnline) expect(screen.queryByRole('status')).toBeNull() }) it('disappears when a later request gets a response', async () => { const api = renderPill() vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new TypeError('Failed to fetch'))) await act(() => expect(api.apiFetch('/api/v1/plan')).rejects.toThrow(TypeError)) expect(screen.getByRole('status')).toBeInTheDocument() stubFetch(jsonResponse(200, {})) await act(() => api.apiFetch('/api/v1/plan')) expect(screen.queryByRole('status')).toBeNull() }) it('shows while online when the service worker answers from its cache', async () => { const api = renderPill() const cached = jsonResponse(200, { id: '01J9ZR' }) cached.headers.set(cacheFallbackHeader, '1') stubFetch(cached, jsonResponse(200, { id: '01J9ZR' })) await act(() => api.apiFetch('/api/v1/recipes/01J9ZR')) expect(navigator.onLine).toBe(true) expect(screen.getByRole('status')).toHaveTextContent(copy) // The server answers again. await act(() => api.apiFetch('/api/v1/recipes/01J9ZR')) expect(screen.queryByRole('status')).toBeNull() }) it('ignores a request body that cannot be serialized', async () => { const api = renderPill() const fetchMock = stubFetch(jsonResponse(200, ME)) await act(() => api.loadMe()) const body: Record = {} body.self = body await act(() => expect(api.apiFetch('/api/v1/plan', { method: 'PUT', body })).rejects.toThrow( 'PUT /api/v1/plan: request body is not serializable as JSON', ), ) expect(fetchMock).toHaveBeenCalledTimes(1) expect(screen.queryByRole('status')).toBeNull() }) it('ignores a GET request with a body, which fetch would refuse to send', async () => { const api = renderPill() const fetchMock = stubFetch() await act(() => expect(api.apiFetch('/api/v1/plan', { body: { week: 1 } })).rejects.toThrow( 'GET /api/v1/plan: a GET request cannot carry a body', ), ) expect(fetchMock).not.toHaveBeenCalled() expect(screen.queryByRole('status')).toBeNull() }) it('ignores an aborted request', async () => { const api = renderPill() vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new DOMException('aborted', 'AbortError'))) await act(() => expect(api.apiFetch('/api/v1/plan')).rejects.toThrow('aborted')) expect(screen.queryByRole('status')).toBeNull() }) it('removes its online and offline listeners on unmount', () => { const add = vi.spyOn(window, 'addEventListener') const remove = vi.spyOn(window, 'removeEventListener') const api = createApiClient(fakeLocation(), createNetworkStatus(window), createVerifiedSession(localStorage)) const { unmount } = render( , ) const added = add.mock.calls.filter(([type]) => type === 'online' || type === 'offline') expect(added.map(([type]) => type).sort()).toEqual(['offline', 'online']) unmount() for (const [type, listener] of added) expect(remove).toHaveBeenCalledWith(type, listener) }) })