diff --git a/__tests__/actions/user-settings.test.ts b/__tests__/actions/user-settings.test.ts new file mode 100644 index 0000000..1fb53ad --- /dev/null +++ b/__tests__/actions/user-settings.test.ts @@ -0,0 +1,82 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +vi.mock('next/headers', () => ({ cookies: vi.fn().mockResolvedValue({}) })) +vi.mock('iron-session', () => ({ + getIronSession: vi.fn().mockResolvedValue({ userId: 'user-1', isDemo: false }), +})) +vi.mock('@/lib/session', () => ({ + sessionOptions: { cookieName: 'test', password: 'test' }, +})) +vi.mock('@/lib/prisma', () => ({ + prisma: { + user: { findUnique: vi.fn() }, + $transaction: vi.fn(async (fn: (tx: unknown) => Promise) => { + return fn({ + user: { + findUnique: vi.fn().mockResolvedValue({ settings: {} }), + update: vi.fn().mockResolvedValue({}), + }, + }) + }), + $executeRaw: vi.fn().mockResolvedValue(1), + }, +})) + +import { prisma } from '@/lib/prisma' +import { getIronSession } from 'iron-session' +import { updateUserSettingsAction } from '@/actions/user-settings' + +const mockPrisma = prisma as unknown as { + user: { findUnique: ReturnType } + $transaction: ReturnType + $executeRaw: ReturnType +} +const mockGetIronSession = getIronSession as ReturnType + +beforeEach(() => { + vi.clearAllMocks() + mockGetIronSession.mockResolvedValue({ userId: 'user-1', isDemo: false }) + mockPrisma.$executeRaw.mockResolvedValue(1) +}) + +describe('updateUserSettingsAction', () => { + it('returns 401 when not logged in', async () => { + mockGetIronSession.mockResolvedValue({ userId: undefined, isDemo: false }) + const result = await updateUserSettingsAction({}) + expect(result).toEqual({ error: 'Niet ingelogd', code: 401 }) + }) + + it('returns 403 for demo accounts', async () => { + mockGetIronSession.mockResolvedValue({ userId: 'user-1', isDemo: true }) + const result = await updateUserSettingsAction({}) + expect('error' in result && result.code).toBe(403) + }) + + it('returns 422 when patch is invalid', async () => { + const result = await updateUserSettingsAction({ + views: { sprintBacklog: { filterStatus: 'NONSENSE' } }, + } as never) + expect('error' in result && result.code).toBe(422) + }) + + it('merges with current settings and emits notify on success', async () => { + const existingFindUnique = vi.fn().mockResolvedValue({ + settings: { views: { sprintBacklog: { sort: 'code' } } }, + }) + const update = vi.fn().mockResolvedValue({}) + mockPrisma.$transaction.mockImplementationOnce(async (fn: (tx: unknown) => Promise) => { + return fn({ user: { findUnique: existingFindUnique, update } }) + }) + + const result = await updateUserSettingsAction({ + views: { sprintBacklog: { sortDir: 'desc' } }, + }) + + expect('success' in result && result.success).toBe(true) + expect(update).toHaveBeenCalledWith({ + where: { id: 'user-1' }, + data: { settings: { views: { sprintBacklog: { sort: 'code', sortDir: 'desc' } } } }, + }) + expect(mockPrisma.$executeRaw).toHaveBeenCalled() + }) +}) diff --git a/__tests__/lib/user-settings.test.ts b/__tests__/lib/user-settings.test.ts new file mode 100644 index 0000000..019cc33 --- /dev/null +++ b/__tests__/lib/user-settings.test.ts @@ -0,0 +1,115 @@ +import { describe, expect, it } from 'vitest' + +import { + DEFAULT_USER_SETTINGS, + UserSettingsSchema, + mergeSettings, + parseUserSettings, + type UserSettings, +} from '@/lib/user-settings' + +describe('mergeSettings', () => { + it('returns the patch when previous is empty', () => { + const result = mergeSettings({}, { views: { sprintBacklog: { sort: 'code' } } }) + expect(result).toEqual({ views: { sprintBacklog: { sort: 'code' } } }) + }) + + it('preserves existing keys when patch only sets new ones', () => { + const prev: UserSettings = { views: { sprintBacklog: { sort: 'code' } } } + const result = mergeSettings(prev, { + views: { pbiList: { sort: 'date' } }, + }) + expect(result).toEqual({ + views: { + sprintBacklog: { sort: 'code' }, + pbiList: { sort: 'date' }, + }, + }) + }) + + it('merges nested objects without overwriting siblings', () => { + const prev: UserSettings = { + views: { sprintBacklog: { sort: 'code', sortDir: 'asc' } }, + } + const result = mergeSettings(prev, { + views: { sprintBacklog: { sort: 'priority' } }, + }) + expect(result).toEqual({ + views: { sprintBacklog: { sort: 'priority', sortDir: 'asc' } }, + }) + }) + + it('replaces arrays instead of appending', () => { + const prev: UserSettings = { + views: { sprintBacklog: { collapsedPbis: ['a', 'b'] } }, + } + const result = mergeSettings(prev, { + views: { sprintBacklog: { collapsedPbis: ['c'] } }, + }) + expect(result.views?.sprintBacklog?.collapsedPbis).toEqual(['c']) + }) + + it('does not mutate the previous object', () => { + const prev: UserSettings = { views: { sprintBacklog: { sort: 'code' } } } + const snapshot = JSON.parse(JSON.stringify(prev)) + mergeSettings(prev, { views: { sprintBacklog: { sortDir: 'desc' } } }) + expect(prev).toEqual(snapshot) + }) + + it('skips undefined values in the patch', () => { + const prev: UserSettings = { views: { sprintBacklog: { sort: 'code' } } } + const result = mergeSettings(prev, { views: undefined }) + expect(result).toEqual(prev) + }) +}) + +describe('parseUserSettings', () => { + it('returns defaults for null', () => { + expect(parseUserSettings(null)).toEqual(DEFAULT_USER_SETTINGS) + }) + + it('returns defaults for undefined', () => { + expect(parseUserSettings(undefined)).toEqual(DEFAULT_USER_SETTINGS) + }) + + it('returns defaults for invalid input', () => { + expect(parseUserSettings({ views: { sprintBacklog: { filterStatus: 'BOGUS' } } })) + .toEqual(DEFAULT_USER_SETTINGS) + }) + + it('passes valid settings through', () => { + const valid = { views: { sprintBacklog: { sort: 'code' as const } } } + expect(parseUserSettings(valid)).toEqual(valid) + }) +}) + +describe('UserSettingsSchema', () => { + it('rejects unknown top-level keys', () => { + const result = UserSettingsSchema.safeParse({ unknown: 1 }) + expect(result.success).toBe(false) + }) + + it('accepts an empty object', () => { + expect(UserSettingsSchema.safeParse({}).success).toBe(true) + }) + + it('accepts the full shape', () => { + const result = UserSettingsSchema.safeParse({ + views: { + sprintBacklog: { + filterPriority: 1, + filterStatus: 'OPEN', + sort: 'code', + sortDir: 'asc', + collapsedPbis: ['x'], + filterPopoverOpen: true, + }, + pbiList: { sort: 'priority', filterPriority: 'all', filterStatus: 'ready', sortDir: 'desc' }, + storyPanel: { sort: 'date' }, + jobsColumns: { 'queue:active': { kinds: ['TASK_IMPLEMENTATION'], statuses: [] } }, + }, + devTools: { debugMode: true }, + }) + expect(result.success).toBe(true) + }) +}) diff --git a/__tests__/stores/user-settings.test.ts b/__tests__/stores/user-settings.test.ts new file mode 100644 index 0000000..019d618 --- /dev/null +++ b/__tests__/stores/user-settings.test.ts @@ -0,0 +1,105 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const updateAction = vi.fn() + +vi.mock('@/actions/user-settings', () => ({ + updateUserSettingsAction: (...args: unknown[]) => updateAction(...args), +})) + +import { useUserSettingsStore } from '@/stores/user-settings/store' + +function resetStore() { + useUserSettingsStore.setState((s) => { + s.entities.settings = {} + s.context.hydrated = false + s.context.isDemo = false + s.pendingMutations = {} + }) +} + +beforeEach(() => { + resetStore() + updateAction.mockReset() +}) + +afterEach(() => { + resetStore() +}) + +describe('useUserSettingsStore', () => { + it('hydrate sets entities and context', () => { + useUserSettingsStore.getState().hydrate( + { views: { sprintBacklog: { sort: 'code' } } }, + false, + ) + const s = useUserSettingsStore.getState() + expect(s.entities.settings.views?.sprintBacklog?.sort).toBe('code') + expect(s.context.hydrated).toBe(true) + expect(s.context.isDemo).toBe(false) + }) + + it('setPref updates state optimistically and settles on success', async () => { + useUserSettingsStore.getState().hydrate({}, false) + updateAction.mockResolvedValueOnce({ + success: true, + settings: { views: { sprintBacklog: { filterStatus: 'all' } } }, + }) + + await useUserSettingsStore + .getState() + .setPref(['views', 'sprintBacklog', 'filterStatus'], 'all') + + const s = useUserSettingsStore.getState() + expect(s.entities.settings.views?.sprintBacklog?.filterStatus).toBe('all') + expect(Object.keys(s.pendingMutations)).toHaveLength(0) + expect(updateAction).toHaveBeenCalledWith({ + views: { sprintBacklog: { filterStatus: 'all' } }, + }) + }) + + it('setPref rolls back on server error', async () => { + useUserSettingsStore.getState().hydrate( + { views: { sprintBacklog: { sort: 'code' } } }, + false, + ) + updateAction.mockResolvedValueOnce({ error: 'boom', code: 422 }) + + await useUserSettingsStore + .getState() + .setPref(['views', 'sprintBacklog', 'sort'], 'priority') + + const s = useUserSettingsStore.getState() + expect(s.entities.settings.views?.sprintBacklog?.sort).toBe('code') + expect(Object.keys(s.pendingMutations)).toHaveLength(0) + }) + + it('setPref skips server-call for demo accounts', async () => { + useUserSettingsStore.getState().hydrate({}, true) + + await useUserSettingsStore + .getState() + .setPref(['devTools', 'debugMode'], true) + + const s = useUserSettingsStore.getState() + expect(s.entities.settings.devTools?.debugMode).toBe(true) + expect(updateAction).not.toHaveBeenCalled() + }) + + it('applyServerPatch merges without optimistic state', () => { + useUserSettingsStore.getState().hydrate( + { views: { sprintBacklog: { sort: 'code' } } }, + false, + ) + + useUserSettingsStore.getState().applyServerPatch({ + views: { sprintBacklog: { sortDir: 'desc' } }, + }) + + const s = useUserSettingsStore.getState() + expect(s.entities.settings.views?.sprintBacklog).toEqual({ + sort: 'code', + sortDir: 'desc', + }) + expect(Object.keys(s.pendingMutations)).toHaveLength(0) + }) +})