Compare commits

..

12 Commits

Author SHA1 Message Date
Marcel
f02c59dd98 docs(legibility): add README reference line to root CLAUDE.md — DOC-1
Some checks failed
CI / Unit & Component Tests (pull_request) Failing after 3m41s
CI / OCR Service Tests (pull_request) Successful in 31s
CI / Backend Unit Tests (pull_request) Failing after 3m21s
CI / Unit & Component Tests (push) Failing after 3m30s
CI / OCR Service Tests (push) Successful in 28s
CI / Backend Unit Tests (push) Failing after 3m17s
Single pointer line at the top: humans read README.md, LLMs read CLAUDE.md.
No existing content removed — full migration is DOC-7's responsibility.

Refs #395

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-05 22:39:07 +02:00
Marcel
a5d20f264e docs(legibility): write human-targeted README.md at repo root — DOC-1
Five-section front door for new contributors: product description,
subsystem map, quick-start (local dev + full Docker variant), where-to-go-next
with TODO markers for DOC-2/4/5, and one-line private license.

Corrects stale port reference (3000→5173, per vite.config.ts).
Links docs/GLOSSARY.md, docs/adr/, docs/architecture/c4-diagrams.md,
and Gitea issue tracker with LAN qualifier.

Closes #395

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-05 22:38:03 +02:00
Marcel
39e7ee2c71 fix(e2e): use dedicated reset user instead of admin in password-reset test
Some checks failed
CI / Unit & Component Tests (push) Failing after 3m34s
CI / OCR Service Tests (push) Successful in 37s
CI / Backend Unit Tests (push) Failing after 3m13s
Introduces a separate reset@familyarchive.local / reset123 seed account
(e2e profile only) so the password-reset flow test never touches the
shared admin credentials.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-05 21:17:00 +02:00
Marcel
f14c8b9eea test(e2e): fix deep-link Fertig selector — strict mode violation at desktop viewport
Some checks failed
CI / Unit & Component Tests (pull_request) Failing after 3m28s
CI / OCR Service Tests (pull_request) Successful in 44s
CI / Backend Unit Tests (pull_request) Failing after 3m24s
CI / Unit & Component Tests (push) Failing after 3m47s
CI / OCR Service Tests (push) Successful in 42s
CI / Backend Unit Tests (push) Failing after 3m23s
getByRole('button', { name: 'Fertig' }) matched two buttons at 1440px width:
the transcribe-mode Fertig button and 'Alle als fertig markieren'. Add exact: true.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-05 20:08:01 +02:00
Marcel
2632434263 test(e2e): fix J5 relationship selector — scope to Beziehungen section, drop baseURL
Some checks failed
CI / OCR Service Tests (pull_request) Successful in 38s
CI / Unit & Component Tests (push) Failing after 3m13s
CI / OCR Service Tests (push) Successful in 34s
CI / Backend Unit Tests (push) Failing after 3m14s
CI / Unit & Component Tests (pull_request) Failing after 3m25s
CI / Backend Unit Tests (pull_request) Failing after 3m22s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-05 19:12:12 +02:00
Marcel
649c3f8f8a docs(audit): narrow J10 coverage claim to what the bell test actually exercises
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-05 19:12:12 +02:00
Marcel
5518122b69 test(e2e): fix notification-deep-link — relative paths, afterAll cleanup, accurate J10 comment
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-05 19:12:12 +02:00
Marcel
64110033bd test(e2e): replace E2E_BASE_URL absolute URL construction with relative paths
All page.goto() calls in documents.spec.ts now use relative paths (/documents/{id})
so Playwright's configured baseURL is the single source of truth. Removes the
fragility of keeping process.env.E2E_BASE_URL in sync with playwright.config.ts.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-05 19:12:12 +02:00
Marcel
29bf45d15a test(e2e): fix J6 — use correct tag URL param, update report from sender to tag filter
The test was using tagId=nonexistent-tag-id which is not a recognised search parameter;
the correct param is tag= (tag name). Updated the test and the coverage report to
accurately describe what is verified: text + tag filter AND combination. The sender
filter test remains an acknowledged gap noted in the report.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-05 19:12:12 +02:00
Marcel
3f25f1fd73 test(e2e): fix J4 — add page reload assertion, unique title, afterAll cleanup, precise selector
Four concerns addressed:
- Persistence: reloads the detail page after save and re-asserts the tag link,
  making the report's "after page reload" claim accurate
- Unique title: adds stamp to document title to prevent accumulation across runs
- Cleanup: afterAll deletes the test document
- Selector: replaces getByText(newTagName) with a[href*="?tag="] scoped to the tag link

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-05 19:12:12 +02:00
Marcel
fcd91c2e81 test(e2e): fix J3 — seed unique tag via API, scope chip selector, add afterAll cleanup
Three concerns addressed:
- Race condition: "Familie" tag is renamed by admin tests; now seeds a unique
  timestamped tag via a throwaway document PUT so J3 never depends on seeded data
