Two export paths, both permission-aware (permissions.md):
- `GET /ponds/:id/export/markdown` streams a ZIP of the pond's readable
pages as Markdown (one `<slug>.md` per page, a `media/` directory,
wikilinks rewritten to relative `[text](slug.md)` links, image sources to
`media/<id>.<ext>`). The `reader` guard is "may see the pond"; the service
filters to the pages the requester may actually read, so a label-restricted
reader gets only their slice. Media is appended as read streams and pages as
small strings, so memory stays bounded for a large pond (500-page test).
- `POST /pages/:id/export {format: docx|odt}` enqueues a `markdown → pandoc →
file` conversion job (the #62 queue): embedded images are inlined as data
URIs so the sidecar embeds them, wikilinks flatten to text. The client polls
`GET /jobs/:id` and downloads `GET /jobs/:id/result`.
Frontend: office-export buttons in the page menu (`.docx`/`.odt` run the job
and download the result; PDF is a disabled placeholder for Gotenberg, #67) and
a "Download pond as ZIP" link in pond settings. New `export` i18n namespace
(de+en). Markdown copy/download stay as-is (#30).
Robustness: the pond ZIP skips an attachment whose bytes are missing on disk
(data drift) rather than letting an unhandled read-stream error crash the api;
`FileStorageService.exists` gates inclusion, with a defensive stream error
handler. The per-page export drops an unreadable image the same way.
- shared: EXPORT_FORMATS + pageExportInputSchema; export-markdown transform
helpers (image/wikilink rewrites, MIME→extension).
- deps: archiver (streaming ZIP; v7 for CommonJS compat), fflate (dev, reads
ZIPs in tests).
- tests: export-markdown unit + export.service.db (ZIP contents & relative
links, label-restricted omission, docx job with inlined images, 500-page
streaming, missing-media skip); e2e export pack (ZIP download; `.docx`
self-skips without a pandoc sidecar, as in the import pack, #64).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EwZ4jR4KFAPvpjWevfUGX1
8.3 KiB
End-to-end tests
Playwright suites, most local-only — three run in CI/CD:
| Suite | Target | Where it runs |
|---|---|---|
smoke.spec.ts |
any deployed stage | CD pipeline against https://test.dorfteich.cloud after every deploy |
auth.spec.ts |
full local stack with Mailpit | CI job auth-e2e on every PR/push; locally against the dev stack |
content.spec.ts |
full local stack | same CI job auth-e2e (a second step), right after the auth pack |
| everything else | full local stack | locally only — editor/sidebar/image/link/markdown/trash.spec.ts |
content.spec.ts is the M2 content regression pack (issue #32): page
lifecycle, editor basics, image paste, Markdown round-trip, and trash —
enough to catch a regression across the whole content model without
re-running every edge case the feature-specific packs above already cover.
Its Markdown round-trip test is a real regression pin, not just a smoke
check: it compares the seeded "Every Element" fixture page's exported
Markdown byte-for-byte against the checked-in content-page.md (see
"Content fixtures" below) — any schema/serializer change that alters how a
node round-trips fails it, once the seed has re-run against the changed
code (build → migrate → seed → test, exactly CI's order).
Running locally
# 1. Stack: database + Mailpit, api (3001), web dev server (5173)
docker compose -f deploy/compose/docker-compose.yml -f deploy/compose/compose.dev.yml up -d db mailpit
DATABASE_URL=postgresql://dorfteich:dorfteich@localhost:5434/dorfteich pnpm --filter @dorfteich/api db:seed
DATABASE_URL=postgresql://dorfteich:dorfteich@localhost:5434/dorfteich PORT=3001 pnpm --filter @dorfteich/api start:dev &
pnpm --filter @dorfteich/web dev &
# 2. Tests
E2E_BASE_URL=http://localhost:5173 E2E_MAILPIT_URL=http://localhost:8025 pnpm --filter @dorfteich/web e2e
auth.spec.ts skips itself when E2E_MAILPIT_URL is unset, so the CD
smoke run never trips over it.
Fixture matrix
Seeded by pnpm --filter @dorfteich/api db:seed (idempotent — re-running
never duplicates). Shared password: fixture passwort 123. Fixtures exist
only on dev machines and disposable CI/Test databases.
| Username | State | Purpose |
|---|---|---|
fixture-admin |
active, Site Admin | admin UI/permissions cases |
fixture-user |
active | regular journeys, settings, sessions |
fixture-editor |
active | second regular account for the collab-permissions pack (reader/editor of another's pond) |
fixture-viewer |
active | signed-in non-member for authenticated/public access-rule cases (issue #55) |
fixture-outsider |
active | the "foreign user" of the permission matrix — member of nothing (issue #60) |
fixture-pending |
e-mail not verified | unverified-login cases |
Permission matrix (permission-matrix.spec.ts, issue #60)
The cross-feature permission hardening pack pins the security-relevant subject × surface combinations against regressions. It is API-level (the UI adds nothing over the resolved status code) and enforces the 404-vs-403 policy: an unauthorized read is 404 (existence hidden), an unauthorized write on something readable is 403.
- Subjects: site admin (
fixture-admin), pond admin / owner (fixture-user), editor (fixture-editor), the same editor label-restricted by asecret-label deny, reader (fixture-viewer), public (anonymous), and the foreign user (fixture-outsider, a member of nothing). - Surfaces: page read, edit (collab-token
rw/ro), sidebar list, search, versions (history = write), media, and the public HTML endpoint. - Extending it: a new permission-touching feature adds a surface here (one
expectrow per subject) rather than a bespoke test, so the matrix stays the one place the policy is pinned. A weakened guard is caught here — verified by temporarily loosening a route decorator and watching the pack go red.
Attachments (attachments.spec.ts, issue #61)
Non-image attachments: a page's attachments section uploads an allowlisted
file, lists it, and inserts it into the document as a download link (verified
to serve with Content-Disposition: attachment + nosniff, never inline); a
disallowed extension is rejected with the localized allowlist error; the Pond
Admin file manager reports storage usage and flags an orphan (a pond-level
upload with no embedding page). The SVG sanitize/reject policy is covered at
the api level in files.e2e.db.test.ts.
Import (import.spec.ts, issue #64)
The sidebar "import document" action: pick a file, upload, watch progress, open
the new page. .md imports directly (the response is already succeeded);
.docx/.odt poll a conversion job. Cases: a .docx corpus fixture (#63)
opens the converted page, a .md opens directly, an unsupported .txt shows
the localized error and creates no page, and two concurrent .md imports both
complete. The .docx case self-skips unless E2E_PANDOC is set — CI's e2e
stack has no reachable pandoc sidecar (jobs are container-networked; same reason
the api's real-pandoc fixtures test skips in CI, #63), so it runs locally / on a
stage. Run it locally with a sidecar reachable at the api's PANDOC_URL:
docker run -d -p 3030:3030 pandoc/core:3.6 server # api PANDOC_URL → this
E2E_PANDOC=1 E2E_BASE_URL=http://localhost:5990 \
pnpm --filter @dorfteich/web exec playwright test e2e/import.spec.ts
Export (export.spec.ts, issue #65)
The pond-settings "Download pond as ZIP" link (a Markdown ZIP of the readable
pages) and the page-menu office export. The ZIP download needs no sidecar; the
.docx export runs a conversion job and, like the import .docx case,
self-skips unless E2E_PANDOC is set. The permission-filtered ZIP contents,
the docx pandoc output, and the 500-page streaming path are covered at the api
level in export.service.db.test.ts (+ export-markdown.test.ts).
Content fixtures
db:seed also creates a shared pond content-fixtures (owned by
fixture-user) with two pages, for the content regression pack and manual
QA:
- Every Element (
every-element) — every editor schema node and mark (issue #24: headings 1–4, all list types, table, blockquote, code block, horizontal rule, hard break, and all five marks). Loaded from the checked-inapps/api/prisma/fixtures/content-page.yjs, a Yjs snapshot generated from the human-readablecontent-page.mdnext to it —content-page.mdis the thing to read or edit; the.yjsfile is a build artifact of it, not source. - Fixture Image (
fixture-image) — one real, servable uploaded image (the placeholderfileIdinside the Markdown fixture above is not a real attachment; this page's image is).
Regenerating after editing content-page.md:
pnpm --filter @dorfteich/api fixtures:regenerate
This is deterministic — re-running without editing the Markdown produces a
byte-identical .yjs file (the script pins the Yjs document's clientID,
which is otherwise randomized per Y.Doc instance) — and it refuses to
write a snapshot that isn't a fixed point of the Markdown round-trip
(docToMarkdown(markdownToDoc(x)) === x), so a stale fixture can't get
checked in silently.
Conventions
- New feature packs get their own
<feature>.spec.tsnext to these and extend the fixture matrix here (permission matrix arrives with M5, issue #60). - Use
contextForUser()fromhelpers.tsfor signed-in tests — it logs in through the api and hands you a browser context with the session cookie, no UI login repetition. - Flaky tests are defects (ADR 0014): fix or quarantine immediately.