Files
jobtrackingapp/job-tracker-ui/src/hooks/useDashboardLayout.test.tsx
T

117 lines
5.8 KiB
TypeScript

import { act, renderHook, waitFor } from '@testing-library/react';
import { api } from '../api';
import { setAuthUserKey } from '../auth';
import { defaultDashboardLayout } from '../dashboardLayout';
import { useDashboardLayout } from './useDashboardLayout';
jest.mock('../api', () => ({ api: { get: jest.fn(), put: jest.fn() } }));
const get = api.get as jest.Mock;
const put = api.put as jest.Mock;
const unsaved = { version: 1, revision: 0, widgets: [] };
beforeEach(() => {
jest.clearAllMocks();
localStorage.clear();
setAuthUserKey('a', false);
get.mockResolvedValue({ data: unsaved });
});
test('loads once, migrates only owner legacy flags, and cancels without writing', async () => {
localStorage.setItem('dashboardPrefs:a', JSON.stringify({ cards: false, activity: false }));
const { result, rerender } = renderHook(() => useDashboardLayout(true));
await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current.layout.widgets[0].visible).toBe(false);
expect(result.current.layout.widgets[1].visible).toBe(true);
act(() => result.current.startEditing());
act(() => result.current.setDraft(d => d && ({ ...d, widgets: [...d.widgets].reverse() })));
rerender();
expect(result.current.dirty).toBe(true);
expect(get).toHaveBeenCalledTimes(1);
act(() => result.current.cancelEditing());
expect(result.current.draft).toBeNull();
expect(put).not.toHaveBeenCalled();
});
test('save waits for acknowledgement and retains draft on conflict until explicit reload', async () => {
const { result } = renderHook(() => useDashboardLayout(true));
await waitFor(() => expect(result.current.loading).toBe(false));
act(() => result.current.startEditing());
act(() => result.current.setDraft(d => d && ({ ...d, widgets: d.widgets.map(w => ({ ...w, visible: false })) })));
put.mockRejectedValueOnce({ response: { status: 409 } });
await act(async () => { expect(await result.current.save()).toBe(false); });
expect(result.current.conflict).toBe(true);
expect(result.current.draft?.widgets.every(w => !w.visible)).toBe(true);
expect(result.current.layout.widgets.every(w => w.visible)).toBe(true);
get.mockResolvedValueOnce({ data: { ...defaultDashboardLayout(), revision: 3 } });
await act(async () => result.current.reload());
expect(result.current.editing).toBe(true);
expect(result.current.draft?.revision).toBe(3);
expect(result.current.conflict).toBe(false);
put.mockResolvedValueOnce({ data: { ...defaultDashboardLayout(), revision: 4 } });
await act(async () => { expect(await result.current.save()).toBe(true); });
expect(result.current.layout.revision).toBe(4);
expect(result.current.editing).toBe(false);
});
test('failed or future-version reads cannot enable editing or saves', async () => {
get.mockRejectedValueOnce(new Error('offline'));
const { result } = renderHook(() => useDashboardLayout(true));
await waitFor(() => expect(result.current.loadError).toBe('request'));
act(() => result.current.startEditing());
expect(result.current.editing).toBe(false);
get.mockResolvedValueOnce({ data: { version: 3, revision: 5, widgets: [] } });
await act(async () => result.current.reload());
expect(result.current.loadError).toBe('unsupportedVersion');
act(() => result.current.startEditing());
await act(async () => { expect(await result.current.save()).toBe(false); });
expect(put).not.toHaveBeenCalled();
});
test('old save handlers and delayed reads cannot affect a new owner', async () => {
let resolveRead!: (value: unknown) => void;
const { result } = renderHook(() => useDashboardLayout(true));
await waitFor(() => expect(result.current.loading).toBe(false));
act(() => result.current.startEditing());
const oldSave = result.current.save;
get.mockImplementationOnce(() => new Promise(resolve => { resolveRead = resolve; }));
let oldRead!: Promise<void>;
act(() => { oldRead = result.current.reload(); });
act(() => setAuthUserKey('b'));
await waitFor(() => expect(result.current.loading).toBe(false));
await act(async () => { resolveRead({ data: { ...defaultDashboardLayout(), revision: 99 } }); await oldRead; });
await act(async () => { expect(await oldSave()).toBe(false); });
expect(result.current.layout.revision).toBe(0);
expect(result.current.editing).toBe(false);
expect(put).not.toHaveBeenCalled();
});
test('pending save acknowledgement is ignored after identity changes', async () => {
let acknowledge!: (value: unknown) => void;
put.mockImplementationOnce(() => new Promise(resolve => { acknowledge = resolve; }));
const { result } = renderHook(() => useDashboardLayout(true));
await waitFor(() => expect(result.current.loading).toBe(false));
act(() => result.current.startEditing());
let save!: Promise<boolean>;
act(() => { save = result.current.save(); });
expect(result.current.saving).toBe(true);
expect(result.current.layout.revision).toBe(0);
act(() => setAuthUserKey('b'));
await waitFor(() => expect(result.current.loading).toBe(false));
await act(async () => {
acknowledge({ data: { ...defaultDashboardLayout(), revision: 10 } });
expect(await save).toBe(false);
});
expect(result.current.layout.revision).toBe(0);
expect(result.current.saving).toBe(false);
});
test('does not import anonymous legacy flags or replace saved server choices', async () => {
localStorage.setItem('dashboardPrefs:anon', JSON.stringify({ cards: false }));
const { result } = renderHook(() => useDashboardLayout(true));
await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current.layout.widgets[0].visible).toBe(true);
localStorage.setItem('dashboardPrefs:a', JSON.stringify({ cards: false }));
get.mockResolvedValueOnce({ data: { ...defaultDashboardLayout(), revision: 1 } });
await act(async () => result.current.reload());
expect(result.current.layout.widgets[0].visible).toBe(true);
});