diff --git a/apps/api/src/auth/auth.service.spec.ts b/apps/api/src/auth/auth.service.spec.ts index 1bafcfd..fd33767 100644 --- a/apps/api/src/auth/auth.service.spec.ts +++ b/apps/api/src/auth/auth.service.spec.ts @@ -49,6 +49,7 @@ interface FakeUserRow { mustChangePassword: boolean; avatarPath?: string | null; accentColor?: string | null; + dashboardBackground?: unknown; } interface BoundCall { @@ -501,6 +502,8 @@ describe('AuthService.getMe', () => { mustChangePassword: false, avatarPath: 'avatars/u1.png', accentColor: '#3b82f6', + // quick-260928-ujj: gespeicherter Zusatzschluessel wird bei der Ausgabe verworfen. + dashboardBackground: { kind: 'preset', id: 'dunes', extra: 'weg' }, }; const ldapUserRow: FakeUserRow = { @@ -515,6 +518,7 @@ describe('AuthService.getMe', () => { mustChangePassword: false, avatarPath: null, accentColor: null, + dashboardBackground: null, }; beforeEach(() => { @@ -535,6 +539,7 @@ describe('AuthService.getMe', () => { tenantId: 't1', mustChangePassword: false, accentColor: '#3b82f6', + dashboardBackground: { kind: 'preset', id: 'dunes' }, isLocalUser: true, hasAvatar: true, }); @@ -549,6 +554,30 @@ describe('AuthService.getMe', () => { expect(result).toMatchObject({ isLocalUser: false, hasAvatar: false }); }); + it('quick-260928-ujj: dashboardBackground NULL (nie gewaehlt) kommt als null zurueck', async () => { + const result = await service.getMe('t1', 'u2'); + + expect(result).toHaveProperty('dashboardBackground', null); + }); + + it('quick-260928-ujj: ungueltiger gespeicherter Hintergrund kommt als null zurueck (T-ujj-01)', async () => { + prisma.__users.set('u1', { + ...prisma.__users.get('u1'), + dashboardBackground: { kind: 'image', imageId: '") ; background: url("x' }, + }); + + const result = await service.getMe('t1', 'u1'); + + expect(result).toHaveProperty('dashboardBackground', null); + }); + + it('quick-260928-ujj: dashboardBackground steht im select neben accentColor', async () => { + await service.getMe('t1', 'u1'); + + const call = prisma.__boundCallLog.find((c: any) => c.method === 'findUnique'); + expect(call.args.select).toMatchObject({ accentColor: true, dashboardBackground: true }); + }); + it('FREMDER Mandant (Klient unter t2, Zeile unter t1): liefert null, kein Fehler', async () => { const result = await service.getMe('t2', 'u1'); diff --git a/apps/api/src/user/user.controller.spec.ts b/apps/api/src/user/user.controller.spec.ts index fd2ab74..0ca46e3 100644 --- a/apps/api/src/user/user.controller.spec.ts +++ b/apps/api/src/user/user.controller.spec.ts @@ -580,4 +580,87 @@ describe('UserController', () => { }); }); }); + + /** + * quick-260928-ujj: Dashboard-Hintergrund pro Benutzer in der Datenbank. + * Der Selbstbedienungsweg schreibt ausschliesslich die Zeile des + * angemeldeten Benutzers ueber den an dessen Mandanten gebundenen Klienten + * (T-ujj-02) und nimmt nur die erlaubten Formen an (T-ujj-01): gespeichert + * wird immer ein frisch aufgebautes Objekt ohne Zusatzschluessel. + */ + describe('Dashboard-Hintergrund (quick-260928-ujj)', () => { + const me = { role: Role.USER, tenantId: 't1', id: 'u-me', username: 'me', mustChangePassword: false }; + const IMAGE_ID = '3f2b8c1e-9a4d-4e7f-8b21-0c5d6e7f8a9b'; + + function seedMe(tenantId = 't1', id = 'u-me') { + prisma.__seedUser({ id, username: id, tenantId, dashboardBackground: null }); + } + + async function storedFor(tenantId: string, id: string) { + const row = await prisma.__makeBoundClient(tenantId).user.findUnique({ where: { id } }); + return row.dashboardBackground; + } + + it.each([ + [{ kind: 'none' }, { kind: 'none' }], + [{ kind: 'preset', id: 'dunes' }, { kind: 'preset', id: 'dunes' }], + [{ kind: 'preset', id: 'mosaic', extra: 'weg' }, { kind: 'preset', id: 'mosaic' }], + [{ kind: 'image', imageId: IMAGE_ID }, { kind: 'image', imageId: IMAGE_ID }], + [{ kind: 'image', imageId: IMAGE_ID.toUpperCase(), id: 'mist' }, { kind: 'image', imageId: IMAGE_ID.toUpperCase() }], + [{ kind: 'none', id: 'dunes', imageId: IMAGE_ID }, { kind: 'none' }], + ])('%j wird normalisiert als %j gespeichert, gebunden an den eigenen Mandanten', async (input, expected) => { + seedMe(); + + const result = await controller.updateDashboardBackground({ background: input }, me); + + expect(result).toEqual({ success: true, dashboardBackground: expected }); + expect(await storedFor('t1', 'u-me')).toEqual(expected); + expectBoundCall(prisma, 't1', 'user', 'update'); + expect(prisma.__boundCallLog.filter((c: any) => c.method === 'update').every((c: any) => c.tenantId === 't1')).toBe(true); + }); + + it.each([ + [{ kind: 'preset', id: 'regenbogen' }], + [{ kind: 'preset' }], + [{ kind: 'preset', id: 42 }], + [{ kind: 'gradient', id: 'mist' }], + [{ kind: 'image', imageId: 'img-1' }], + [{ kind: 'image', imageId: '") ; background: url("https://boese.invalid/x' }], + [{ kind: 'image', imageId: `${IMAGE_ID})` }], + [{ kind: 'image', imageId: `${IMAGE_ID}0` }], + [{ kind: 'image', imageId: '' }], + [{ kind: 'image' }], + [{}], + [null], + [undefined], + ['none'], + [42], + [[{ kind: 'none' }]], + ])('ungueltige Wahl %j → BadRequestException, nichts geschrieben', async (background) => { + seedMe(); + + await expect(controller.updateDashboardBackground({ background }, me)).rejects.toBeInstanceOf(BadRequestException); + expect(prisma.__boundCallLog.some((c: any) => c.method === 'update')).toBe(false); + expect(await storedFor('t1', 'u-me')).toBeNull(); + }); + + it('fehlender Rumpf → BadRequestException', async () => { + seedMe(); + + await expect( + controller.updateDashboardBackground(undefined as unknown as { background: unknown }, me), + ).rejects.toBeInstanceOf(BadRequestException); + expect(prisma.__boundCallLog.some((c: any) => c.method === 'update')).toBe(false); + }); + + it('zwei Benutzer in zwei Mandanten: nur die Zeile des Anfragenden aendert sich', async () => { + seedMe('t1', 'u-me'); + seedMe('t2', 'u-other'); + + await controller.updateDashboardBackground({ background: { kind: 'preset', id: 'mist' } }, me); + + expect(await storedFor('t1', 'u-me')).toEqual({ kind: 'preset', id: 'mist' }); + expect(await storedFor('t2', 'u-other')).toBeNull(); + }); + }); }); diff --git a/apps/web/src/components/dashboard/dashboard-background.test.tsx b/apps/web/src/components/dashboard/dashboard-background.test.tsx new file mode 100644 index 0000000..6bf2943 --- /dev/null +++ b/apps/web/src/components/dashboard/dashboard-background.test.tsx @@ -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 { + 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(); + }); +}); diff --git a/apps/web/src/components/layout/header.test.tsx b/apps/web/src/components/layout/header.test.tsx index e99e1e3..5b38aca 100644 --- a/apps/web/src/components/layout/header.test.tsx +++ b/apps/web/src/components/layout/header.test.tsx @@ -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(''); }); diff --git a/apps/web/src/lib/dashboard-background.test.ts b/apps/web/src/lib/dashboard-background.test.ts index ddbf377..77b24a3 100644 --- a/apps/web/src/lib/dashboard-background.test.ts +++ b/apps/web/src/lib/dashboard-background.test.ts @@ -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)', () => {