diff --git a/frontend/e2e/persons.spec.ts b/frontend/e2e/persons.spec.ts index 2fa49755..12f7d2f0 100644 --- a/frontend/e2e/persons.spec.ts +++ b/frontend/e2e/persons.spec.ts @@ -211,3 +211,84 @@ test.describe('Conversations', () => { await page.screenshot({ path: 'test-results/e2e/conversations-sort.png' }); }); }); + +test.describe('Conversations — enhancements', () => { + // Hans→Anna (1923) and Anna→Hans (1965) are seeded in DataInitializer + // Navigate directly by URL so the test doesn't rely on typeahead interaction + async function loadHansAnnaConversation(page: import('@playwright/test').Page) { + // Resolve person IDs from the persons list + await page.goto('/persons'); + const hansLink = page.getByRole('link', { name: /Hans Müller/ }).first(); + const hansHref = await hansLink.getAttribute('href'); + const hansId = hansHref!.split('/').pop()!; + + const annaLink = page.getByRole('link', { name: /Anna Schmidt/ }).first(); + const annaHref = await annaLink.getAttribute('href'); + const annaId = annaHref!.split('/').pop()!; + + await page.goto(`/conversations?senderId=${hansId}&receiverId=${annaId}`); + await page.waitForURL(/senderId=/); + } + + test('shows document count and year range summary when both persons are selected', async ({ + page + }) => { + await loadHansAnnaConversation(page); + // Hans→Anna (1923) + Anna→Hans (1965) = 2 documents, range 1923–1965 + await expect(page.getByTestId('conv-summary')).toContainText('2'); + await expect(page.getByTestId('conv-summary')).toContainText('1923'); + await expect(page.getByTestId('conv-summary')).toContainText('1965'); + await page.screenshot({ path: 'test-results/e2e/conversations-summary.png' }); + }); + + test('shows year dividers between documents from different years', async ({ page }) => { + await loadHansAnnaConversation(page); + // Expect at least two year dividers (1923 and 1965) + await expect(page.getByTestId('year-divider').first()).toBeVisible(); + const dividers = page.getByTestId('year-divider'); + const texts = await dividers.allTextContents(); + expect(texts.some((t) => t.includes('1923'))).toBe(true); + expect(texts.some((t) => t.includes('1965'))).toBe(true); + await page.screenshot({ path: 'test-results/e2e/conversations-year-dividers.png' }); + }); + + test('swap button switches sender and receiver and reloads', async ({ page }) => { + await loadHansAnnaConversation(page); + const url = new URL(page.url()); + const originalSenderId = url.searchParams.get('senderId')!; + const originalReceiverId = url.searchParams.get('receiverId')!; + + await page.getByTestId('conv-swap-btn').click(); + await page.waitForURL(/senderId=/); + + const swappedUrl = new URL(page.url()); + expect(swappedUrl.searchParams.get('senderId')).toBe(originalReceiverId); + expect(swappedUrl.searchParams.get('receiverId')).toBe(originalSenderId); + await page.screenshot({ path: 'test-results/e2e/conversations-swap.png' }); + }); + + test('shows "new document" link pre-filled with both persons when conversation is loaded', async ({ + page + }) => { + await loadHansAnnaConversation(page); + const url = new URL(page.url()); + const senderId = url.searchParams.get('senderId')!; + const receiverId = url.searchParams.get('receiverId')!; + + const link = page.getByTestId('conv-new-doc-link'); + await expect(link).toBeVisible(); + const href = await link.getAttribute('href'); + expect(href).toContain(`senderId=${senderId}`); + expect(href).toContain(`receiverId=${receiverId}`); + await page.screenshot({ path: 'test-results/e2e/conversations-new-doc-link.png' }); + }); + + test('does not show swap button or new document link when only one person is selected', async ({ + page + }) => { + await page.goto('/conversations'); + await page.waitForURL('/conversations'); + await expect(page.getByTestId('conv-swap-btn')).not.toBeVisible(); + await expect(page.getByTestId('conv-new-doc-link')).not.toBeVisible(); + }); +}); diff --git a/frontend/src/hooks.server.ts b/frontend/src/hooks.server.ts index 78c5dae5..37d4823e 100644 --- a/frontend/src/hooks.server.ts +++ b/frontend/src/hooks.server.ts @@ -65,6 +65,12 @@ export const handleFetch: HandleFetch = async ({ event, request, fetch }) => { const isApi = request.url.startsWith(apiUrl) || request.url.includes('/api/'); if (isApi) { + // If the request already carries an explicit Authorization header (e.g. the + // login action sends Basic auth), pass it through unchanged. + if (request.headers.has('Authorization')) { + return fetch(request); + } + const token = event.cookies.get('auth_token'); if (!token) { diff --git a/frontend/src/lib/components/PersonMultiSelect.svelte.spec.ts b/frontend/src/lib/components/PersonMultiSelect.svelte.spec.ts index 1209538f..1a132943 100644 --- a/frontend/src/lib/components/PersonMultiSelect.svelte.spec.ts +++ b/frontend/src/lib/components/PersonMultiSelect.svelte.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi, afterEach } from 'vitest'; -import { render } from 'vitest-browser-svelte'; +import { cleanup, render } from 'vitest-browser-svelte'; import { page } from 'vitest/browser'; import PersonMultiSelect from './PersonMultiSelect.svelte'; @@ -29,6 +29,7 @@ function receiverInputs() { } afterEach(() => { + cleanup(); vi.unstubAllGlobals(); }); diff --git a/frontend/src/lib/components/PersonTypeahead.svelte b/frontend/src/lib/components/PersonTypeahead.svelte index fb9a54dc..2ff22708 100644 --- a/frontend/src/lib/components/PersonTypeahead.svelte +++ b/frontend/src/lib/components/PersonTypeahead.svelte @@ -1,4 +1,5 @@ - -
0 || loading)}
{#if loading}
{m.comp_typeahead_loading()}
diff --git a/frontend/src/lib/components/PersonTypeahead.svelte.spec.ts b/frontend/src/lib/components/PersonTypeahead.svelte.spec.ts index b481e16e..440fb9cb 100644 --- a/frontend/src/lib/components/PersonTypeahead.svelte.spec.ts +++ b/frontend/src/lib/components/PersonTypeahead.svelte.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi, afterEach } from 'vitest'; -import { render } from 'vitest-browser-svelte'; +import { cleanup, render } from 'vitest-browser-svelte'; import { page } from 'vitest/browser'; import PersonTypeahead from './PersonTypeahead.svelte'; @@ -30,6 +30,7 @@ function hiddenInput(name: string) { } afterEach(() => { + cleanup(); vi.unstubAllGlobals(); }); @@ -117,9 +118,12 @@ describe('PersonTypeahead – selection', () => { const input = page.getByPlaceholder('Namen tippen...'); await input.fill('Mu'); await waitForDebounce(); - await page.getByText('Mustermann, Max').click(); + document.querySelector('[role="button"]')!.click(); + await tick(); await expect.element(input).toHaveValue('Max Mustermann'); - await expect.element(page.getByText('Mustermann, Max')).not.toBeInTheDocument(); + await expect + .element(page.getByRole('button', { name: 'Mustermann, Max' })) + .not.toBeInTheDocument(); await page.screenshot({ path: 'test-results/screenshots/person-typeahead-selected.png' }); }); @@ -129,7 +133,8 @@ describe('PersonTypeahead – selection', () => { const input = page.getByPlaceholder('Namen tippen...'); await input.fill('Mu'); await waitForDebounce(); - await page.getByText('Mustermann, Max').click(); + document.querySelector('[role="button"]')!.click(); + await tick(); await tick(); expect(hiddenInput('senderId')?.value).toBe('1'); }); @@ -141,7 +146,8 @@ describe('PersonTypeahead – selection', () => { const input = page.getByPlaceholder('Namen tippen...'); await input.fill('Mu'); await waitForDebounce(); - await page.getByText('Mustermann, Max').click(); + document.querySelector('[role="button"]')!.click(); + await tick(); expect(onchange).toHaveBeenCalledWith('1'); }); @@ -151,7 +157,8 @@ describe('PersonTypeahead – selection', () => { const input = page.getByPlaceholder('Namen tippen...'); await input.fill('Ma'); await waitForDebounce(); - await page.getByText('Mustermann, Max').click(); + document.querySelector('[role="button"]')!.click(); + await tick(); await expect.element(input).toHaveValue('Max Mustermann'); }); }); @@ -167,7 +174,8 @@ describe('PersonTypeahead – clearing a selection', () => { await input.fill('Mu'); await waitForDebounce(); - await page.getByText('Mustermann, Max').click(); + document.querySelector('[role="button"]')!.click(); + await tick(); expect(onchange).toHaveBeenCalledWith('1'); onchange.mockClear(); @@ -190,7 +198,7 @@ describe('PersonTypeahead – correspondent mode', () => { restrictToCorrespondentsOf: 'person-a-id' }); - await page.getByPlaceholder('Namen tippen...').click(); + (document.querySelector('input[placeholder="Namen tippen..."]') as HTMLInputElement).focus(); await waitForDebounce(); const fetchMock = globalThis.fetch as ReturnType; @@ -207,7 +215,7 @@ describe('PersonTypeahead – correspondent mode', () => { restrictToCorrespondentsOf: 'person-a-id' }); - await page.getByPlaceholder('Namen tippen...').click(); + (document.querySelector('input[placeholder="Namen tippen..."]') as HTMLInputElement).focus(); await waitForDebounce(); await expect.element(page.getByText('Mustermann, Max')).toBeInTheDocument(); diff --git a/frontend/src/lib/components/TagInput.svelte b/frontend/src/lib/components/TagInput.svelte index fd6728a5..9e19e13b 100644 --- a/frontend/src/lib/components/TagInput.svelte +++ b/frontend/src/lib/components/TagInput.svelte @@ -1,4 +1,5 @@