Files
ToBePlayed-Frontend/src/hooks/useSetStatus.test.jsx
T
lobo 45f5087227 feat(library): set status and rating in one step (#56)
Library rows: the status badge is a menu that saves at once.
Game page: status and rating in the top card, saved on click.
Status changes and hiding a game show an Undo toast.
Other game actions moved into a "..." menu; split and unlink ask first.
New sorts: name, last played, time to beat; "last synced" is gone.
Page numbers and 44 px touch targets on phones.
2026-10-21 11:09:44 +02:00

207 lines
7.4 KiB
React

// @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();
});
});