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
> 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.
## 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.");
}
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) {
log.info("E2E seed: Personendaten bereits vorhanden, überspringe Dokument-Seed.");
return;

View File

@@ -15,7 +15,7 @@
| J3 — Edit document sender + tags | ✅ COVERED | `documents.spec.ts` |
| J4 — Tag create via TagInput | ✅ COVERED | `documents.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` |
| J8 — Geschichte create, publish + link person | ✅ COVERED | `geschichten.spec.ts` |
| J9 — Bilateral conversation timeline | ✅ COVERED | `korrespondenz.spec.ts` |
@@ -71,7 +71,7 @@
**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`.
**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 importBtn.first().click();
// After triggering, either a RUNNING status text appears (job started)
// or a DONE/FAILED result text appears (job finished quickly or was already done).
// After triggering, a status message specific to the import operation appears.
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 });
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;
test.beforeAll(async ({ request }) => {
const baseURL = process.env.E2E_BASE_URL ?? 'http://localhost:3000';
// Create a document with a PDF file.
const createRes = await request.post('/api/documents', {
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()}`);
pdfDocHref = `${baseURL}/documents/${doc.id}`;
pdfDocHref = `/documents/${doc.id}`;
// Create a document WITHOUT a file — used to verify no canvas is rendered.
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()}`);
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 ({
@@ -306,8 +304,7 @@ test.describe('PDF annotations — admin', () => {
});
if (!uploadRes.ok()) throw new Error(`Upload PDF failed: ${uploadRes.status()}`);
const baseURL = process.env.E2E_BASE_URL ?? 'http://localhost:3000';
annotationDocHref = `${baseURL}/documents/${doc.id}`;
annotationDocHref = `/documents/${doc.id}`;
sharedAnnotationDocId = doc.id;
});
@@ -404,7 +401,6 @@ test.describe('PDF annotations — admin', () => {
// ─── PDF Annotations — file hash (version awareness) ─────────────────────────
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');
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()}`);
// 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.locator('canvas').first().waitFor({ state: 'visible', timeout: 20000 });
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()}`);
// 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.locator('canvas').first().waitFor({ state: 'visible', timeout: 20000 });
@@ -548,8 +544,7 @@ test.describe('PDF annotations — read-only user', () => {
await page.waitForURL('/');
// Navigate directly to the PDF document created by the admin beforeAll.
const baseURL = process.env.E2E_BASE_URL ?? 'http://localhost:3000';
await page.goto(`${baseURL}/documents/${sharedAnnotationDocId}`);
await page.goto(`/documents/${sharedAnnotationDocId}`);
await page.waitForSelector('[data-hydrated]');
// 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 ────────────────────────────────
//
// 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)', () => {
const baseURL = process.env.E2E_BASE_URL ?? 'http://localhost:3000';
let tagDocHref: string;
let tagDocId: string;
let seedDocId: string;
let seededTagName: string;
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', {
multipart: { title: 'E2E Tag Edit Test' }
multipart: { title: `E2E Tag Edit Test ${stamp}` }
});
if (!createRes.ok()) throw new Error(`Create document failed: ${createRes.status()}`);
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 }) => {
await page.goto(`${tagDocHref}/edit`);
await page.goto(`/documents/${tagDocId}/edit`);
await page.waitForSelector('[data-hydrated]');
// TagInput has placeholder "Schlagworte hinzufügen..." when empty.
const tagInput = page.getByPlaceholder('Schlagworte hinzufügen...');
await expect(tagInput).toBeVisible();
// Type the beginning of the seeded "Familie" tag and wait for the suggestion.
await tagInput.fill('Fami');
const suggestion = page.getByRole('option', { name: /Familie/i }).first();
// Type the seeded tag name and wait for the suggestion.
await tagInput.fill(seededTagName);
const suggestion = page.getByRole('option', { name: seededTagName }).first();
await expect(suggestion).toBeVisible({ timeout: 5_000 });
await suggestion.click();
// Save the document.
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.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' });
});
});
@@ -605,24 +628,30 @@ test.describe('Document editing — tags (J3)', () => {
// ── J4: Create a brand-new tag via TagInput ────────────────────────────────
//
// 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)', () => {
const baseURL = process.env.E2E_BASE_URL ?? 'http://localhost:3000';
let newTagDocHref: string;
const newTagName = `E2E-Tag-${Date.now().toString(36)}`;
let newTagDocId: string;
const stamp = Date.now().toString(36);
const newTagName = `E2E-Tag-${stamp}`;
test.beforeAll(async ({ request }) => {
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()}`);
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 }) => {
await page.goto(`${newTagDocHref}/edit`);
test.afterAll(async ({ request }) => {
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]');
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();
// Detail page after redirect — tag link must be visible.
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' });
});
});
@@ -650,10 +690,11 @@ test.describe('Document editing — new tag creation (J4)', () => {
test.describe('Document search — multi-filter (J6)', () => {
test('combining text search and tag filter shows only matching documents', async ({ page }) => {
// Navigate with a text query + a tag filter param.
// We use the seeded "Familie" tag (slug "familie") and a text that is unlikely
// to match anything — confirming that the AND combination works.
await page.goto('/?q=zzz_unlikely&tagId=nonexistent-tag-id');
// Navigate with a text query + a tag filter param. Using an unlikely text string and
// a nonexistent tag name confirms that the AND combination of both filters returns no
// results without relying on seeded data. Note: the correct URL param is "tag" (tag name),
// not "tagId".
await page.goto('/?q=zzz_unlikely&tag=zzz-nonexistent-tag-name');
await page.waitForSelector('[data-hydrated]');
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.beforeAll(async ({ request }) => {
const baseURL = process.env.E2E_BASE_URL ?? 'http://localhost:3000';
const createRes = await request.post('/api/documents', {
multipart: { title: 'E2E Deep-Link Test', documentDate: '1945-05-08' }
});
if (!createRes.ok()) throw new Error(`Create document failed: ${createRes.status()}`);
const doc = await createRes.json();
docId = doc.id;
docHref = `${baseURL}/documents/${docId}`;
docHref = `/documents/${docId}`;
const uploadRes = await request.put(`/api/documents/${docId}`, {
multipart: {
@@ -74,6 +72,10 @@ test.describe('Notification deep-link scroll', () => {
commentId = comment.id;
});
test.afterAll(async ({ request }) => {
if (docId) await request.delete(`/api/documents/${docId}`);
});
async function openDeepLink(page: Page) {
const url = `${docHref}?commentId=${commentId}&annotationId=${annotationId}`;
await page.goto(url);
@@ -90,7 +92,9 @@ test.describe('Notification deep-link scroll', () => {
await openDeepLink(page);
// 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
const article = page.locator(`#comment-${commentId}`);
@@ -119,16 +123,17 @@ test.describe('Notification deep-link scroll', () => {
// ── Notification bell — J10 ────────────────────────────────────────────────
//
// Verifies the notification bell in the global header: clicking it opens the
// dropdown, an unread notification is visible, clicking it marks it as read
// and navigates to the target document.
// dropdown and it closes on Escape. Full mark-as-read and navigation flows are
// tracked in a follow-up issue.
test.describe('Notification bell', () => {
let bellDocId: string;
test.beforeAll(async ({ request }) => {
const stamp = Date.now().toString(36);
// Seed a document + comment to ensure the notification list has content to render.
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()}`);
const doc = await createRes.json();
@@ -140,6 +145,10 @@ test.describe('Notification bell', () => {
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.setTimeout(30_000);

View File

@@ -42,8 +42,9 @@ test.describe('Password reset', () => {
});
test('full password reset flow', async ({ page }) => {
const testEmail = process.env.E2E_EMAIL ?? 'admin@familyarchive.local';
const originalPassword = process.env.E2E_PASSWORD ?? 'admin123';
// Uses a dedicated low-privilege test account so the admin account is never touched.
const testEmail = 'reset@familyarchive.local';
const originalPassword = 'reset123';
const newPassword = 'NewP@ssw0rd_E2E!';
// 1. Request reset
@@ -70,7 +71,7 @@ test.describe('Password reset', () => {
// 5. Log in with new password
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.getByRole('button', { name: 'Anmelden' }).click();
await expect(page).toHaveURL('/');
@@ -85,7 +86,7 @@ test.describe('Password reset', () => {
await expect(page).toHaveURL(/\/login/);
// 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.getByRole('button', { name: 'Anmelden' }).click();
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.
test.describe('Person relationship — add via edit page (J5)', () => {
const baseURL = process.env.E2E_BASE_URL ?? 'http://localhost:3000';
let personAHref: string;
let personAId: string;
let personBName: string;
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()}`);
const a = await aRes.json();
personAHref = `${baseURL}/persons/${a.id}`;
personAId = a.id;
const bRes = await request.post('/api/persons', {
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 ({
page
}) => {
await page.goto(`${personAHref}/edit`);
await page.goto(`/persons/${personAId}/edit`);
await page.waitForSelector('[data-hydrated]');
// 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.
await page.getByRole('button', { name: 'Hinzufügen' }).click();
// The relationship chip should appear in the Stammbaum section.
await expect(page.getByText(personBName)).toBeVisible({ timeout: 8_000 });
// The relationship chip should appear inside the Beziehungen section.
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' });
});
});