refactor(hooks): add useSetStatus for game status saves
Build, Test & Push Frontend / quality-check (push) Successful in 37s
Build, Test & Push Frontend / docker (push) Successful in 23s

Replaces 5 copies of the save code in hints, PlayNextPage and sync.
An empty 2xx body now counts as saved, not a network error.
Refs #46
This commit is contained in:
lobo committed 2026-10-11 17:55:30 +02:00
1 parent bbc707de87
commit 73ad4fd3b8
10 files changed
+239 -114

No files matched your search

+160
View File
@@ -0,0 +1,160 @@
// @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);
});
});