117 lines
5.8 KiB
TypeScript
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);
|
|
});
|