Compare commits
12 Commits
20cceefbe1
...
feat/issue
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f02c59dd98 | ||
|
|
a5d20f264e | ||
|
|
39e7ee2c71 | ||
|
|
f14c8b9eea | ||
|
|
2632434263 | ||
|
|
649c3f8f8a | ||
|
|
5518122b69 | ||
|
|
64110033bd | ||
|
|
29bf45d15a | ||
|
|
3f25f1fd73 | ||
|
|
fcd91c2e81 | ||
|
|
c7bf35f011 |
@@ -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
93
README.md
Normal 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 30–60 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.
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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' });
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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('/');
|
||||
|
||||
@@ -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' });
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user