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; 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; 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); });