import { act, fireEvent, render, renderHook, screen, waitFor } from '@testing-library/react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { type AuthUser, useAuthStore } from '@/lib/stores/auth-store'; // quick-260928-ujj: der Dashboard-Hintergrund steht pro Benutzer in der // Datenbank. Der Hook liest ihn aus dem Auth-Store (Sitzungsantwort) und // speichert ueber die Server-Aktion; eine alte localStorage-Wahl wird // einmalig uebernommen. const updateDashboardBackgroundAction = vi.fn(); vi.mock('@/lib/auth-actions', () => ({ updateDashboardBackgroundAction: (...args: unknown[]) => updateDashboardBackgroundAction(...args), })); // quick-260930: Auswahlfenster (Fokus, geloeschtes Bild) braucht Uebersetzung, // Erscheinungsbild und die Bilderliste. vi.mock('next-intl', () => ({ useTranslations: () => (key: string) => key, })); vi.mock('next-themes', () => ({ useTheme: () => ({ resolvedTheme: 'light' }), })); const fetchDashboardImages = vi.fn(); vi.mock('@/lib/dashboard-images-api', () => ({ fetchDashboardImages: (...args: unknown[]) => fetchDashboardImages(...args), uploadDashboardImage: vi.fn(), dashboardImageSrc: (id: string) => `/api-proxy/dashboard/images/${id}`, })); const IMAGE_ID = '11111111-2222-3333-4444-555555555555'; const LEGACY_KEY = 'tessera.dashboardBackground.u1'; function makeUser(overrides: Partial = {}): AuthUser { return { id: 'u1', username: 'schalli', displayName: 'Schalli', role: 'USER', tenantId: 't1', accentColor: null, dashboardBackground: null, ...overrides, }; } async function renderBackgroundHook() { const { useDashboardBackground } = await import('./dashboard-background'); return renderHook(() => useDashboardBackground()); } beforeEach(() => { updateDashboardBackgroundAction.mockReset(); updateDashboardBackgroundAction.mockResolvedValue({ success: true }); useAuthStore.setState({ user: null }); }); afterEach(() => { window.localStorage.clear(); }); describe('useDashboardBackground (quick-260928-ujj)', () => { it('ohne angemeldeten Benutzer: kein Hintergrund, kein Speichern', async () => { const { result } = await renderBackgroundHook(); expect(result.current.background).toEqual({ kind: 'none' }); expect(updateDashboardBackgroundAction).not.toHaveBeenCalled(); }); it('liest die Wahl aus dem Auth-Store; null ergibt „kein Hintergrund“', async () => { useAuthStore.setState({ user: makeUser({ dashboardBackground: { kind: 'preset', id: 'dunes' } }), }); const { result } = await renderBackgroundHook(); expect(result.current.background).toEqual({ kind: 'preset', id: 'dunes' }); act(() => useAuthStore.setState({ user: makeUser({ dashboardBackground: null }) })); expect(result.current.background).toEqual({ kind: 'none' }); }); it('choose() setzt den Store sofort und speichert ueber die Server-Aktion', async () => { useAuthStore.setState({ user: makeUser({ dashboardBackground: { kind: 'none' } }) }); const { result } = await renderBackgroundHook(); act(() => { result.current.choose({ kind: 'preset', id: 'mosaic' }); }); expect(useAuthStore.getState().user?.dashboardBackground).toEqual({ kind: 'preset', id: 'mosaic', }); expect(result.current.background).toEqual({ kind: 'preset', id: 'mosaic' }); expect(updateDashboardBackgroundAction).toHaveBeenCalledWith({ kind: 'preset', id: 'mosaic' }); }); it.each([ [ 'Fehlerantwort', () => updateDashboardBackgroundAction.mockResolvedValue({ success: false, error: 'saveError' }), ], ['Ausnahme', () => updateDashboardBackgroundAction.mockRejectedValue(new Error('offline'))], ])('Speichern schlaegt fehl (%s): der vorige Wert wird zurueckgesetzt', async (_label, arrange) => { arrange(); useAuthStore.setState({ user: makeUser({ dashboardBackground: { kind: 'preset', id: 'dunes' } }), }); const { result } = await renderBackgroundHook(); act(() => { result.current.choose({ kind: 'preset', id: 'mist' }); }); await waitFor(() => expect(result.current.background).toEqual({ kind: 'preset', id: 'dunes' })); expect(useAuthStore.getState().user?.dashboardBackground).toEqual({ kind: 'preset', id: 'dunes', }); }); it('Server-Wert null und alte localStorage-Wahl: wird genau einmal gespeichert, Schluessel entfernt', async () => { window.localStorage.setItem(LEGACY_KEY, JSON.stringify({ kind: 'preset', id: 'pebble' })); useAuthStore.setState({ user: makeUser({ dashboardBackground: null }) }); const { result, rerender } = await renderBackgroundHook(); await waitFor(() => expect(updateDashboardBackgroundAction).toHaveBeenCalledTimes(1)); expect(updateDashboardBackgroundAction).toHaveBeenCalledWith({ kind: 'preset', id: 'pebble' }); expect(result.current.background).toEqual({ kind: 'preset', id: 'pebble' }); expect(window.localStorage.getItem(LEGACY_KEY)).toBeNull(); // erneutes Rendern und ein neues Benutzerobjekt desselben Benutzers loesen nichts mehr aus rerender(); act(() => useAuthStore.setState({ user: makeUser({ dashboardBackground: null }) })); rerender(); expect(updateDashboardBackgroundAction).toHaveBeenCalledTimes(1); }); it('Server-Wert gesetzt: keine Uebernahme, der alte Schluessel wird nur aufgeraeumt', async () => { window.localStorage.setItem(LEGACY_KEY, JSON.stringify({ kind: 'preset', id: 'pebble' })); useAuthStore.setState({ user: makeUser({ dashboardBackground: { kind: 'none' } }) }); const { result } = await renderBackgroundHook(); await waitFor(() => expect(window.localStorage.getItem(LEGACY_KEY)).toBeNull()); expect(updateDashboardBackgroundAction).not.toHaveBeenCalled(); expect(result.current.background).toEqual({ kind: 'none' }); }); it('Server-Wert null ohne alte Wahl: nichts wird gespeichert', async () => { useAuthStore.setState({ user: makeUser({ dashboardBackground: null }) }); const { result } = await renderBackgroundHook(); expect(result.current.background).toEqual({ kind: 'none' }); expect(updateDashboardBackgroundAction).not.toHaveBeenCalled(); }); }); describe('useDashboardBackground — Rueckfall nur fuer den eigenen Wert (quick-260930)', () => { it('scheitert der erste von zwei schnellen Klicks, bleibt die zweite Wahl stehen', async () => { let rejectFirst: (e: Error) => void = () => {}; updateDashboardBackgroundAction .mockImplementationOnce( () => new Promise((_resolve, reject) => { rejectFirst = reject; }), ) .mockResolvedValueOnce({ success: true }); useAuthStore.setState({ user: makeUser({ dashboardBackground: { kind: 'none' } }) }); const { result } = await renderBackgroundHook(); act(() => result.current.choose({ kind: 'preset', id: 'mist' })); act(() => result.current.choose({ kind: 'preset', id: 'dunes' })); await act(async () => { rejectFirst(new Error('offline')); await Promise.resolve(); }); expect(result.current.background).toEqual({ kind: 'preset', id: 'dunes' }); expect(useAuthStore.getState().user?.dashboardBackground).toEqual({ kind: 'preset', id: 'dunes', }); }); }); describe('useDashboardBackground — Bild nicht mehr vorhanden (quick-260930)', () => { afterEach(() => { vi.unstubAllGlobals(); }); it('laesst sich das gewaehlte Bild nicht laden, gilt die Wahl als „kein Hintergrund“ (ohne zu speichern)', async () => { class FailingImage { onerror: (() => void) | null = null; set src(_value: string) { setTimeout(() => this.onerror?.(), 0); } } vi.stubGlobal('Image', FailingImage); useAuthStore.setState({ user: makeUser({ dashboardBackground: { kind: 'image', imageId: IMAGE_ID } }), }); const { result } = await renderBackgroundHook(); await waitFor(() => expect(result.current.background).toEqual({ kind: 'none' })); expect(updateDashboardBackgroundAction).not.toHaveBeenCalled(); }); it('ein ladbares Bild bleibt gewaehlt', async () => { class LoadingImage { onerror: (() => void) | null = null; onload: (() => void) | null = null; set src(_value: string) { setTimeout(() => this.onload?.(), 0); } } vi.stubGlobal('Image', LoadingImage); useAuthStore.setState({ user: makeUser({ dashboardBackground: { kind: 'image', imageId: IMAGE_ID } }), }); const { result } = await renderBackgroundHook(); await act(async () => { await new Promise((r) => setTimeout(r, 5)); }); expect(result.current.background).toEqual({ kind: 'image', imageId: IMAGE_ID }); }); }); describe('BackgroundPicker (quick-260930)', () => { async function renderPicker(value: import('@/lib/dashboard-background').DashboardBackground) { const { BackgroundPicker } = await import('./dashboard-background'); const onChange = vi.fn(); render(); return { onChange, trigger: screen.getByRole('button', { name: 'button' }) }; } beforeEach(() => { fetchDashboardImages.mockReset(); fetchDashboardImages.mockResolvedValue([]); }); it('Oeffnen setzt den Fokus auf die erste Auswahl, Escape schliesst und gibt ihn an den Knopf zurueck', async () => { const { trigger } = await renderPicker({ kind: 'none' }); trigger.focus(); fireEvent.click(trigger); const dialog = await screen.findByRole('dialog'); const first = screen.getByRole('button', { name: 'none' }); expect(dialog).toContainElement(first); expect(first).toHaveFocus(); fireEvent.keyDown(document, { key: 'Escape' }); expect(screen.queryByRole('dialog')).toBeNull(); await waitFor(() => expect(trigger).toHaveFocus()); }); it('Klick nach draussen schliesst und gibt den Fokus an den Knopf zurueck', async () => { const { trigger } = await renderPicker({ kind: 'none' }); fireEvent.click(trigger); await screen.findByRole('dialog'); fireEvent.mouseDown(document.body); expect(screen.queryByRole('dialog')).toBeNull(); await waitFor(() => expect(trigger).toHaveFocus()); }); it('Tab bleibt im Fenster: vom letzten Knopf zum ersten, Umschalt+Tab vom ersten zum letzten', async () => { const { trigger } = await renderPicker({ kind: 'none' }); fireEvent.click(trigger); const dialog = await screen.findByRole('dialog'); const buttons = Array.from(dialog.querySelectorAll('button')); const first = buttons[0]; const last = buttons[buttons.length - 1]; last.focus(); fireEvent.keyDown(document, { key: 'Tab' }); expect(first).toHaveFocus(); fireEvent.keyDown(document, { key: 'Tab', shiftKey: true }); expect(last).toHaveFocus(); }); it('gewaehltes Bild fehlt in der Bilderliste -> „Keiner“ ist markiert', async () => { fetchDashboardImages.mockResolvedValue([ { id: 'anderes', originalName: 'urlaub.jpg', mimeType: 'image/jpeg', size: 1, createdAt: '' }, ]); const { trigger } = await renderPicker({ kind: 'image', imageId: IMAGE_ID }); fireEvent.click(trigger); await screen.findByRole('button', { name: 'urlaub.jpg' }); expect(screen.getByRole('button', { name: 'none' })).toHaveAttribute('aria-pressed', 'true'); expect(screen.getByRole('button', { name: 'urlaub.jpg' })).toHaveAttribute( 'aria-pressed', 'false', ); }); });