// @vitest-environment jsdom import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; import {act, cleanup, renderHook} from '@testing-library/react'; import {toast} from 'sonner'; import useSetStatus from './useSetStatus.js'; import {fetchRoutes, json} from '../test/fetchRoutes.js'; vi.mock('react-i18next', () => import('../test/i18n-mock.js')); vi.mock('sonner', () => ({toast: {success: vi.fn(), error: vi.fn()}})); const pointsCheck = vi.fn(() => Promise.resolve(null)); vi.mock('./usePointsCheck.js', () => ({default: () => pointsCheck})); const PUT = 'PUT /api/library/games/220/status'; beforeEach(() => { localStorage.setItem('tbp_session', '1'); }); afterEach(() => { cleanup(); vi.clearAllMocks(); localStorage.clear(); }); const render = (options) => renderHook(() => useSetStatus(220, options)); describe('useSetStatus', () => { it('savesTheStatusAndShowsTheSavedToast', async () => { const fetchMock = fetchRoutes({[PUT]: () => json(200, {status: 'PLAYING'})}); const {result} = render(); let saved; await act(async () => { saved = await result.current.setStatus('PLAYING'); }); expect(saved).toBe(true); expect(toast.success).toHaveBeenCalledWith('BACKLOG.SAVED'); const [, init] = fetchMock.mock.calls[0]; expect(JSON.parse(init.body)).toEqual({status: 'PLAYING'}); }); it('passesTheSavedEntryToOnChanged', async () => { fetchRoutes({[PUT]: () => json(200, {status: 'PLAYING', appid: 220})}); const onChanged = vi.fn(); const {result} = render({onChanged}); await act(() => result.current.setStatus('PLAYING')); expect(onChanged).toHaveBeenCalledTimes(1); expect(onChanged).toHaveBeenCalledWith({status: 'PLAYING', appid: 220}); }); it('countsAnEmptyBodyAsSavedAndPassesNull', async () => { fetchRoutes({[PUT]: () => new Response(null, {status: 200})}); const onChanged = vi.fn(); const {result} = render({onChanged}); let saved; await act(async () => { saved = await result.current.setStatus('PLAYING'); }); expect(saved).toBe(true); expect(toast.success).toHaveBeenCalledWith('BACKLOG.SAVED'); expect(toast.error).not.toHaveBeenCalled(); expect(onChanged).toHaveBeenCalledWith(null); }); it('showsSaveFailedOnAServerError', async () => { fetchRoutes({[PUT]: () => json(500, {})}); const onChanged = vi.fn(); const {result} = render({onChanged}); await act(() => result.current.setStatus('PLAYING')); expect(toast.error).toHaveBeenCalledWith('BACKLOG.SAVE_FAILED'); expect(onChanged).not.toHaveBeenCalled(); expect(result.current.busy).toBe(false); }); it('showsTheNetworkErrorWhenFetchRejects', async () => { vi.spyOn(console, 'error').mockImplementation(() => {}); fetchRoutes({[PUT]: () => { throw new Error('offline'); }}); const {result} = render(); await act(() => result.current.setStatus('PLAYING')); expect(toast.error).toHaveBeenCalledWith('INFO.ERROR.NETWORK_FAILURE'); }); it('staysSilentOn401', async () => { fetchRoutes({[PUT]: () => json(401, {}), 'POST /api/auth/refresh': () => json(401, {})}); const onChanged = vi.fn(); const {result} = render({onChanged}); let saved; await act(async () => { saved = await result.current.setStatus('PLAYING'); }); expect(saved).toBe(false); expect(toast.error).not.toHaveBeenCalled(); expect(toast.success).not.toHaveBeenCalled(); expect(onChanged).not.toHaveBeenCalled(); }); it('sendsOneRequestWhenCalledTwice', async () => { let release; const held = new Promise((resolve) => { release = resolve; }); const fetchMock = fetchRoutes({[PUT]: () => held}); const {result} = render(); await act(async () => { const first = result.current.setStatus('PLAYING'); const second = await result.current.setStatus('PLAYING'); expect(second).toBe(false); release(json(200, {})); await first; }); expect(fetchMock).toHaveBeenCalledTimes(1); }); it('checksPointsOnlyWhenAsked', async () => { fetchRoutes({[PUT]: () => json(200, {})}); const plain = render(); await act(() => plain.result.current.setStatus('PLAYING')); expect(pointsCheck).not.toHaveBeenCalled(); const asking = render({checkPoints: true}); await act(() => asking.result.current.setStatus('PLAYING')); expect(pointsCheck).toHaveBeenCalledWith(220, {status: 'PLAYING', tell: true}); }); it('isBusyWhileSaving', async () => { let release; const held = new Promise((resolve) => { release = resolve; }); fetchRoutes({[PUT]: () => held}); const {result} = render(); let pending; await act(async () => { pending = result.current.setStatus('PLAYING'); }); expect(result.current.busy).toBe(true); await act(async () => { release(json(200, {})); await pending; }); expect(result.current.busy).toBe(false); }); it('showsUndoOnlyWithAPreviousStatusThatDiffers', async () => { fetchRoutes({[PUT]: () => json(200, {})}); const {result} = render(); await act(() => result.current.setStatus('PLAYING')); await act(() => result.current.setStatus('PLAYING', {previous: 'PLAYING'})); expect(toast.success.mock.calls.every(([, options]) => options === undefined)).toBe(true); await act(() => result.current.setStatus('PLAYING', {previous: 'BACKLOG'})); const [message, options] = toast.success.mock.calls.at(-1); expect(message).toBe('BACKLOG.SAVED'); expect(options.action.label).toBe('MANUAL_GAME.UNDO'); expect(options.duration).toBe(8000); }); it('undoSavesThePreviousStatusAndTellsOnChanged', async () => { const fetchMock = fetchRoutes({[PUT]: (url, init) => json(200, JSON.parse(init.body))}); const onChanged = vi.fn(); const {result} = render({onChanged, checkPoints: true}); await act(() => result.current.setStatus('PLAYING', {previous: 'BACKLOG'})); onChanged.mockClear(); pointsCheck.mockClear(); await act(async () => toast.success.mock.calls[0][1].action.onClick()); expect(JSON.parse(fetchMock.mock.calls[1][1].body)).toEqual({status: 'BACKLOG'}); expect(onChanged).toHaveBeenCalledWith({status: 'BACKLOG'}); expect(pointsCheck).toHaveBeenCalledWith(220, {status: 'BACKLOG', tell: true}); // The undo itself has no Undo. expect(toast.success.mock.calls.at(-1)).toEqual(['BACKLOG.SAVED']); }); it('undoShowsSaveFailedOnAServerError', async () => { let calls = 0; fetchRoutes({[PUT]: () => json(++calls === 1 ? 200 : 500, {})}); const onChanged = vi.fn(); const {result} = render({onChanged}); await act(() => result.current.setStatus('PLAYING', {previous: 'BACKLOG'})); onChanged.mockClear(); await act(async () => toast.success.mock.calls[0][1].action.onClick()); expect(toast.error).toHaveBeenCalledWith('BACKLOG.SAVE_FAILED'); expect(onChanged).not.toHaveBeenCalled(); }); });