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();
+ });
+});