- Chip selector: replaces getByText(/Familie/) with a[href*="?tag="] scoped to the
  actual tag link in the metadata section
- Cleanup: afterAll deletes both the test document and the seeder document

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-05 19:12:12 +02:00
Marcel
c7bf35f011 test(e2e): tighten J12 import status regex to match only import-specific messages
The previous regex /Importiert|Dokument|Import|Läuft|DONE|laufend/i was too broad —
it would match almost any German text on the page including unrelated copy. Replaced
with /Import läuft|Import abgeschlossen|Fehler:/ which matches only the three status
messages the mass import feature actually emits.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-05 19:12:12 +02:00
9 changed files with 223 additions and 58 deletions

View File

@@ -1,5 +1,7 @@
# CLAUDE.md # CLAUDE.md
> For a human-readable project overview, see [README.md](./README.md).
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview ## Project Overview

93
README.md Normal file
View File

@@ -0,0 +1,93 @@
# Familienarchiv
Familienarchiv is a private web application for digitising, organising, and searching a family document collection — letters, postcards, and photographs from 1899 to 1950. Family members upload scans, transcribe handwritten text (Kurrent/Sütterlin), and read the archive from any device.
---
## Subsystems
- `frontend/` — SvelteKit 2 / Svelte 5 / TypeScript / Tailwind 4 web app (server-side rendered)
- `backend/` — Spring Boot 4 (Java 21) REST API; handles documents, persons, search, and user management
- `ocr-service/` — Python FastAPI microservice for OCR and handwritten text recognition (HTR); single-node by design — see [ADR-001](docs/adr/001-ocr-python-microservice.md). Not part of the default dev stack (see Quick start below)
- `infra/` — Gitea Actions CI/CD config; future home for infrastructure-as-code
- `scripts/` — operational and data-pipeline helpers (`reset-db.sh`, `clean-e2e-data.sh`, import scripts)
---
## Quick start
**Prerequisites:** Java 21, Node 24, Docker with the `docker compose` plugin (V2).
### 1. Configure environment
```bash
cp .env.example .env
# The defaults in .env.example work for local development without changes.
```
### 2. Start infrastructure
```bash
# Starts PostgreSQL, MinIO (object storage), and Mailpit (dev mail catcher)
docker compose up -d db minio mailpit
```
### 3. Start the backend
```bash
cd backend
./mvnw spring-boot:run
# Starts on http://localhost:8080
# API docs (dev profile, auto-enabled): http://localhost:8080/v3/api-docs
```
### 4. Start the frontend
```bash
cd frontend
npm install
npm run dev
# Starts on http://localhost:5173
```
Open **http://localhost:5173** — you should see the Familienarchiv login screen.
Default development credentials:
```
# local dev only — change before any network-exposed deployment
Email: admin@familyarchive.local
Password: admin123
```
> **Development setup only.** The default `docker compose` config exposes the database port and uses root MinIO credentials. Do not connect this to a network without first reading `docs/DEPLOYMENT.md` _(coming: [DOC-5, #399](http://heim-nas:3005/marcel/familienarchiv/issues/399))_.
### Running the full stack via Docker (optional)
To run everything including the backend and frontend in containers:
```bash
docker compose up -d
```
Note: the OCR service (`ocr-service/`) builds its Docker image locally and downloads ~6 GB of ML models on first start. Expect 3060 minutes on a first run. The rest of the stack starts independently; OCR can be excluded with `--scale ocr-service=0` on memory-constrained machines (requires ≥ 12 GB RAM).
---
## Where to go next
| Resource | Purpose |
|---|---|
| [docs/architecture/c4-diagrams.md](docs/architecture/c4-diagrams.md) | C4 container and component diagrams (current system view) |
| [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) _(coming: [DOC-2, #396](http://heim-nas:3005/marcel/familienarchiv/issues/396))_ | Full architecture guide with domain list |
| [docs/GLOSSARY.md](docs/GLOSSARY.md) | Overloaded terms: Person vs AppUser, Chronik vs Aktivität, etc. |
| [CONTRIBUTING.md](CONTRIBUTING.md) _(coming: [DOC-4, #398](http://heim-nas:3005/marcel/familienarchiv/issues/398))_ | How to add a domain, endpoint, or SvelteKit route |
| [docs/DEPLOYMENT.md](docs/DEPLOYMENT.md) _(coming: [DOC-5, #399](http://heim-nas:3005/marcel/familienarchiv/issues/399))_ | Production deployment checklist and secrets guide |
| [docs/adr/](docs/adr/) | Architecture Decision Records — the "why" behind key choices |
| [Gitea issue tracker](http://heim-nas:3005/marcel/familienarchiv/issues) _(internal — home network only)_ | Bug reports, feature requests, and project planning |
---
## License
Private project — all rights reserved. Not licensed for redistribution.

View File

@@ -102,6 +102,21 @@ public class UserDataInitializer {
log.info("E2E seed: 'reader'-Testbenutzer erstellt."); log.info("E2E seed: 'reader'-Testbenutzer erstellt.");
} }
if (userRepository.findByEmail("reset@familyarchive.local").isEmpty()) {
log.info("E2E seed: Erstelle 'reset'-Testbenutzer...");
UserGroup leserGroup = groupRepository.findByName("Leser").orElseGet(() ->
groupRepository.save(UserGroup.builder()
.name("Leser")
.permissions(Set.of("READ_ALL"))
.build()));
userRepository.save(AppUser.builder()
.email("reset@familyarchive.local")
.password(passwordEncoder.encode("reset123"))
.groups(Set.of(leserGroup))
.build());
log.info("E2E seed: 'reset'-Testbenutzer erstellt.");
}
if (personRepo.count() > 0) { if (personRepo.count() > 0) {
log.info("E2E seed: Personendaten bereits vorhanden, überspringe Dokument-Seed."); log.info("E2E seed: Personendaten bereits vorhanden, überspringe Dokument-Seed.");
return; return;

View File

@@ -15,7 +15,7 @@
| J3 — Edit document sender + tags | ✅ COVERED | `documents.spec.ts` | | J3 — Edit document sender + tags | ✅ COVERED | `documents.spec.ts` |
| J4 — Tag create via TagInput | ✅ COVERED | `documents.spec.ts` | | J4 — Tag create via TagInput | ✅ COVERED | `documents.spec.ts` |
| J5 — Create person + add relationship | ✅ COVERED | `persons.spec.ts` | | J5 — Create person + add relationship | ✅ COVERED | `persons.spec.ts` |
| J6 — Search with text + sender filter | ✅ COVERED | `documents.spec.ts` | | J6 — Search with text + tag filter | ✅ COVERED | `documents.spec.ts` |
| J7 — Full transcription journey | ✅ COVERED | `transcription.spec.ts` | | J7 — Full transcription journey | ✅ COVERED | `transcription.spec.ts` |
| J8 — Geschichte create, publish + link person | ✅ COVERED | `geschichten.spec.ts` | | J8 — Geschichte create, publish + link person | ✅ COVERED | `geschichten.spec.ts` |
| J9 — Bilateral conversation timeline | ✅ COVERED | `korrespondenz.spec.ts` | | J9 — Bilateral conversation timeline | ✅ COVERED | `korrespondenz.spec.ts` |
@@ -71,7 +71,7 @@
**Pre-existing coverage:** Date range filter; text search (separate tests). **Pre-existing coverage:** Date range filter; text search (separate tests).
**Gap filled:** A test in `documents.spec.ts` creates two documents — one seeded with a known sender — then applies both a text query and a sender filter simultaneously and asserts only matching results appear. **Gap filled:** A test in `documents.spec.ts` navigates with both a text query (`?q=zzz_unlikely`) and a tag filter (`&tag=zzz-nonexistent-tag-name`) and confirms that the AND combination returns no results. A second test verifies that a `?q=E2E&from=2000-01-01` URL preserves both parameters. Note: a dedicated sender filter test remains a gap — see follow-up issue.
--- ---
@@ -99,7 +99,7 @@
**Pre-existing coverage:** Deep-link scroll in `notification-deep-link.spec.ts`. **Pre-existing coverage:** Deep-link scroll in `notification-deep-link.spec.ts`.
**Gap filled:** A test seeds a comment, then via the bell button opens the notification dropdown, verifies the unread count badge, clicks a notification to mark it as read, and confirms the badge disappears. **Gap filled:** A test in `notification-deep-link.spec.ts` seeds a comment, clicks the notification bell button, and asserts the dropdown/dialog opens; pressing Escape closes it. The full mark-as-read flow and navigation to the target document are **not** covered by this test — tracked in a follow-up issue.
--- ---

View File

@@ -233,10 +233,9 @@ test.describe('Admin system tab — mass import trigger', () => {
await expect(importBtn.first()).toBeVisible({ timeout: 10_000 }); await expect(importBtn.first()).toBeVisible({ timeout: 10_000 });
await importBtn.first().click(); await importBtn.first().click();
// After triggering, either a RUNNING status text appears (job started) // After triggering, a status message specific to the import operation appears.
// or a DONE/FAILED result text appears (job finished quickly or was already done).
await expect( await expect(
page.locator('text=/Importiert|Dokument|Import|Läuft|DONE|laufend/i').first() page.locator('text=/Import läuft|Import abgeschlossen|Fehler:/').first()
).toBeVisible({ timeout: 15_000 }); ).toBeVisible({ timeout: 15_000 });
await page.screenshot({ path: 'test-results/e2e/admin-mass-import-triggered.png' }); await page.screenshot({ path: 'test-results/e2e/admin-mass-import-triggered.png' });

View File

@@ -209,8 +209,6 @@ test.describe('PDF viewer', () => {
let noFileDocHref: string; let noFileDocHref: string;
test.beforeAll(async ({ request }) => { test.beforeAll(async ({ request }) => {
const baseURL = process.env.E2E_BASE_URL ?? 'http://localhost:3000';
// Create a document with a PDF file. // Create a document with a PDF file.
const createRes = await request.post('/api/documents', { const createRes = await request.post('/api/documents', {
multipart: { title: 'E2E PDF Viewer Test' } multipart: { title: 'E2E PDF Viewer Test' }
@@ -229,7 +227,7 @@ test.describe('PDF viewer', () => {
} }
}); });
if (!uploadRes.ok()) throw new Error(`Upload PDF failed: ${uploadRes.status()}`); if (!uploadRes.ok()) throw new Error(`Upload PDF failed: ${uploadRes.status()}`);
pdfDocHref = `${baseURL}/documents/${doc.id}`; pdfDocHref = `/documents/${doc.id}`;
// Create a document WITHOUT a file — used to verify no canvas is rendered. // Create a document WITHOUT a file — used to verify no canvas is rendered.
const noFileRes = await request.post('/api/documents', { const noFileRes = await request.post('/api/documents', {
@@ -237,7 +235,7 @@ test.describe('PDF viewer', () => {
}); });
if (!noFileRes.ok()) throw new Error(`Create no-file document failed: ${noFileRes.status()}`); if (!noFileRes.ok()) throw new Error(`Create no-file document failed: ${noFileRes.status()}`);
const noFileDoc = await noFileRes.json(); const noFileDoc = await noFileRes.json();
noFileDocHref = `${baseURL}/documents/${noFileDoc.id}`; noFileDocHref = `/documents/${noFileDoc.id}`;
}); });
test('PDF renders in the custom viewer — canvas is present instead of iframe', async ({ test('PDF renders in the custom viewer — canvas is present instead of iframe', async ({
@@ -306,8 +304,7 @@ test.describe('PDF annotations — admin', () => {
}); });
if (!uploadRes.ok()) throw new Error(`Upload PDF failed: ${uploadRes.status()}`); if (!uploadRes.ok()) throw new Error(`Upload PDF failed: ${uploadRes.status()}`);
const baseURL = process.env.E2E_BASE_URL ?? 'http://localhost:3000'; annotationDocHref = `/documents/${doc.id}`;
annotationDocHref = `${baseURL}/documents/${doc.id}`;
sharedAnnotationDocId = doc.id; sharedAnnotationDocId = doc.id;
}); });
@@ -404,7 +401,6 @@ test.describe('PDF annotations — admin', () => {
// ─── PDF Annotations — file hash (version awareness) ───────────────────────── // ─── PDF Annotations — file hash (version awareness) ─────────────────────────
test.describe('PDF annotations — file hash versioning', () => { test.describe('PDF annotations — file hash versioning', () => {
const baseURL = process.env.E2E_BASE_URL ?? 'http://localhost:3000';
const PDF_FIXTURE2 = path.resolve(__dirname, 'fixtures/minimal2.pdf'); const PDF_FIXTURE2 = path.resolve(__dirname, 'fixtures/minimal2.pdf');
test('annotations are hidden after a different file is uploaded', async ({ page, request }) => { test('annotations are hidden after a different file is uploaded', async ({ page, request }) => {
@@ -436,7 +432,7 @@ test.describe('PDF annotations — file hash versioning', () => {
if (!annotRes.ok()) throw new Error(`Create annotation failed: ${annotRes.status()}`); if (!annotRes.ok()) throw new Error(`Create annotation failed: ${annotRes.status()}`);
// 3. Verify annotation appears before re-upload // 3. Verify annotation appears before re-upload
await page.goto(`${baseURL}/documents/${doc.id}`); await page.goto(`/documents/${doc.id}`);
await page.waitForSelector('[data-hydrated]'); await page.waitForSelector('[data-hydrated]');
await page.locator('canvas').first().waitFor({ state: 'visible', timeout: 20000 }); await page.locator('canvas').first().waitFor({ state: 'visible', timeout: 20000 });
await expect(page.locator('[data-testid^="annotation-"]').first()).toBeVisible({ await expect(page.locator('[data-testid^="annotation-"]').first()).toBeVisible({
@@ -520,7 +516,7 @@ test.describe('PDF annotations — file hash versioning', () => {
if (!restoreRes.ok()) throw new Error(`Restore failed: ${restoreRes.status()}`); if (!restoreRes.ok()) throw new Error(`Restore failed: ${restoreRes.status()}`);
// 5. Verify annotation reappears and notice is gone // 5. Verify annotation reappears and notice is gone
await page.goto(`${baseURL}/documents/${doc.id}`); await page.goto(`/documents/${doc.id}`);
await page.waitForSelector('[data-hydrated]'); await page.waitForSelector('[data-hydrated]');
await page.locator('canvas').first().waitFor({ state: 'visible', timeout: 20000 }); await page.locator('canvas').first().waitFor({ state: 'visible', timeout: 20000 });
@@ -548,8 +544,7 @@ test.describe('PDF annotations — read-only user', () => {
await page.waitForURL('/'); await page.waitForURL('/');
// Navigate directly to the PDF document created by the admin beforeAll. // Navigate directly to the PDF document created by the admin beforeAll.
const baseURL = process.env.E2E_BASE_URL ?? 'http://localhost:3000'; await page.goto(`/documents/${sharedAnnotationDocId}`);
await page.goto(`${baseURL}/documents/${sharedAnnotationDocId}`);
await page.waitForSelector('[data-hydrated]'); await page.waitForSelector('[data-hydrated]');
// Reader users do not have ANNOTATE_ALL permission — the button must not be shown at all. // Reader users do not have ANNOTATE_ALL permission — the button must not be shown at all.
@@ -563,41 +558,69 @@ test.describe('PDF annotations — read-only user', () => {
// ── J3: Edit document — add an existing tag ──────────────────────────────── // ── J3: Edit document — add an existing tag ────────────────────────────────
// //
// Verifies that a user can open a document's edit page and assign a tag using // Verifies that a user can open a document's edit page and assign a tag using
// the TagInput component, then save and see the tag chip on the detail page. // the TagInput component, then save and see the tag link on the detail page.
// Seeds a unique tag via a throwaway document so the test never depends on the
// seeded "Familie" tag (which admin tests rename during their lifecycle).
test.describe('Document editing — tags (J3)', () => { test.describe('Document editing — tags (J3)', () => {
const baseURL = process.env.E2E_BASE_URL ?? 'http://localhost:3000'; let tagDocId: string;
let tagDocHref: string; let seedDocId: string;
let seededTagName: string;
test.beforeAll(async ({ request }) => { test.beforeAll(async ({ request }) => {
const stamp = Date.now().toString(36);
seededTagName = `E2E-J3-Tag-${stamp}`;
// Create a throwaway document and associate the unique tag with it so it
// exists in the system for the TagInput suggestion list.
const seederRes = await request.post('/api/documents', {
multipart: { title: `E2E J3 Tag Seeder ${stamp}` }
});
if (!seederRes.ok()) throw new Error(`Create seeder failed: ${seederRes.status()}`);
const seeder = await seederRes.json();
seedDocId = seeder.id;
const seedTagRes = await request.put(`/api/documents/${seedDocId}`, {
multipart: { title: seeder.title, tags: seededTagName }
});
if (!seedTagRes.ok()) throw new Error(`Seed tag failed: ${seedTagRes.status()}`);
// Create the test document without the tag — the test will add it.
const createRes = await request.post('/api/documents', { const createRes = await request.post('/api/documents', {
multipart: { title: 'E2E Tag Edit Test' } multipart: { title: `E2E Tag Edit Test ${stamp}` }
}); });
if (!createRes.ok()) throw new Error(`Create document failed: ${createRes.status()}`); if (!createRes.ok()) throw new Error(`Create document failed: ${createRes.status()}`);
const doc = await createRes.json(); const doc = await createRes.json();
tagDocHref = `${baseURL}/documents/${doc.id}`; tagDocId = doc.id;
});
test.afterAll(async ({ request }) => {
if (tagDocId) await request.delete(`/api/documents/${tagDocId}`);
if (seedDocId) await request.delete(`/api/documents/${seedDocId}`);
}); });
test('user adds an existing tag and sees it on the detail page', async ({ page }) => { test('user adds an existing tag and sees it on the detail page', async ({ page }) => {
await page.goto(`${tagDocHref}/edit`); await page.goto(`/documents/${tagDocId}/edit`);
await page.waitForSelector('[data-hydrated]'); await page.waitForSelector('[data-hydrated]');
// TagInput has placeholder "Schlagworte hinzufügen..." when empty. // TagInput has placeholder "Schlagworte hinzufügen..." when empty.
const tagInput = page.getByPlaceholder('Schlagworte hinzufügen...'); const tagInput = page.getByPlaceholder('Schlagworte hinzufügen...');
await expect(tagInput).toBeVisible(); await expect(tagInput).toBeVisible();
// Type the beginning of the seeded "Familie" tag and wait for the suggestion. // Type the seeded tag name and wait for the suggestion.
await tagInput.fill('Fami'); await tagInput.fill(seededTagName);
const suggestion = page.getByRole('option', { name: /Familie/i }).first(); const suggestion = page.getByRole('option', { name: seededTagName }).first();
await expect(suggestion).toBeVisible({ timeout: 5_000 }); await expect(suggestion).toBeVisible({ timeout: 5_000 });
await suggestion.click(); await suggestion.click();
// Save the document. // Save the document.
await page.getByRole('button', { name: 'Speichern', exact: true }).click(); await page.getByRole('button', { name: 'Speichern', exact: true }).click();
// Redirected to detail page — the tag chip must be visible. // Redirected to detail page — the tag link must be visible in the metadata section.
await expect(page).toHaveURL(/\/documents\/[^/]+$/); await expect(page).toHaveURL(/\/documents\/[^/]+$/);
await expect(page.getByText(/Familie/)).toBeVisible({ timeout: 5_000 }); await expect(page.locator('a[href*="?tag="]', { hasText: seededTagName })).toBeVisible({
timeout: 5_000
});
await page.screenshot({ path: 'test-results/e2e/document-edit-tag.png' }); await page.screenshot({ path: 'test-results/e2e/document-edit-tag.png' });
}); });
}); });
@@ -605,24 +628,30 @@ test.describe('Document editing — tags (J3)', () => {
// ── J4: Create a brand-new tag via TagInput ──────────────────────────────── // ── J4: Create a brand-new tag via TagInput ────────────────────────────────
// //
// Types a tag name that does not exist yet, confirms creation with Enter, and // Types a tag name that does not exist yet, confirms creation with Enter, and
// verifies the tag chip persists after save. // verifies the tag chip persists after save AND after a full page reload.
test.describe('Document editing — new tag creation (J4)', () => { test.describe('Document editing — new tag creation (J4)', () => {
const baseURL = process.env.E2E_BASE_URL ?? 'http://localhost:3000'; let newTagDocId: string;
let newTagDocHref: string; const stamp = Date.now().toString(36);
const newTagName = `E2E-Tag-${Date.now().toString(36)}`; const newTagName = `E2E-Tag-${stamp}`;
test.beforeAll(async ({ request }) => { test.beforeAll(async ({ request }) => {
const createRes = await request.post('/api/documents', { const createRes = await request.post('/api/documents', {
multipart: { title: 'E2E New Tag Test' } multipart: { title: `E2E New Tag Test ${stamp}` }
}); });
if (!createRes.ok()) throw new Error(`Create document failed: ${createRes.status()}`); if (!createRes.ok()) throw new Error(`Create document failed: ${createRes.status()}`);
const doc = await createRes.json(); const doc = await createRes.json();
newTagDocHref = `${baseURL}/documents/${doc.id}`; newTagDocId = doc.id;
}); });
test('user types a new tag name, presses Enter, saves, and sees the chip', async ({ page }) => { test.afterAll(async ({ request }) => {
await page.goto(`${newTagDocHref}/edit`); if (newTagDocId) await request.delete(`/api/documents/${newTagDocId}`);
});
test('user types a new tag name, presses Enter, saves, and tag persists after reload', async ({
page
}) => {
await page.goto(`/documents/${newTagDocId}/edit`);
await page.waitForSelector('[data-hydrated]'); await page.waitForSelector('[data-hydrated]');
const tagInput = page.getByPlaceholder('Schlagworte hinzufügen...'); const tagInput = page.getByPlaceholder('Schlagworte hinzufügen...');
@@ -637,8 +666,19 @@ test.describe('Document editing — new tag creation (J4)', () => {
await page.getByRole('button', { name: 'Speichern', exact: true }).click(); await page.getByRole('button', { name: 'Speichern', exact: true }).click();
// Detail page after redirect — tag link must be visible.
await expect(page).toHaveURL(/\/documents\/[^/]+$/); await expect(page).toHaveURL(/\/documents\/[^/]+$/);
await expect(page.getByText(newTagName)).toBeVisible({ timeout: 5_000 }); await expect(page.locator('a[href*="?tag="]', { hasText: newTagName })).toBeVisible({
timeout: 5_000
});
// Reload to verify the tag survived the round-trip (not just client-side state).
await page.reload();
await page.waitForSelector('[data-hydrated]');
await expect(page.locator('a[href*="?tag="]', { hasText: newTagName })).toBeVisible({
timeout: 5_000
});
await page.screenshot({ path: 'test-results/e2e/document-new-tag-created.png' }); await page.screenshot({ path: 'test-results/e2e/document-new-tag-created.png' });
}); });
}); });
@@ -650,10 +690,11 @@ test.describe('Document editing — new tag creation (J4)', () => {
test.describe('Document search — multi-filter (J6)', () => { test.describe('Document search — multi-filter (J6)', () => {
test('combining text search and tag filter shows only matching documents', async ({ page }) => { test('combining text search and tag filter shows only matching documents', async ({ page }) => {
// Navigate with a text query + a tag filter param. // Navigate with a text query + a tag filter param. Using an unlikely text string and
// We use the seeded "Familie" tag (slug "familie") and a text that is unlikely // a nonexistent tag name confirms that the AND combination of both filters returns no
// to match anything — confirming that the AND combination works. // results without relying on seeded data. Note: the correct URL param is "tag" (tag name),
await page.goto('/?q=zzz_unlikely&tagId=nonexistent-tag-id'); // not "tagId".
await page.goto('/?q=zzz_unlikely&tag=zzz-nonexistent-tag-name');
await page.waitForSelector('[data-hydrated]'); await page.waitForSelector('[data-hydrated]');
await expect(page.getByText('Keine Dokumente gefunden')).toBeVisible({ timeout: 5_000 }); await expect(page.getByText('Keine Dokumente gefunden')).toBeVisible({ timeout: 5_000 });

View File

@@ -25,15 +25,13 @@ let commentId: string;
test.describe('Notification deep-link scroll', () => { test.describe('Notification deep-link scroll', () => {
test.beforeAll(async ({ request }) => { test.beforeAll(async ({ request }) => {
const baseURL = process.env.E2E_BASE_URL ?? 'http://localhost:3000';
const createRes = await request.post('/api/documents', { const createRes = await request.post('/api/documents', {
multipart: { title: 'E2E Deep-Link Test', documentDate: '1945-05-08' } multipart: { title: 'E2E Deep-Link Test', documentDate: '1945-05-08' }
}); });
if (!createRes.ok()) throw new Error(`Create document failed: ${createRes.status()}`); if (!createRes.ok()) throw new Error(`Create document failed: ${createRes.status()}`);
const doc = await createRes.json(); const doc = await createRes.json();
docId = doc.id; docId = doc.id;
docHref = `${baseURL}/documents/${docId}`; docHref = `/documents/${docId}`;
const uploadRes = await request.put(`/api/documents/${docId}`, { const uploadRes = await request.put(`/api/documents/${docId}`, {
multipart: { multipart: {
@@ -74,6 +72,10 @@ test.describe('Notification deep-link scroll', () => {
commentId = comment.id; commentId = comment.id;
}); });
test.afterAll(async ({ request }) => {
if (docId) await request.delete(`/api/documents/${docId}`);
});
async function openDeepLink(page: Page) { async function openDeepLink(page: Page) {
const url = `${docHref}?commentId=${commentId}&annotationId=${annotationId}`; const url = `${docHref}?commentId=${commentId}&annotationId=${annotationId}`;
await page.goto(url); await page.goto(url);
@@ -90,7 +92,9 @@ test.describe('Notification deep-link scroll', () => {
await openDeepLink(page); await openDeepLink(page);
// Transcribe mode was auto-entered — Fertig button is visible // Transcribe mode was auto-entered — Fertig button is visible
await expect(page.getByRole('button', { name: 'Fertig' })).toBeVisible({ timeout: 15_000 }); await expect(page.getByRole('button', { name: 'Fertig', exact: true })).toBeVisible({
timeout: 15_000
});
// The target comment article is in the DOM and visible // The target comment article is in the DOM and visible
const article = page.locator(`#comment-${commentId}`); const article = page.locator(`#comment-${commentId}`);
@@ -119,16 +123,17 @@ test.describe('Notification deep-link scroll', () => {
// ── Notification bell — J10 ──────────────────────────────────────────────── // ── Notification bell — J10 ────────────────────────────────────────────────
// //
// Verifies the notification bell in the global header: clicking it opens the // Verifies the notification bell in the global header: clicking it opens the
// dropdown, an unread notification is visible, clicking it marks it as read // dropdown and it closes on Escape. Full mark-as-read and navigation flows are
// and navigates to the target document. // tracked in a follow-up issue.
test.describe('Notification bell', () => { test.describe('Notification bell', () => {
let bellDocId: string; let bellDocId: string;
test.beforeAll(async ({ request }) => { test.beforeAll(async ({ request }) => {
const stamp = Date.now().toString(36);
// Seed a document + comment to ensure the notification list has content to render. // Seed a document + comment to ensure the notification list has content to render.
const createRes = await request.post('/api/documents', { const createRes = await request.post('/api/documents', {
multipart: { title: 'E2E Bell Test Doc', documentDate: '1930-01-01' } multipart: { title: `E2E Bell Test Doc ${stamp}`, documentDate: '1930-01-01' }
}); });
if (!createRes.ok()) throw new Error(`Create document failed: ${createRes.status()}`); if (!createRes.ok()) throw new Error(`Create document failed: ${createRes.status()}`);
const doc = await createRes.json(); const doc = await createRes.json();
@@ -140,6 +145,10 @@ test.describe('Notification bell', () => {
if (!commentRes.ok()) throw new Error(`Create comment failed: ${commentRes.status()}`); if (!commentRes.ok()) throw new Error(`Create comment failed: ${commentRes.status()}`);
}); });
test.afterAll(async ({ request }) => {
if (bellDocId) await request.delete(`/api/documents/${bellDocId}`);
});
test('bell opens dropdown, shows notifications list', async ({ page }) => { test('bell opens dropdown, shows notifications list', async ({ page }) => {
test.setTimeout(30_000); test.setTimeout(30_000);

View File

@@ -42,8 +42,9 @@ test.describe('Password reset', () => {
}); });
test('full password reset flow', async ({ page }) => { test('full password reset flow', async ({ page }) => {
const testEmail = process.env.E2E_EMAIL ?? 'admin@familyarchive.local'; // Uses a dedicated low-privilege test account so the admin account is never touched.
const originalPassword = process.env.E2E_PASSWORD ?? 'admin123'; const testEmail = 'reset@familyarchive.local';
const originalPassword = 'reset123';
const newPassword = 'NewP@ssw0rd_E2E!'; const newPassword = 'NewP@ssw0rd_E2E!';
// 1. Request reset // 1. Request reset
@@ -70,7 +71,7 @@ test.describe('Password reset', () => {
// 5. Log in with new password // 5. Log in with new password
await expect(page).toHaveURL(/\/login/); await expect(page).toHaveURL(/\/login/);
await page.getByLabel('Benutzername').fill(process.env.E2E_USERNAME ?? 'admin'); await page.getByLabel('Benutzername').fill(testEmail);
await page.getByLabel('Passwort').fill(newPassword); await page.getByLabel('Passwort').fill(newPassword);
await page.getByRole('button', { name: 'Anmelden' }).click(); await page.getByRole('button', { name: 'Anmelden' }).click();
await expect(page).toHaveURL('/'); await expect(page).toHaveURL('/');
@@ -85,7 +86,7 @@ test.describe('Password reset', () => {
await expect(page).toHaveURL(/\/login/); await expect(page).toHaveURL(/\/login/);
// 7. Log back in with original password to confirm restore worked // 7. Log back in with original password to confirm restore worked
await page.getByLabel('Benutzername').fill(process.env.E2E_USERNAME ?? 'admin'); await page.getByLabel('Benutzername').fill(testEmail);
await page.getByLabel('Passwort').fill(originalPassword); await page.getByLabel('Passwort').fill(originalPassword);
await page.getByRole('button', { name: 'Anmelden' }).click(); await page.getByRole('button', { name: 'Anmelden' }).click();
await expect(page).toHaveURL('/'); await expect(page).toHaveURL('/');

View File

@@ -188,8 +188,7 @@ test.describe('Person detail — sent and received documents', () => {
// uses the AddRelationshipForm to link them. Asserts the chip appears. // uses the AddRelationshipForm to link them. Asserts the chip appears.
test.describe('Person relationship — add via edit page (J5)', () => { test.describe('Person relationship — add via edit page (J5)', () => {
const baseURL = process.env.E2E_BASE_URL ?? 'http://localhost:3000'; let personAId: string;
let personAHref: string;
let personBName: string; let personBName: string;
test.beforeAll(async ({ request }) => { test.beforeAll(async ({ request }) => {
@@ -200,7 +199,7 @@ test.describe('Person relationship — add via edit page (J5)', () => {
}); });
if (!aRes.ok()) throw new Error(`Create person A failed: ${aRes.status()}`); if (!aRes.ok()) throw new Error(`Create person A failed: ${aRes.status()}`);
const a = await aRes.json(); const a = await aRes.json();
personAHref = `${baseURL}/persons/${a.id}`; personAId = a.id;
const bRes = await request.post('/api/persons', { const bRes = await request.post('/api/persons', {
data: { firstName: 'E2E-Rel-B', lastName: stamp } data: { firstName: 'E2E-Rel-B', lastName: stamp }
@@ -213,7 +212,7 @@ test.describe('Person relationship — add via edit page (J5)', () => {
test('user adds a SPOUSE_OF relationship and sees the chip on the edit page', async ({ test('user adds a SPOUSE_OF relationship and sees the chip on the edit page', async ({
page page
}) => { }) => {
await page.goto(`${personAHref}/edit`); await page.goto(`/persons/${personAId}/edit`);
await page.waitForSelector('[data-hydrated]'); await page.waitForSelector('[data-hydrated]');
// Open the AddRelationshipForm by clicking the "+ Beziehung hinzufügen" button. // Open the AddRelationshipForm by clicking the "+ Beziehung hinzufügen" button.
@@ -234,8 +233,14 @@ test.describe('Person relationship — add via edit page (J5)', () => {
// Submit the relationship form. // Submit the relationship form.
await page.getByRole('button', { name: 'Hinzufügen' }).click(); await page.getByRole('button', { name: 'Hinzufügen' }).click();
// The relationship chip should appear in the Stammbaum section. // The relationship chip should appear inside the Beziehungen section.
await expect(page.getByText(personBName)).toBeVisible({ timeout: 8_000 }); const relCard = page
.locator('div')
.filter({ has: page.locator('h2', { hasText: 'Beziehungen' }) })
.first();
await expect(relCard.locator('a[href^="/persons/"]', { hasText: personBName })).toBeVisible({
timeout: 8_000
});
await page.screenshot({ path: 'test-results/e2e/person-relationship-added.png' }); await page.screenshot({ path: 'test-results/e2e/person-relationship-added.png' });
}); });
}); });