test(260928-ujj): rote Tests fuer Dashboard-Hintergrund in der Datenbank

- PATCH me/dashboard-background: Allowlist, UUID-Bildkennung, Mandantenbindung
- getMe liefert dashboardBackground normalisiert
- Web: Uebernahme der alten localStorage-Wahl, Hook liest aus dem Auth-Store

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-28 22:08:00 +02:00
parent 9fa0a3f498
commit 76f6d87973
5 changed files with 299 additions and 31 deletions
@@ -0,0 +1,129 @@
import { act, renderHook, 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),
}));
const LEGACY_KEY = 'tessera.dashboardBackground.u1';
function makeUser(overrides: Partial<AuthUser> = {}): 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();
});
});
@@ -103,6 +103,7 @@ describe('Header — Sitzungswaechter (quick-260917-gyd)', () => {
tenantId: 't1',
hasAvatar: false,
accentColor: null,
dashboardBackground: { kind: 'preset', id: 'dunes' },
},
});
@@ -111,6 +112,8 @@ describe('Header — Sitzungswaechter (quick-260917-gyd)', () => {
await waitFor(() =>
expect(useAuthStore.getState().user?.username).toBe('schalli'),
);
// quick-260928-ujj: der Hintergrund kommt mit derselben Sitzungsantwort wie accentColor.
expect(useAuthStore.getState().user?.dashboardBackground).toEqual({ kind: 'preset', id: 'dunes' });
expect((window as any).location.href).toBe('');
});
+55 -31
View File
@@ -1,48 +1,72 @@
import { DASHBOARD_BACKGROUND_PRESET_IDS } from '@tessera/shared';
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
BACKGROUND_PRESETS,
DARK_FALLBACK_ID,
loadDashboardBackground,
presetBackground,
saveDashboardBackground,
takeLegacyDashboardBackground,
} from './dashboard-background';
const IMAGE_ID = '3f2b8c1e-9a4d-4e7f-8b21-0c5d6e7f8a9b';
afterEach(() => {
window.localStorage.clear();
vi.restoreAllMocks();
});
describe('dashboard-background (Design „Mosaik“, Prototyp)', () => {
it('ohne gespeicherte Wahl: kein Hintergrund', () => {
expect(loadDashboardBackground('u1')).toEqual({ kind: 'none' });
});
it('speichert je Benutzer getrennt', () => {
saveDashboardBackground('u1', { kind: 'preset', id: 'bloom' });
saveDashboardBackground('u2', { kind: 'image', imageId: 'img-1' });
expect(loadDashboardBackground('u1')).toEqual({ kind: 'preset', id: 'bloom' });
expect(loadDashboardBackground('u2')).toEqual({ kind: 'image', imageId: 'img-1' });
});
it('verwirft kaputte oder unbekannte Werte', () => {
window.localStorage.setItem('tessera.dashboardBackground.u1', '{kaputt');
expect(loadDashboardBackground('u1')).toEqual({ kind: 'none' });
window.localStorage.setItem(
'tessera.dashboardBackground.u1',
JSON.stringify({ kind: 'preset', id: 'regenbogen' }),
);
expect(loadDashboardBackground('u1')).toEqual({ kind: 'none' });
});
it('gesperrter Speicher wirft nicht', () => {
vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => {
throw new Error('blocked');
describe('dashboard-background (Design „Mosaik“)', () => {
describe('takeLegacyDashboardBackground (quick-260928-ujj)', () => {
it('ohne alte Wahl: null', () => {
expect(takeLegacyDashboardBackground('u1')).toBeNull();
});
vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
throw new Error('blocked');
it('liefert eine gueltige alte Wahl je Benutzer und entfernt den Schluessel', () => {
window.localStorage.setItem(
'tessera.dashboardBackground.u1',
JSON.stringify({ kind: 'preset', id: 'bloom' }),
);
window.localStorage.setItem(
'tessera.dashboardBackground.u2',
JSON.stringify({ kind: 'image', imageId: IMAGE_ID }),
);
expect(takeLegacyDashboardBackground('u1')).toEqual({ kind: 'preset', id: 'bloom' });
expect(window.localStorage.getItem('tessera.dashboardBackground.u1')).toBeNull();
// der Schluessel des anderen Benutzers bleibt, bis dieser sich anmeldet
expect(window.localStorage.getItem('tessera.dashboardBackground.u2')).not.toBeNull();
expect(takeLegacyDashboardBackground('u2')).toEqual({ kind: 'image', imageId: IMAGE_ID });
// zweiter Aufruf: nichts mehr da
expect(takeLegacyDashboardBackground('u1')).toBeNull();
});
expect(() => saveDashboardBackground('u1', { kind: 'preset', id: 'mist' })).not.toThrow();
expect(loadDashboardBackground('u1')).toEqual({ kind: 'none' });
it.each([
['{kaputt'],
[JSON.stringify({ kind: 'preset', id: 'regenbogen' })],
// Prototyp-Werte mit beliebiger Bildkennung werden nicht uebernommen
[JSON.stringify({ kind: 'image', imageId: 'img-1' })],
[JSON.stringify(null)],
])('ungueltiger Wert %j: null, Schluessel entfernt', (raw) => {
window.localStorage.setItem('tessera.dashboardBackground.u1', raw);
expect(takeLegacyDashboardBackground('u1')).toBeNull();
expect(window.localStorage.getItem('tessera.dashboardBackground.u1')).toBeNull();
});
it('gesperrter Speicher: null ohne Ausnahme', () => {
vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => {
throw new Error('blocked');
});
vi.spyOn(Storage.prototype, 'removeItem').mockImplementation(() => {
throw new Error('blocked');
});
expect(() => takeLegacyDashboardBackground('u1')).not.toThrow();
expect(takeLegacyDashboardBackground('u1')).toBeNull();
});
});
it('die eingebauten Hintergruende sind deckungsgleich mit der Pruefregel in @tessera/shared', () => {
expect(BACKGROUND_PRESETS.map((p) => p.id).sort()).toEqual([...DASHBOARD_BACKGROUND_PRESET_IDS].sort());
});
it('jeder eingebaute Hintergrund liefert hell und dunkel ein Bild (dunkel notfalls den Ersatz)', () => {