// @vitest-environment jsdom import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; import {act, cleanup, renderHook} from '@testing-library/react'; import {MemoryRouter, Route, Routes} from 'react-router-dom'; import {toast} from 'sonner'; import {PreferencesProvider, usePreferences} from '../context/PreferencesContext.jsx'; import useSaveAppearance from './useSaveAppearance.js'; import RequireSession from '../components/RequireSession.jsx'; import {DEFAULT_APPEARANCE} from '../utils/appearance.js'; vi.mock('react-i18next', () => import('../test/i18n-mock.js')); vi.mock('sonner', () => ({toast: {success: vi.fn(), error: vi.fn()}})); // RequireSession ends the session on a 401, as on the pages that use the hook. const wrapper = ({children}) => ( }> ); const renderSave = () => renderHook(() => ({save: useSaveAppearance(), preferences: usePreferences()}), {wrapper}); beforeEach(() => { localStorage.setItem('tbp_session', '1'); }); afterEach(() => { cleanup(); localStorage.clear(); vi.clearAllMocks(); }); describe('useSaveAppearance', () => { it('applies the change at once and sends the whole appearance', async () => { globalThis.fetch = vi.fn(async () => ({ok: true, status: 204})); const {result} = renderSave(); let saved; await act(async () => { saved = await result.current.save({accent: 'blue'}); }); expect(saved).toBe(true); expect(result.current.preferences.appearance.accent).toBe('blue'); expect(document.documentElement.dataset.accent).toBe('blue'); const [url, request] = fetch.mock.calls[0]; expect(url).toContain('/api/users/settings/appearance'); expect(request.method).toBe('PUT'); expect(JSON.parse(request.body)).toEqual({accent: 'blue', corners: 'default', wideLayout: false, backgroundAppid: null, bannerAppid: null, showBanner: true}); }); it('puts the old value back when Auth refuses', async () => { globalThis.fetch = vi.fn(async () => ({ok: false, status: 400})); const {result} = renderSave(); await act(async () => { await result.current.save({corners: 'round'}); }); expect(result.current.preferences.appearance.corners).toBe('default'); expect(toast.error).toHaveBeenCalledWith('SETTINGS.ERROR_SAVE'); }); it('keeps a later change when an earlier save fails', async () => { let failFirst; globalThis.fetch = vi.fn() .mockImplementationOnce(() => new Promise((resolve) => { failFirst = () => resolve({ok: false, status: 500}); })) .mockImplementation(async () => ({ok: true, status: 204})); const {result} = renderSave(); let first; let second; await act(async () => { first = result.current.save({accent: 'green'}); }); await act(async () => { second = result.current.save({accent: 'pink'}); }); await act(async () => { failFirst(); await first; await second; }); expect(result.current.preferences.appearance.accent).toBe('pink'); expect(JSON.parse(fetch.mock.calls[1][1].body).accent).toBe('pink'); }); it('ends the session on a 401', async () => { globalThis.fetch = vi.fn(async () => ({ok: false, status: 401})); const {result} = renderSave(); await act(async () => { await result.current.save({wideLayout: true}); }); expect(localStorage.getItem('tbp_jwt')).toBeNull(); }); it('ends the session with the default look on a 401, so the next user does not get this one', async () => { localStorage.setItem('tbp_appearance', JSON.stringify({accent: 'green', corners: 'round', wideLayout: true, backgroundAppid: 620})); globalThis.fetch = vi.fn(async () => ({ok: false, status: 401})); const {result} = renderSave(); await act(async () => { await result.current.save({corners: 'sharp'}); }); expect(result.current.preferences.appearance).toEqual(DEFAULT_APPEARANCE); expect(JSON.parse(localStorage.getItem('tbp_appearance'))).toEqual(DEFAULT_APPEARANCE); }); it('waits for a running load from Auth, then keeps its values for the fields not changed here', async () => { const fromServer = {accent: 'green', corners: 'default', wideLayout: false, backgroundAppid: null, bannerAppid: 620, showBanner: false}; let answerMe; globalThis.fetch = vi.fn((url, init) => (init?.method === 'PUT' ? Promise.resolve({ok: true, status: 204}) : new Promise((resolve) => { answerMe = () => resolve({ok: true, status: 200, json: async () => ({settings: {appearance: fromServer}})}); }))); const {result} = renderSave(); let sync; let save; act(() => { sync = result.current.preferences.syncPreferences(); }); act(() => { save = result.current.save({corners: 'round'}); }); expect(result.current.preferences.appearance.corners).toBe('round'); expect(fetch.mock.calls.some(([, init]) => init?.method === 'PUT')).toBe(false); await act(async () => { answerMe(); await sync; await save; }); expect(result.current.preferences.appearance).toEqual({...fromServer, corners: 'round'}); const [, put] = fetch.mock.calls.find(([, init]) => init?.method === 'PUT'); expect(JSON.parse(put.body)).toEqual({...fromServer, corners: 'round'}); }); it('does not bring back the look of the user before after a reset', async () => { let answerMe; globalThis.fetch = vi.fn(() => new Promise((resolve) => { answerMe = () => resolve({ok: true, status: 200, json: async () => ({settings: {appearance: {accent: 'red', corners: 'sharp'}}})}); })); const {result} = renderSave(); let sync; act(() => { sync = result.current.preferences.syncPreferences(); }); act(() => result.current.preferences.resetAppearance()); await act(async () => { answerMe(); await sync; }); expect(result.current.preferences.appearance).toEqual(DEFAULT_APPEARANCE); }); it('sends saves one at a time, so a later save never carries an older value', async () => { const answers = []; globalThis.fetch = vi.fn((url, init) => (init?.method === 'PUT' ? new Promise((resolve) => answers.push(() => resolve({ok: true, status: 204}))) : Promise.resolve({ok: true, status: 200, json: async () => ({})}))); const puts = () => fetch.mock.calls.filter(([, init]) => init?.method === 'PUT').map(([, init]) => JSON.parse(init.body)); const {result} = renderSave(); let first; let second; await act(async () => { first = result.current.save({accent: 'green'}); }); await act(async () => { second = result.current.save({corners: 'round'}); }); expect(puts()).toHaveLength(1); await act(async () => { answers[0](); await first; }); await act(async () => { answers[1](); await second; }); let third; await act(async () => { third = result.current.save({wideLayout: true}); }); await act(async () => { answers[2](); await third; }); expect(result.current.preferences.appearance.corners).toBe('round'); expect(puts()[2]).toEqual({accent: 'green', corners: 'round', wideLayout: true, backgroundAppid: null, bannerAppid: null, showBanner: true}); }); it('sends nothing for a change that waited for the load when a reset came first', async () => { let answerMe; globalThis.fetch = vi.fn((url, init) => (init?.method === 'PUT' ? Promise.resolve({ok: true, status: 204}) : new Promise((resolve) => { answerMe = () => resolve({ok: true, status: 200, json: async () => ({})}); }))); const {result} = renderSave(); act(() => { result.current.preferences.syncPreferences(); }); let save; act(() => { save = result.current.save({accent: 'green'}); }); act(() => result.current.preferences.resetAppearance()); await act(async () => { answerMe(); await save; }); expect(fetch.mock.calls.some(([, init]) => init?.method === 'PUT')).toBe(false); expect(result.current.preferences.appearance).toEqual(DEFAULT_APPEARANCE); }); });