Backend: - Add ANNOTATE_ALL permission - Add ANNOTATION_NOT_FOUND and ANNOTATION_OVERLAP error codes - V10 migration: document_annotations table with page/rect/color/owner - DocumentAnnotation entity, AnnotationRepository, CreateAnnotationDTO - AnnotationService: overlap detection (rectangle intersection), ownership enforcement on delete - AnnotationController: GET (authenticated), POST/DELETE (ANNOTATE_ALL) - 15 new tests (AnnotationServiceTest, AnnotationControllerTest) — TDD red/green Frontend: - AnnotationLayer.svelte: pointer-event drawing, colored rect overlays, delete buttons - PdfViewer.svelte: annotate toggle, color picker, loads/saves/deletes annotations via API - Disabled annotate button with tooltip for users without ANNOTATE_ALL - canAnnotate exposed from layout server, passed to PdfViewer - errors.ts + de/en/es translations for new error codes - 3 new unit tests for AnnotationLayer — TDD red/green Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
165 lines
6.7 KiB
TypeScript
165 lines
6.7 KiB
TypeScript
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||
import { cleanup, render } from 'vitest-browser-svelte';
|
||
import { page } from 'vitest/browser';
|
||
import Page from './+page.svelte';
|
||
|
||
vi.mock('$app/navigation', () => ({ goto: vi.fn() }));
|
||
|
||
afterEach(cleanup);
|
||
|
||
// ─── Test data ────────────────────────────────────────────────────────────────
|
||
|
||
const baseData = {
|
||
user: undefined,
|
||
canWrite: true,
|
||
canAnnotate: false,
|
||
documents: [],
|
||
initialValues: { senderName: '', receiverName: '' },
|
||
filters: { senderId: '', receiverId: '', from: '', to: '', dir: 'DESC' as const }
|
||
};
|
||
|
||
const withPersons = {
|
||
...baseData,
|
||
filters: { ...baseData.filters, senderId: 'p1', receiverId: 'p2' }
|
||
};
|
||
|
||
const makeDoc = (overrides: Record<string, unknown> = {}) => ({
|
||
id: 'd1',
|
||
title: 'Testbrief',
|
||
originalFilename: 'testbrief.pdf',
|
||
status: 'UPLOADED' as const,
|
||
documentDate: '1923-04-12',
|
||
location: 'Berlin',
|
||
sender: { id: 'p1', firstName: 'Hans', lastName: 'Müller' },
|
||
receivers: [{ id: 'p2', firstName: 'Anna', lastName: 'Schmidt' }],
|
||
tags: [],
|
||
transcription: undefined,
|
||
filePath: undefined,
|
||
createdAt: '1923-04-12T00:00:00Z',
|
||
updatedAt: '1923-04-12T00:00:00Z',
|
||
...overrides
|
||
});
|
||
|
||
const withDocs = {
|
||
...withPersons,
|
||
documents: [makeDoc()]
|
||
};
|
||
|
||
// ─── Empty state ──────────────────────────────────────────────────────────────
|
||
|
||
describe('Conversations page – empty state', () => {
|
||
it('shows the "select two persons" prompt when no persons are selected', async () => {
|
||
render(Page, { data: baseData });
|
||
await expect.element(page.getByText(/Wählen Sie zwei Personen aus/i)).toBeInTheDocument();
|
||
});
|
||
|
||
it('hides the swap button when no persons are selected', async () => {
|
||
render(Page, { data: baseData });
|
||
// Button is always in the DOM (holds grid column width on desktop) but made invisible
|
||
await expect.element(page.getByTestId('conv-swap-btn')).toHaveClass('invisible');
|
||
});
|
||
|
||
it('does not show the new document link when no persons are selected', async () => {
|
||
render(Page, { data: baseData });
|
||
await expect.element(page.getByTestId('conv-new-doc-link')).not.toBeInTheDocument();
|
||
});
|
||
});
|
||
|
||
// ─── No results ───────────────────────────────────────────────────────────────
|
||
|
||
describe('Conversations page – no results', () => {
|
||
it('shows "no documents found" when both persons are selected but there are no documents', async () => {
|
||
render(Page, { data: withPersons });
|
||
await expect.element(page.getByText(/Keine Dokumente gefunden/i)).toBeInTheDocument();
|
||
});
|
||
});
|
||
|
||
// ─── Swap button ──────────────────────────────────────────────────────────────
|
||
|
||
describe('Conversations page – swap button', () => {
|
||
it('shows the swap button when both persons are selected', async () => {
|
||
render(Page, { data: withPersons });
|
||
await expect.element(page.getByTestId('conv-swap-btn')).not.toHaveClass('invisible');
|
||
});
|
||
|
||
it('calls goto with swapped sender and receiver when clicked', async () => {
|
||
const { goto } = await import('$app/navigation');
|
||
vi.mocked(goto).mockClear();
|
||
render(Page, { data: withPersons });
|
||
document.querySelector<HTMLElement>('[data-testid="conv-swap-btn"]')!.click();
|
||
expect(goto).toHaveBeenCalledWith(expect.stringContaining('senderId=p2'), expect.anything());
|
||
expect(goto).toHaveBeenCalledWith(expect.stringContaining('receiverId=p1'), expect.anything());
|
||
});
|
||
});
|
||
|
||
// ─── Summary ──────────────────────────────────────────────────────────────────
|
||
|
||
describe('Conversations page – summary', () => {
|
||
it('shows document count and year range when documents are loaded', async () => {
|
||
const data = {
|
||
...withPersons,
|
||
documents: [
|
||
makeDoc({ documentDate: '1923-04-12' }),
|
||
makeDoc({ id: 'd2', documentDate: '1965-08-03' })
|
||
]
|
||
};
|
||
render(Page, { data });
|
||
const summary = page.getByTestId('conv-summary');
|
||
await expect.element(summary).toHaveTextContent('2');
|
||
await expect.element(summary).toHaveTextContent('1923');
|
||
await expect.element(summary).toHaveTextContent('1965');
|
||
});
|
||
});
|
||
|
||
// ─── Year dividers ────────────────────────────────────────────────────────────
|
||
|
||
describe('Conversations page – year dividers', () => {
|
||
it('renders a year divider for the first document', async () => {
|
||
render(Page, { data: withDocs });
|
||
await expect.element(page.getByTestId('year-divider').first()).toHaveTextContent('1923');
|
||
});
|
||
|
||
it('renders a divider for each new year in the document list', async () => {
|
||
const data = {
|
||
...withPersons,
|
||
documents: [
|
||
makeDoc({ documentDate: '1923-04-12' }),
|
||
makeDoc({ id: 'd2', documentDate: '1965-08-03' })
|
||
]
|
||
};
|
||
render(Page, { data });
|
||
await expect.element(page.getByTestId('year-divider').first()).toHaveTextContent('1923');
|
||
await expect.element(page.getByTestId('year-divider').nth(1)).toHaveTextContent('1965');
|
||
});
|
||
|
||
it('does not render a second divider for documents from the same year', async () => {
|
||
const data = {
|
||
...withPersons,
|
||
documents: [
|
||
makeDoc({ documentDate: '1923-04-12' }),
|
||
makeDoc({ id: 'd2', documentDate: '1923-09-01' })
|
||
]
|
||
};
|
||
render(Page, { data });
|
||
// Only one divider for 1923; 1965 divider should not appear
|
||
await expect.element(page.getByTestId('year-divider').first()).toHaveTextContent('1923');
|
||
await expect.element(page.getByTestId('year-divider').nth(1)).not.toBeInTheDocument();
|
||
});
|
||
});
|
||
|
||
// ─── New document link ────────────────────────────────────────────────────────
|
||
|
||
describe('Conversations page – new document link', () => {
|
||
it('shows the link with correct href for a write user', async () => {
|
||
render(Page, { data: { ...withDocs, canWrite: true } });
|
||
const link = page.getByTestId('conv-new-doc-link');
|
||
await expect.element(link).toBeInTheDocument();
|
||
await expect.element(link).toHaveAttribute('href', '/documents/new?senderId=p1&receiverId=p2');
|
||
});
|
||
|
||
it('hides the link for a read-only user', async () => {
|
||
render(Page, { data: { ...withDocs, canWrite: false } });
|
||
await expect.element(page.getByTestId('conv-new-doc-link')).not.toBeInTheDocument();
|
||
});
|
||
});
|