From 0fdf46635b81501803d9f18f689d36070c10537a Mon Sep 17 00:00:00 2001 From: Sandro Date: Thu, 29 Oct 2026 15:03:54 +0100 Subject: [PATCH] test(frontend): task dialog, sharing avatars, trash, API key scopes, passwords (FLAW-19) Covers opening and closing the task dialog (the former hook-order bug), date/time fields and the view-only mode for viewer lists, the avatar stack of shared items, the two-tap delete in the trash, scope selection and badges of API keys, and the shared password rule. Co-Authored-By: Claude Opus 5.5 --- .../components/__tests__/AvatarStack.test.tsx | 22 ++++++ .../settings/__tests__/ApiKeysPanel.test.tsx | 41 +++++++++++ .../triage/__tests__/TaskDetailModal.test.tsx | 70 +++++++++++++++++++ .../workspace/__tests__/TrashView.test.tsx | 30 ++++++++ frontend/src/lib/__tests__/password.test.ts | 11 +++ 5 files changed, 174 insertions(+) create mode 100644 frontend/src/components/__tests__/AvatarStack.test.tsx create mode 100644 frontend/src/features/settings/__tests__/ApiKeysPanel.test.tsx create mode 100644 frontend/src/features/triage/__tests__/TaskDetailModal.test.tsx create mode 100644 frontend/src/features/workspace/__tests__/TrashView.test.tsx create mode 100644 frontend/src/lib/__tests__/password.test.ts diff --git a/frontend/src/components/__tests__/AvatarStack.test.tsx b/frontend/src/components/__tests__/AvatarStack.test.tsx new file mode 100644 index 0000000..118f777 --- /dev/null +++ b/frontend/src/components/__tests__/AvatarStack.test.tsx @@ -0,0 +1,22 @@ +import { describe, it, expect } from 'vitest'; +import { render } from '@testing-library/react'; +import { AvatarStack } from '../AvatarStack'; + +const people = ['Anna', 'Ben', 'Carla', 'Dana', 'Emil'].map((name, i) => ({ id: `u${i}`, name })); + +describe('AvatarStack (who a list or note is shared with)', () => { + it('shows at most three people and the rest as +n', () => { + const { container } = render(); + expect(container.textContent).toContain('+2'); + expect(container.firstElementChild?.children.length).toBe(4); // 3 avatars + overflow circle + }); + + it('renders nothing for an unshared item', () => { + expect(render().container.firstChild).toBeNull(); + }); + + it('needs no overflow circle when everyone fits', () => { + const { container } = render(); + expect(container.textContent).not.toContain('+'); + }); +}); diff --git a/frontend/src/features/settings/__tests__/ApiKeysPanel.test.tsx b/frontend/src/features/settings/__tests__/ApiKeysPanel.test.tsx new file mode 100644 index 0000000..84bbf64 --- /dev/null +++ b/frontend/src/features/settings/__tests__/ApiKeysPanel.test.tsx @@ -0,0 +1,41 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent, act } from '@testing-library/react'; + +const createAPIKey = vi.fn(async () => ({ id: 'k1', userId: 'u', name: 'HA', keyPrefix: 'mindsift_sec_ab', scopes: 'webhook:triage,webhook:list', createdAt: '' })); +vi.mock('../../../lib/api', () => ({ + api: { + getAPIKeys: vi.fn(async () => [ + { id: 'k0', userId: 'u', name: 'Rauchmelder', keyPrefix: 'mindsift_sec_xy', scopes: 'webhook:triage,webhook:task', createdAt: '' }, + ]), + getAllLists: vi.fn(async () => []), + getTaskLists: vi.fn(async () => []), + createAPIKey: (...args: unknown[]) => createAPIKey(...(args as [])), + deleteAPIKey: vi.fn(), + testWebhookIngest: vi.fn(), + }, + triggerHaptic: vi.fn(), +})); + +import { ApiKeysPanel } from '../ApiKeysPanel'; + +describe('ApiKeysPanel', () => { + beforeEach(() => createAPIKey.mockClear()); + + it('shows the scopes of existing keys as badges', async () => { + render(); + expect(await screen.findByText('Rauchmelder')).toBeTruthy(); + expect(screen.getAllByText('📋 Aufgaben').length).toBeGreaterThan(1); // chip + badge + }); + + it('creates a key with the chosen scopes and never without one', async () => { + render(); + const triage = screen.getByRole('checkbox', { name: /Triage/ }); + fireEvent.click(triage); // last remaining scope cannot be switched off + expect(triage.getAttribute('aria-checked')).toBe('true'); + fireEvent.click(screen.getByRole('checkbox', { name: /Listeneinträge/ })); + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: /API-Key erzeugen/ })); + }); + expect(createAPIKey).toHaveBeenCalledWith('Home Assistant', ['webhook:triage', 'webhook:list']); + }); +}); diff --git a/frontend/src/features/triage/__tests__/TaskDetailModal.test.tsx b/frontend/src/features/triage/__tests__/TaskDetailModal.test.tsx new file mode 100644 index 0000000..6a0c10d --- /dev/null +++ b/frontend/src/features/triage/__tests__/TaskDetailModal.test.tsx @@ -0,0 +1,70 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { TaskDetailModal } from '../TaskDetailModal'; +import { Thought } from '../../../lib/types'; + +const task: Thought = { + id: 'th_1', + userId: 'usr_1', + content: 'Fenster putzen', + tags: [], + priority: 'normal', + source: 'manual', + status: 'converted', + isCompleted: false, + kanbanStatus: 'todo', + dueDate: '2026-10-30T14:30', + createdAt: '2026-10-29T08:00:00Z', +}; +const columns = [ + { id: 'todo', name: 'Zu erledigen' }, + { id: 'done', name: 'Erledigt', isDone: true }, +]; +const noop = vi.fn(async () => {}); + +const props = (over: Partial> = {}) => ({ + task, + isOpen: true, + onClose: vi.fn(), + activeColumns: columns, + taskLists: [], + onUpdateTitle: noop, + onUpdateStatus: noop, + onUpdatePriority: noop, + onUpdateDueDate: vi.fn(async () => {}), + onUpdateTaskList: noop, + onUpdateNotes: noop, + onToggleSubtask: noop, + onAddSubtask: noop, + onDeleteSubtask: noop, + onConvertToDoc: vi.fn(), + onDeleteTask: vi.fn(), + ...over, +}); + +describe('TaskDetailModal', () => { + it('opens and closes without breaking the hook order', () => { + const { rerender, container } = render(); + expect(container.firstChild).toBeNull(); + rerender(); + expect(screen.getByText('Aufgabe bearbeiten')).toBeTruthy(); + rerender(); + rerender(); + expect(screen.getByText('Aufgabe bearbeiten')).toBeTruthy(); + }); + + it('shows date and time separately and removes only the time', () => { + const onUpdateDueDate = vi.fn(async () => {}); + render(); + expect((screen.getByLabelText('Fälligkeitsdatum') as HTMLInputElement).value).toBe('2026-10-30'); + expect((screen.getByLabelText('Uhrzeit (optional)') as HTMLInputElement).value).toBe('14:30'); + fireEvent.click(screen.getByTitle(/Uhrzeit entfernen/)); + expect(onUpdateDueDate).toHaveBeenCalledWith(task, '2026-10-30'); + }); + + it('is view-only for tasks in lists shared with viewer rights', () => { + render(); + expect(screen.getByText('Aufgabe ansehen')).toBeTruthy(); + expect((screen.getByLabelText('Fälligkeitsdatum') as HTMLInputElement).closest('fieldset')?.disabled).toBe(true); + }); +}); diff --git a/frontend/src/features/workspace/__tests__/TrashView.test.tsx b/frontend/src/features/workspace/__tests__/TrashView.test.tsx new file mode 100644 index 0000000..21cb3f9 --- /dev/null +++ b/frontend/src/features/workspace/__tests__/TrashView.test.tsx @@ -0,0 +1,30 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, fireEvent, act } from '@testing-library/react'; +import { TrashView } from '../TrashView'; + +const items = [ + { id: 'doc_1', title: 'Urlaub', icon: '🏔️', folderPath: 'Privat', deletedAt: '2026-10-29T08:00:00Z', isOwner: true }, +]; + +describe('TrashView', () => { + it('deletes for good only on the second tap', async () => { + const onPurge = vi.fn(async () => {}); + render( {})} onPurge={onPurge} onEmpty={vi.fn(async () => {})} onBackMobile={vi.fn()} />); + fireEvent.click(screen.getByText('Endgültig löschen')); + expect(onPurge).not.toHaveBeenCalled(); + await act(async () => { + fireEvent.click(screen.getByText('Wirklich löschen?')); + }); + expect(onPurge).toHaveBeenCalledWith('doc_1'); + }); + + it('restores with one tap and shows the original folder', async () => { + const onRestore = vi.fn(async () => {}); + render( {})} onEmpty={vi.fn(async () => {})} onBackMobile={vi.fn()} />); + expect(screen.getByText('Privat')).toBeTruthy(); + await act(async () => { + fireEvent.click(screen.getByText('Wiederherstellen')); + }); + expect(onRestore).toHaveBeenCalledWith('doc_1'); + }); +}); diff --git a/frontend/src/lib/__tests__/password.test.ts b/frontend/src/lib/__tests__/password.test.ts new file mode 100644 index 0000000..1ad33c0 --- /dev/null +++ b/frontend/src/lib/__tests__/password.test.ts @@ -0,0 +1,11 @@ +import { describe, it, expect } from 'vitest'; +import { passwordProblem } from '../password'; + +describe('passwordProblem (same rule as the server)', () => { + it('needs 8 characters, counted as characters', () => { + expect(passwordProblem('kurz')).toMatch(/mindestens 8 Zeichen/); + expect(passwordProblem(' ')).toMatch(/mindestens 8 Zeichen/); + expect(passwordProblem('geheim12')).toBeNull(); + expect(passwordProblem('äöüßäöüß')).toBeNull(); + }); +});