initial backend: Dokumente, Historie, API und Persistenz (D76)

- Liquibase-Schema (`document`, `document_history`) + Rollback-scripts
- Spring Boot with JPA-repositories, entities and services
- REST-API (`/documents`, `/documents/{id}`, `/documents/{id}/history`)
- OpenAPI-specifikation for CRUD-Operationen and history
- config files (`application.yaml`, `Liquibase`, H2 im PostgreSQL-Modus)
- preps for future live-editing/delta-updates
- Exceptions for conflikt- and not-found cases (409/404)
- keeping document hostory even after `delete` for RESTORE functionality
This commit is contained in:
mhoennig
2026-08-26 12:56:27 +02:00
parent 4e0ce51820
commit 0446e3d81e
31 changed files with 2060 additions and 7 deletions
+114 -7
View File
@@ -1,9 +1,116 @@
# Werkbaum Backend
# Editor Backend Grundgerippe
Noch nicht bootstrapped. Vorgesehener Weg:
CRUD-Skelett mit **Spring Boot 4**, **Kotlin** und **API First** (OpenAPI 3, YAML).
Die vier HTTP-Befehle (GET, POST, PUT, DELETE) sind für die Ressource `Document`
umgesetzt bewusst noch **ohne Autorisierung**, aber mit vorbereiteten
Erweiterungspunkten für Live-Editing, Autorisierung und clientseitige
Verschlüsselung.
1. Gerüst über https://start.spring.io erzeugen: Kotlin · Gradle (Kotlin DSL) ·
JDK 21 · Abhängigkeiten: Spring Web, Spring Data JPA, Validation.
2. Inhalt dieses Ordners (CLAUDE.md, README.md) beibehalten, Gerüst
hineinlegen, Paketwurzel `de.werkbaum`.
3. Konventionen: siehe CLAUDE.md in diesem Ordner.
## Voraussetzungen
- JDK 21
- Gradle 9 (einmalig `gradle wrapper --gradle-version 9.1` ausführen, danach `./gradlew`)
## Wichtige Kommandos
| Kommando | Zweck |
|------------------------------|------------------------------------------------------------------------------|
| `./gradlew openApiGenerate` | Generiert API-Interfaces + Modelle aus `src/main/resources/openapi/api.yaml` |
| `./gradlew build` | Generierung, Kompilierung, alle Tests, Coverage-Prüfung |
| `./gradlew test` | Unit- und Behavior-Tests (Cucumber) |
| `./gradlew jacocoTestReport` | Coverage-Report unter `build/reports/jacoco/test/html` |
| `./gradlew bootRun` | Startet das Backend auf Port 8080 |
## API First Ablauf
1. Vertrag ändern: `src/main/resources/openapi/api.yaml`
2. `./gradlew openApiGenerate` → erzeugt `DocumentsApi` (Interface) und Modelle
nach `build/generated/openapi` (Pakete `com.example.editor.generated.*`)
3. `DocumentsController` implementiert das Interface mit
`skipDefaultInterface=true`: Weicht die Implementierung vom Vertrag ab,
**bricht der Build** Spezifikation und Code können nicht auseinanderlaufen.
Generierter Code wird nicht eingecheckt und zählt nicht zur Code Coverage.
## Teststrategie
- **Behavior-Tests (Cucumber, `src/test/resources/features/dokumente.feature`)**
testen die API von außen gegen die laufende Anwendung (`RANDOM_PORT`):
Statuscodes, Payloads, Fehlerpfade. Die Szenarien sind auf Deutsch
(`# language: de`) und dienen als lebende Dokumentation.
- **Unit-Tests (JUnit 5 + MockK)** decken die Geschäftslogik im
`DocumentService` isoliert ab (Versionierung, Zeitstempel per festem `Clock`,
Fehlerfälle).
- **Coverage**: JaCoCo, Verifikation mit mind. 80 % Line Coverage
(`jacocoTestCoverageVerification`, hängt an `check`).
## Architektur
```
api/ DocumentsController (implementiert generiertes Interface),
GlobalExceptionHandler (RFC 9457 ProblemDetail)
service/ DocumentService (Geschäftslogik, Versionierung), Clock-Bean
repository/ DocumentRepository + DocumentHistoryRepository (Interfaces)
persistence/ JPA-Entities, Spring-Data-Repositories und Adapter (H2/Liquibase)
domain/ Document (internes Modell, getrennt vom API-Modell)
```
Das interne Domänenmodell ist bewusst vom generierten API-Modell getrennt
so können API-Vertrag und Persistenz unabhängig voneinander weiterentwickelt
werden.
## Historie & Wiederherstellung
- Jede Änderung (CREATED, UPDATED, DELETED, RESTORED) wird als Snapshot in
einer vom Dokument getrennten Historie protokolliert sie **überlebt ein
DELETE**.
- `GET /api/v1/documents/{uuid}/history` liefert alle Einträge (älteste
zuerst); Identifier ist die UUID, wie bei GET (der Titel ist nicht eindeutig).
- `POST /api/v1/documents/{uuid}/restore` stellt ein gelöschtes Dokument unter
derselben UUID wieder her (letzter Stand vor dem Löschen). Mit optionalem
Body `{"version": n}` wird eine bestimmte Version wiederhergestellt das
funktioniert auch als Rollback für noch existierende Dokumente; ohne
Zielversion antwortet der Server bei existierendem Dokument mit 409.
## Vorbereitete Erweiterungen
**Autorisierung**
- `bearerAuth` (JWT) ist in der OpenAPI-Spec als Security Scheme definiert,
aber noch auf keine Operation angewendet.
- Später: `spring-boot-starter-security` + `security: [bearerAuth]` in der
Spec; die Behavior-Tests erhalten dann einen Auth-Schritt
(„Angenommen ich bin als … angemeldet").
**Live-Editing**
- Jedes Dokument trägt eine `version`, die bei jedem Update inkrementiert
wird Basis für Optimistic Locking (HTTP 409 ist in der Spec bereits
reserviert) und für Delta-Synchronisation über WebSocket/STOMP.
- `DocumentUpdateRequest.expectedVersion` ist bereits im Vertrag vorgesehen,
wird aber noch nicht ausgewertet.
**Clientseitige Verschlüsselung**
- `content` ist ein opaker String, den der Server nie interpretiert. Der
Wechsel auf Ciphertext erfordert keine API-Änderung; ggf. kommen später
Metadaten-Felder (z. B. Schlüssel-ID, Nonce) als eigene Properties hinzu.
## Persistenz
- **H2 im File-Modus** (`./data/editor.mv.db`) mit `MODE=PostgreSQL`
läuft im Server-Prozess, keine Datenbank-Installation nötig. Dokumente und
Historie überleben einen Neustart.
- **Schema per Liquibase im formatierten SQL-Format**
(`src/main/resources/db/changelog/db.changelog-master.sql`, kein XML).
Neue Änderungen werden als weitere `--changeset`-Blöcke angehängt;
Hibernate validiert nur (`ddl-auto: validate`).
- **Umstieg auf echtes PostgreSQL:** im Wesentlichen JDBC-URL/Credentials in
der `application.yaml` tauschen und den Postgres-Treiber als Dependency
ergänzen Schema-Migrationen und Code bleiben unverändert.
- Tests laufen gegen H2 in-memory (`src/test/resources/application.yaml`),
mit demselben Liquibase-Schema.
- Service-Methoden sind `@Transactional`: Dokument-Änderung und
Historieneintrag werden atomar geschrieben.
## Hinweise
- Versionsnummern in `build.gradle.kts` (Spring Boot, OpenAPI Generator,
Cucumber, MockK) beim ersten Build ggf. auf den aktuellen Patch-Stand heben.
+133
View File
@@ -0,0 +1,133 @@
plugins {
kotlin("jvm") version "2.2.20"
kotlin("plugin.spring") version "2.2.20"
kotlin("plugin.jpa") version "2.2.20"
id("org.springframework.boot") version "4.0.2"
id("io.spring.dependency-management") version "1.1.7"
id("org.openapi.generator") version "7.25.0"
jacoco
}
group = "com.example"
version = "0.1.0-SNAPSHOT"
java {
toolchain {
languageVersion = JavaLanguageVersion.of(21)
}
}
repositories {
mavenCentral()
}
val cucumberVersion = "7.23.0"
val mockkVersion = "1.13.16"
dependencies {
implementation("org.springframework.boot:spring-boot-starter-web")
implementation("org.springframework.boot:spring-boot-starter-validation")
implementation("org.springframework.boot:spring-boot-starter-data-jpa")
// Boot 4: Liquibase-Autokonfiguration liegt im eigenen Starter (zieht liquibase-core mit)
implementation("org.springframework.boot:spring-boot-starter-liquibase")
runtimeOnly("com.h2database:h2")
implementation("org.jetbrains.kotlin:kotlin-reflect")
// Jackson 3 (Standard in Spring Boot 4) + Kotlin-Modul
implementation("tools.jackson.module:jackson-module-kotlin")
// --- Tests ---
testImplementation("org.springframework.boot:spring-boot-starter-test")
// TestRestTemplate liegt in Boot 4 im eigenen Modul (org.springframework.boot.resttestclient)
// und braucht spring-boot-restclient zur Laufzeit
testImplementation("org.springframework.boot:spring-boot-resttestclient")
testRuntimeOnly("org.springframework.boot:spring-boot-restclient")
testImplementation("io.mockk:mockk:$mockkVersion")
// Behavior-Tests (BDD) mit Cucumber
testImplementation("io.cucumber:cucumber-java:$cucumberVersion")
testImplementation("io.cucumber:cucumber-spring:$cucumberVersion")
testImplementation("io.cucumber:cucumber-junit-platform-engine:$cucumberVersion")
testImplementation("org.junit.platform:junit-platform-suite")
}
// ---------------------------------------------------------------------------
// API First: Code-Generierung aus der OpenAPI-Spezifikation
// ---------------------------------------------------------------------------
openApiGenerate {
generatorName.set("kotlin-spring")
inputSpec.set("$projectDir/src/main/resources/openapi/api.yaml")
outputDir.set(layout.buildDirectory.dir("generated/openapi").get().asFile.path)
apiPackage.set("com.example.editor.generated.api")
modelPackage.set("com.example.editor.generated.model")
configOptions.set(
mapOf(
"useSpringBoot4" to "true",
"interfaceOnly" to "true", // nur Interfaces + Modelle, Implementierung liegt bei uns
"skipDefaultInterface" to "true", // Controller MUSS alle Operationen implementieren
"useTags" to "true", // Interface-Name aus Tag: DocumentsApi
"useBeanValidation" to "true",
"documentationProvider" to "none",
"enumPropertyNaming" to "UPPERCASE",
"gradleBuildFile" to "false",
)
)
}
sourceSets {
main {
kotlin {
srcDir(layout.buildDirectory.dir("generated/openapi/src/main/kotlin"))
}
}
}
tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompile>().configureEach {
dependsOn(tasks.openApiGenerate)
}
// ---------------------------------------------------------------------------
// Tests + Code Coverage
// ---------------------------------------------------------------------------
tasks.test {
useJUnitPlatform()
finalizedBy(tasks.jacocoTestReport)
}
jacoco {
toolVersion = "0.8.13"
}
tasks.jacocoTestReport {
dependsOn(tasks.test)
reports {
xml.required = true
html.required = true
}
// Generierter Code zaehlt nicht zur Coverage
classDirectories.setFrom(
classDirectories.files.map {
fileTree(it) { exclude("com/example/editor/generated/**") }
}
)
}
tasks.jacocoTestCoverageVerification {
dependsOn(tasks.test)
classDirectories.setFrom(
classDirectories.files.map {
fileTree(it) { exclude("com/example/editor/generated/**") }
}
)
violationRules {
rule {
limit {
counter = "LINE"
minimum = "0.80".toBigDecimal()
}
}
}
}
tasks.check {
dependsOn(tasks.jacocoTestCoverageVerification)
}
@@ -0,0 +1,223 @@
# Aufgabe: Live-Editing-Client für den Werkbaum-Editor implementieren
Du arbeitest im Repository der Werkbaum-Web-App (PWA). Implementiere die
Client-Seite des Live-Editing-Protokolls gegen das Editor-Backend. Das
Protokoll ist HTTP-only (kein WebSocket): Änderungen werden als
zeilenbasierte Diffs per PATCH eingereicht, andere Clients erhalten sie über
einen Long-Polling-Feed.
**Wichtiger Kontext:** Die beiden Live-Editing-Endpunkte (`PATCH …/content`
und `GET …/changes`) sind im Backend spezifiziert, aber ggf. noch nicht
deployt. Implementiere gegen den hier definierten Vertrag und baue einen
Mock-Server (oder MSW-Handler) für die Tests. Die CRUD-Endpunkte existieren
bereits.
---
## 1. Backend-Vertrag
Basis-URL: konfigurierbar (`VITE_BACKEND_URL` o. Ä.), Pfad-Präfix `/api/v1`.
Alle Bodies sind JSON. Fehler kommen als RFC-9457 `application/problem+json`.
### 1.1 Bestehende Endpunkte (bereits verfügbar)
- `GET /documents/{uuid}`
`{ id, title, content, version, createdAt, updatedAt }`
- `content`: das komplette Werkbaum-Dokument als ein String, LF-getrennt.
- `version`: Long, wird serverseitig bei jeder Änderung inkrementiert.
- `GET /documents/{uuid}/history` → Historie (hier nicht benötigt).
- `POST /documents/{uuid}/restore` → gelöschtes Dokument wiederherstellen.
### 1.2 `PATCH /documents/{uuid}/content` — Änderung einreichen
Request:
```json
{
"baseVersion": 41,
"ops": [
{ "op": "replace", "index": 12, "count": 1, "lines": [" - [~] Backend (L) @ben"] },
{ "op": "insert", "index": 20, "lines": [" + [?] Dark mode (S)"] },
{ "op": "delete", "index": 25, "count": 2 }
]
}
```
Antworten:
| Status | Bedeutung | Body |
|---|---|---|
| 200 | akzeptiert | `{ "version": 42 }` |
| 409 | `baseVersion` veraltet | `{ "currentVersion": 43, "opsSinceBase": [ …Ops… ] }` |
| 404 | Dokument gelöscht | problem+json |
| 422 | Diff nicht anwendbar (Client-Bug) | problem+json |
Nach 200: lokale Version auf `version` setzen. Nach 409: siehe Rebase (§4).
Nach 422: Dokument einmalig komplett neu laden (GET) und Zustand ersetzen —
das ist der einzige zulässige Vollreload-Pfad.
### 1.3 `GET /documents/{uuid}/changes?since={version}&wait=25` — Feed
- 200: `{ "fromVersion": 41, "currentVersion": 43, "ops": [ … ], "events": [ { "version": 43, "changeType": "UPDATED" } ] }`
- `ops` ist das **kumulierte** Diff `fromVersion → currentVersion`,
direkt anwendbar auf den lokalen Stand, wenn `since == fromVersion`.
- `changeType``CREATED | UPDATED | DELETED | RESTORED`.
- 204: Timeout ohne Änderungen → sofort erneut pollen.
- Netzwerkfehler/Timeout des Browsers: mit Exponential Backoff
(1 s, 2 s, 4 s … max 30 s) erneut versuchen; bei Erfolg Backoff zurücksetzen.
- Bei `changeType == "DELETED"`: Editieren sperren, Banner „Dokument wurde
gelöscht" mit Restore-Button (`POST /restore`) anzeigen. Bei `RESTORED`
Sperre aufheben.
---
## 2. Diff-Format: exakte Semantik
Das Dokument ist eine Liste von Zeilen: `content.split("\n")`.
Alle Indizes sind **0-basiert und beziehen sich auf die Basisversion**
(nicht auf Zwischenstände!). Ops sind nach `index` aufsteigend sortiert und
überlappen nicht.
- `replace`: ersetzt `count` Zeilen ab `index` durch `lines`
(`lines.length` darf von `count` abweichen).
- `insert`: fügt `lines` **vor** `index` ein; `index == zeilen.length`
bedeutet anhängen.
- `delete`: entfernt `count` Zeilen ab `index`.
**Anwenden:** entweder rückwärts iterieren (höchster Index zuerst), dann
bleiben die Basis-Indizes gültig — oder vorwärts mit mitlaufendem Offset.
Implementiere `applyOps(lines: string[], ops: Op[]): string[]` als pure
Funktion, rückwärts iterierend (einfacher zu beweisen).
**Erzeugen:** implementiere `computeOps(before: string[], after: string[]): Op[]`
mit einem Standard-Zeilen-Diff (Myers; eine kleine Bibliothek wie `diff`
[jsdiff] mit `diffArrays` ist ok, dann Hunks in unsere drei Op-Typen
übersetzen). Aufeinanderfolgende delete+insert am selben Index zu `replace`
zusammenfassen.
**Invarianten (als Tests absichern):**
- `applyOps(before, computeOps(before, after)) ≡ after` (Property-Test mit
zufälligen Zeilen-Arrays, unbedingt mit Duplikaten und Leerzeilen).
- Leeres Diff (`ops: []`) wird gar nicht erst gesendet.
**Zeilenenden:** beim Laden und vor jedem `computeOps` normalisieren:
`content.replace(/\r\n?/g, "\n")`. Kein trailing-newline-Sonderfall:
`split("\n")` auf beiden Seiten konsistent verwenden.
---
## 3. Sync-Engine (Kernmodul)
Lege ein UI-unabhängiges Modul `syncEngine.ts` an mit diesem Zustand:
```ts
interface SyncState {
documentId: string;
serverVersion: number; // letzte bestätigte Server-Version
serverLines: string[]; // Stand der Server-Version (Schattenkopie)
localLines: string[]; // aktueller Editor-Inhalt
pending: Op[] | null; // gerade unterwegs befindlicher Patch
status: "idle" | "sending" | "conflict" | "deleted" | "offline";
}
```
Abläufe:
1. **Init:** `GET /documents/{id}``serverVersion`, `serverLines`,
`localLines` initialisieren; Feed-Schleife starten.
2. **Lokale Eingabe:** Editor schreibt nur `localLines`. Ein Debounce
(Empfehlung: 1500 ms nach letztem Tastendruck, zusätzlich sofort bei
Blur/Fenster-Verlassen via `visibilitychange`) triggert `flush()`.
3. **`flush()`:** wenn `pending` leer und `localLines ≠ serverLines`:
`ops = computeOps(serverLines, localLines)`, PATCH senden,
`pending = ops`, `status = "sending"`.
- 200 → `serverVersion = antwort.version`,
`serverLines = applyOps(serverLines, pending)`, `pending = null`.
Falls sich `localLines` inzwischen weiter geändert hat: erneut flushen.
- 409 → Rebase (§4).
4. **Feed-Ereignis (200):** Remote-Ops einarbeiten (§5). Niemals während
`status == "sending"` anwenden — Feed-Antworten bis zur PATCH-Antwort
puffern (Queue), sonst entstehen Races zwischen eigener und fremder
Änderung.
Nur **eine** Feed-Anfrage gleichzeitig; `AbortController` benutzen und beim
Dokumentwechsel/Unmount abbrechen.
## 4. Rebase nach 409
Gegeben: eigene ungesicherte Änderung (`localLines` vs. `serverLines`) und
`opsSinceBase` (fremd). Vorgehen:
1. `theirs = opsSinceBase`, `mine = computeOps(serverLines, localLines)`.
2. **Überlappungsprüfung:** berechne für jede Op ihren betroffenen
Zeilenbereich in Basis-Koordinaten (`[index, index + count)` bzw. für
insert `[index, index]`). Überschneidet sich ein Bereich aus `mine` mit
einem aus `theirs`**echter Konflikt**: `status = "conflict"`, UI zeigt
Dialog („Fremde Änderung übernehmen und meine verwerfen" / „Meine
erzwingen" — letzteres = fremde Ops anwenden, eigene Zeilen darüber
schreiben, als neuen Patch senden). Keine automatische Silent-Merge-Magie
bei Überlappung.
3. **Kein Überlappen (Normalfall):**
- `serverLines = applyOps(serverLines, theirs)`,
`serverVersion = currentVersion`.
- `localLines`: ebenfalls `theirs` anwenden, aber mit Index-Verschiebung
durch die eigenen, noch nicht gesendeten Edits. Einfachste korrekte
Variante: `mine` gegen `theirs` verschieben (für jede eigene Op:
Summe der Zeilendelta aller fremden Ops mit kleinerem Index addieren),
dann `localLines = applyOps(serverLines, mineShifted)`.
- Danach normal `flush()`.
## 5. Remote-Ops anwenden ohne Cursor-Verlust
Beim Einarbeiten von Feed-Ops in den Editor:
- **CodeMirror 6 / Monaco:** Ops in eine einzige Änderungs-Transaktion des
Editors übersetzen (CM6: `dispatch({changes: […]})` mit
from/to-Offsets; Monaco: `applyEdits`). Der Editor verschiebt Cursor,
Selektion und Scrollposition dann selbst korrekt. **Das ist der bevorzugte
Weg — niemals `setValue()` mit dem Gesamttext aufrufen**, das ist genau
der verbotene „Cursor springt an den Anfang"-Fall.
- **Rohe Textarea (Fallback):** Cursor via `selectionStart` in
`(zeile, spalte)` umrechnen. Pro Op: Bereich komplett unterhalb der
Cursor-Zeile → nichts; komplett oberhalb → Cursor-Zeile um das
Zeilendelta der Op verschieben; Op trifft die Cursor-Zeile → Zeile
beibehalten (ggf. auf neue Zeilenanzahl klemmen), Spalte auf neue
Zeilenlänge klemmen. Danach zurückrechnen und `setSelectionRange` setzen,
Scrollposition vorher sichern und wiederherstellen.
- **IME:** während einer aktiven Composition (`compositionstart` bis
`compositionend`) keine Remote-Ops in den Editor schreiben — in einer
Queue puffern und danach anwenden.
## 6. Was NICHT tun
- Kein WebSocket, kein SSE, kein setInterval-Kurztakt-Polling — nur die
Long-Poll-Schleife (Rate-Limit-Disziplin ist eine harte Anforderung).
- Kein Vollreload des Dokuments außer im 422-Fall.
- Keine Ops auf Zwischenständen aufsetzen: `computeOps` immer gegen
`serverLines` der bestätigten `serverVersion`.
- Keine Auto-Merges bei überlappenden Änderungen — der Nutzer entscheidet.
## 7. Tests (mindestens)
Unit (pure Funktionen, kein DOM):
- `applyOps`: jede Op-Art; Anhängen; letzte Zeile löschen; leeres Dokument;
mehrere Ops in einem Diff; Property-Test Roundtrip mit `computeOps`.
- Überlappungsprüfung und Index-Verschiebung (Rebase) mit Tabellenfällen.
- Cursor-Korrektur: Änderung oberhalb / unterhalb / auf der Cursor-Zeile;
„300-Zeilen-Dokument, Edit in Zeile 5, Cursor in Zeile 200 bleibt
inhaltlich an derselben Stelle".
Integration (Mock-Server/MSW):
- Feed liefert Ops → Editorinhalt aktualisiert, Cursor stabil.
- PATCH → 409 mit nicht überlappenden `opsSinceBase` → automatischer
Rebase + erneuter PATCH → 200.
- PATCH → 409 mit überlappenden Ops → Konfliktdialog erscheint.
- Feed meldet `DELETED` → Editor gesperrt, Restore-Flow funktioniert.
- Long-Poll 204 → nahtloses Re-Polling; Netzwerkfehler → Backoff.
## 8. Reihenfolge
1. `diff.ts`: `applyOps`, `computeOps`, Bereichs-/Shift-Helfer + Unit-Tests
2. `syncEngine.ts`: Zustand, flush, Feed-Schleife, Rebase + Tests gegen Mock
3. Editor-Anbindung (Transaktions-Anwendung, Cursor, IME)
4. UI: Konfliktdialog, Deleted-Banner, Offline-Indikator
+190
View File
@@ -0,0 +1,190 @@
# Proposal: Live-Editing über HTTP (Variante „Simpel")
Status: Entwurf zur Diskussion — noch nichts implementiert.
## Ziel und Rahmenbedingungen
- Mehrere Clients (Werkbaum-Web-App/PWA) arbeiten am selben Dokument:
ca. **10 Beobachter**, davon **23 gelegentliche Editoren**, praktisch nie
in derselben Sekunde.
- **Nur HTTP**, kein WebSocket. Wenige, sparsame Requests (Lehre aus den
aggressiven Rate-Limits der Etherpad-Integration).
- **Kein Neuladen des Dokuments** im Normalbetrieb: Clients erhalten
Zeilen-Diffs und wenden sie lokal an, damit Cursor/Scrollposition erhalten
bleiben.
- Das Werkbaum-Format ist **zeilenorientiert**; Zeilen-IDs (`#id`) sind
optional und identifizieren Knoten, nicht Zeilen. Das Protokoll arbeitet
deshalb ausschließlich auf **physischen Zeilen** und braucht keine IDs.
- Bei echtem Gleichzeitig-Konflikt: Update ablehnen, **der Client
entscheidet** (rebase, neu laden, verwerfen). Das Dokument darf nie
kaputtgehen.
## Grundidee in einem Satz
Jede Dokumentänderung ist ein **zeilenbasiertes Diff gegen eine
Basisversion**; der Server akzeptiert es nur, wenn die Basisversion noch die
aktuelle ist (Optimistic Locking auf Dokumentebene), und verteilt akzeptierte
Diffs über **Long Polling** an alle Beobachter.
## Datenmodell: das Zeilen-Diff
Ein Diff ist eine Liste von Operationen relativ zur Basisversion. Zeilen
werden über ihren **Index in der Basisversion** adressiert (0-basiert);
Operationen sind nach Index aufsteigend sortiert und überlappen nicht.
```json
{
"baseVersion": 41,
"ops": [
{ "op": "replace", "index": 12, "count": 1,
"lines": [" - [~] Backend (L) @ben"] },
{ "op": "insert", "index": 20,
"lines": [" + [?] Dark mode (S)"] },
{ "op": "delete", "index": 25, "count": 2 }
]
}
```
- `replace`: `count` Zeilen ab `index` werden durch `lines` ersetzt.
- `insert`: `lines` werden **vor** `index` eingefügt
(`index == Zeilenanzahl` = anhängen).
- `delete`: `count` Zeilen ab `index` entfallen.
Warum Indizes statt Inhalts-Hashes reichen: Der Server kennt die
Basisversion vollständig (Historie speichert Snapshots). Version + Index ist
damit eindeutig — auch bei identischen Zeilen (Leerzeilen!). Ein optionales
`"checksum"`-Feld (Hash des Gesamtdokuments der Basisversion) dient nur als
Integritätsprüfung gegen Client-Bugs.
Werkbaum-Besonderheiten sind damit automatisch abgedeckt: Fortsetzungszeilen
(` \`), `"`-Beschreibungszeilen und der `---`-Beschreibungsteil sind schlicht
physische Zeilen. Halbfertige Zwischenzustände rendert Werkbaum mit Warnung
weiter — zeilenweise Updates sind hier risikoarm.
## API-Erweiterung (OpenAPI-Spec)
### 1. `PATCH /documents/{id}/content` — Änderung einreichen
Request: das Diff-Objekt oben.
- **200 OK**: akzeptiert. Antwort: `{ "version": 42 }` (neue Version).
Der Server wendet das Diff an, inkrementiert die Dokumentversion, schreibt
einen Historieneintrag (ChangeType `UPDATED`).
- **409 Conflict**: `baseVersion` ist nicht mehr aktuell. Antwort enthält
alles, was der Client zum Weiterarbeiten braucht — **ohne Neuladen**:
```json
{
"currentVersion": 43,
"opsSinceBase": [ ...Diff von baseVersion → currentVersion... ]
}
```
Der Client entscheidet:
- **Rebase**: fremde Ops lokal anwenden; überlappen sie nicht mit den
eigenen Änderungen, eigene Ops auf neue Indizes verschieben und erneut
senden. Deckt den häufigsten Fall („jemand hat weiter oben editiert")
ohne Nutzerinteraktion ab.
- **Konflikt anzeigen**: bei Überlappung Nutzer fragen (übernehmen /
verwerfen / manuell mergen).
- **404**: Dokument gelöscht (Restore-Hinweis in der Problem-Detail-Antwort).
- **422**: Diff nicht anwendbar (Index außerhalb, Checksum-Fehler) —
deutet auf einen Client-Bug, Client sollte neu laden.
`PUT /documents/{id}` bleibt als „Ganzdokument ersetzen" bestehen
(Import, Reparatur), wertet aber künftig `expectedVersion` aus.
### 2. `GET /documents/{id}/changes?since={version}&wait={seconds}` — Änderungsfeed
Long Polling, der Kern der „Echtzeit ohne WebSocket"-Lösung:
- Gibt es bereits Änderungen nach `since`: **sofort 200** mit
```json
{
"fromVersion": 41,
"currentVersion": 43,
"ops": [ ...kumuliertes Diff 41 → 43... ],
"events": [ { "version": 43, "changeType": "UPDATED" } ]
}
```
- Sonst hält der Server die Anfrage bis zu `wait` Sekunden offen
(Empfehlung: 25 s, unterhalb üblicher Proxy-Timeouts). Kommt in der Zeit
eine Änderung, antwortet er sofort; sonst **204 No Content**, und der
Client pollt erneut.
- Latenz: praktisch sofort. Kosten: **1 offene HTTP-Anfrage pro Beobachter**,
~2,4 Requests/Minute im Leerlauf — rate-limit-freundlich, PWA-tauglich,
kein WebSocket nötig.
- `DELETED`/`RESTORED` erscheinen als Events im Feed, damit Beobachter auch
Löschung/Wiederherstellung live mitbekommen.
Client-Schleife eines Beobachters:
```
loop:
antwort = GET /changes?since=meineVersion&wait=25
wenn 200: ops lokal anwenden, meineVersion = currentVersion,
Cursor-Indizes um Verschiebungen oberhalb korrigieren
wenn 204: weiter
```
### Warum Long Polling und nicht SSE?
Server-Sent Events wären die Alternative (eine dauerhafte Verbindung,
Push vom Server). Long Polling gewinnt hier, weil es (a) reines
Request/Response-HTTP ist — trivial mit unseren Cucumber-Tests testbar,
(b) keinerlei Sonderbehandlung in Proxies/PWA-Service-Workern braucht und
(c) bei 10 Beobachtern der Effizienzunterschied irrelevant ist. Ein
späterer Umstieg auf SSE oder WebSocket ändert nur den Feed-Endpunkt;
Diff-Format und Konfliktlogik bleiben identisch.
## Serverseitige Umsetzung
- **Diff anwenden**: Snapshot der Basisversion aus der Historie laden
(bzw. aktueller Stand, wenn `baseVersion == currentVersion`, der
Normalfall), Ops anwenden, als neue Version speichern.
- **Diff berechnen** (für 409-Antwort und Feed): Zeilen-Diff zwischen zwei
Snapshots aus der Historie (Standard-Algorithmus, z. B. Myers über
`java.util`-nahe Bibliothek oder eigene simple Implementierung).
Alternativ können eingereichte Ops pro Version direkt mitgespeichert
werden — Optimierung, kein Muss für v1.
- **Long Polling**: Spring MVC `DeferredResult` + ein In-Process-Notifier
(pro Dokument eine Warteliste; `notifyAll` bei akzeptiertem Update).
Kein zusätzliches Framework nötig.
- **Serialisierung**: Updates pro Dokument strikt sequenziell
(Locking pro Dokument-UUID), damit Versionsprüfung + Anwenden atomar sind.
- **Historie**: unverändert Snapshots; das Keyframe/Kompressions-Schema aus
der Speicher-Evaluation ist eine spätere, unabhängige Optimierung hinter
dem `DocumentHistoryRepository`-Interface.
## Grenzen der simplen Variante (bewusst akzeptiert)
- Konflikterkennung auf **Dokumentebene**: Zwei Editoren, die gleichzeitig
verschiedene Stellen ändern, erzeugen formal einen Konflikt — der
Rebase-Mechanismus in der 409-Antwort löst das aber in der Praxis
transparent. Erst wenn das nicht reicht, lohnt Konflikt­prüfung pro
Zeilenbereich (die 409-Struktur bleibt dabei gleich).
- Kein Präsenz-Feature (wer ist online, fremde Cursor). Später über ein
leichtgewichtiges `presence`-Feld im Feed nachrüstbar; das `!!!`-Fokusmark
des Formats kann dafür genutzt werden.
- Clientseitige Verschlüsselung: Das Protokoll transportiert Zeilen als
opake Strings und funktioniert unverändert mit Ciphertext pro Zeile —
nur das serverseitige Diff-Berechnen entfiele dann (Clients müssten Ops
immer selbst liefern; die Struktur erlaubt das bereits).
## Teststrategie
- **Cucumber**: „Client B sieht die Änderung von Client A im Feed",
„Patch mit veralteter Basisversion liefert 409 mit opsSinceBase",
„Feed meldet DELETED", „Rebase-Fall: nicht überlappende Änderung nach 409
erneut einreichen".
- **Unit-Tests**: Diff-Anwendung (alle drei Ops, Randfälle: leeres Dokument,
Anhängen, letzte Zeile), Diff-Berechnung, Index-Verschiebung.
## Vorschlag Umsetzungsreihenfolge
1. Diff-Modell + Anwenden/Berechnen als reine Kotlin-Funktionen (Unit-Tests)
2. `PATCH /content` inkl. 409-Antwort (Spec + Cucumber)
3. `GET /changes` mit Long Polling (Spec + Cucumber)
4. Client-Anpassung (Feed-Schleife, lokales Anwenden, Rebase)
+3
View File
@@ -0,0 +1,3 @@
org.gradle.caching=true
org.gradle.parallel=true
kotlin.code.style=official
+1
View File
@@ -0,0 +1 @@
rootProject.name = "editor-backend"
@@ -0,0 +1,11 @@
package com.example.editor
import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.boot.runApplication
@SpringBootApplication
class EditorBackendApplication
fun main(args: Array<String>) {
runApplication<EditorBackendApplication>(*args)
}
@@ -0,0 +1,88 @@
package com.example.editor.api
import com.example.editor.generated.api.DocumentsApi
import com.example.editor.generated.model.Document as ApiDocument
import com.example.editor.generated.model.DocumentCreateRequest
import com.example.editor.generated.model.DocumentHistoryEntry as ApiHistoryEntry
import com.example.editor.generated.model.DocumentUpdateRequest
import com.example.editor.generated.model.RestoreRequest
import com.example.editor.domain.Document
import com.example.editor.domain.DocumentHistoryEntry
import com.example.editor.service.DocumentService
import org.springframework.http.HttpStatus
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController
import java.util.UUID
/**
* Implementiert das aus der OpenAPI-Spezifikation generierte Interface.
* Aendert sich die Spezifikation, schlaegt hier der Compile fehl so bleibt
* die Implementierung immer synchron zum Vertrag (API First).
*/
@RestController
@RequestMapping("/api/v1")
class DocumentsController(
private val service: DocumentService,
) : DocumentsApi {
override fun listDocuments(): ResponseEntity<List<ApiDocument>> =
ResponseEntity.ok(service.findAll().map { it.toApi() })
override fun createDocument(documentCreateRequest: DocumentCreateRequest): ResponseEntity<ApiDocument> {
val created = service.create(
title = documentCreateRequest.title,
content = documentCreateRequest.content,
)
return ResponseEntity.status(HttpStatus.CREATED).body(created.toApi())
}
override fun getDocument(documentId: UUID): ResponseEntity<ApiDocument> =
ResponseEntity.ok(service.findById(documentId).toApi())
override fun updateDocument(
documentId: UUID,
documentUpdateRequest: DocumentUpdateRequest,
): ResponseEntity<ApiDocument> {
val updated = service.update(
id = documentId,
title = documentUpdateRequest.title,
content = documentUpdateRequest.content,
)
return ResponseEntity.ok(updated.toApi())
}
override fun deleteDocument(documentId: UUID): ResponseEntity<Unit> {
service.delete(documentId)
return ResponseEntity.noContent().build()
}
override fun getDocumentHistory(documentId: UUID): ResponseEntity<List<ApiHistoryEntry>> =
ResponseEntity.ok(service.history(documentId).map { it.toApi() })
override fun restoreDocument(
documentId: UUID,
restoreRequest: RestoreRequest?,
): ResponseEntity<ApiDocument> {
val restored = service.restore(documentId, restoreRequest?.version)
return ResponseEntity.ok(restored.toApi())
}
private fun Document.toApi(): ApiDocument = ApiDocument(
id = id,
title = title,
content = content,
version = version,
createdAt = createdAt,
updatedAt = updatedAt,
)
private fun DocumentHistoryEntry.toApi(): ApiHistoryEntry = ApiHistoryEntry(
documentId = documentId,
version = version,
title = title,
content = content,
changeType = ApiHistoryEntry.ChangeType.valueOf(changeType.name),
timestamp = timestamp,
)
}
@@ -0,0 +1,28 @@
package com.example.editor.api
import com.example.editor.service.DocumentConflictException
import com.example.editor.service.DocumentNotFoundException
import org.springframework.http.HttpStatus
import org.springframework.http.ProblemDetail
import org.springframework.web.bind.annotation.ExceptionHandler
import org.springframework.web.bind.annotation.RestControllerAdvice
/**
* Zentrale Fehlerbehandlung im Problem-Details-Format (RFC 9457),
* passend zum ProblemDetail-Schema der OpenAPI-Spezifikation.
*/
@RestControllerAdvice
class GlobalExceptionHandler {
@ExceptionHandler(DocumentNotFoundException::class)
fun handleNotFound(ex: DocumentNotFoundException): ProblemDetail =
ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.message ?: "Nicht gefunden").apply {
title = "Dokument nicht gefunden"
}
@ExceptionHandler(DocumentConflictException::class)
fun handleConflict(ex: DocumentConflictException): ProblemDetail =
ProblemDetail.forStatusAndDetail(HttpStatus.CONFLICT, ex.message ?: "Konflikt").apply {
title = "Konflikt"
}
}
@@ -0,0 +1,21 @@
package com.example.editor.domain
import java.time.OffsetDateTime
import java.util.UUID
/**
* Internes Domänenmodell (bewusst getrennt vom generierten API-Modell).
*
* [version] wird bei jeder Änderung inkrementiert und dient später als Basis
* für Optimistic Locking und Live-Editing-Konflikterkennung.
* [content] ist ein opaker String bei clientseitiger Verschlüsselung wird
* hier später Ciphertext gespeichert, ohne dass sich das Modell ändert.
*/
data class Document(
val id: UUID,
val title: String,
val content: String,
val version: Long,
val createdAt: OffsetDateTime,
val updatedAt: OffsetDateTime,
)
@@ -0,0 +1,20 @@
package com.example.editor.domain
import java.time.OffsetDateTime
import java.util.UUID
enum class ChangeType { CREATED, UPDATED, DELETED, RESTORED }
/**
* Ein Eintrag der Dokumenthistorie. Die Historie wird getrennt vom Dokument
* gespeichert und überlebt daher ein DELETE Grundlage für die
* Wiederherstellung und später auch für Audit/Live-Editing-Replays.
*/
data class DocumentHistoryEntry(
val documentId: UUID,
val version: Long,
val title: String,
val content: String,
val changeType: ChangeType,
val timestamp: OffsetDateTime,
)
@@ -0,0 +1,51 @@
package com.example.editor.persistence
import com.example.editor.domain.Document
import jakarta.persistence.Column
import jakarta.persistence.Entity
import jakarta.persistence.Id
import jakarta.persistence.Table
import java.time.OffsetDateTime
import java.util.UUID
@Entity
@Table(name = "document")
class DocumentEntity(
@Id
val id: UUID,
@Column(nullable = false)
var title: String,
@Column(nullable = false, columnDefinition = "text")
var content: String,
@Column(nullable = false)
var version: Long,
@Column(name = "created_at", nullable = false)
var createdAt: OffsetDateTime,
@Column(name = "updated_at", nullable = false)
var updatedAt: OffsetDateTime,
) {
fun toDomain() = Document(
id = id,
title = title,
content = content,
version = version,
createdAt = createdAt,
updatedAt = updatedAt,
)
companion object {
fun fromDomain(document: Document) = DocumentEntity(
id = document.id,
title = document.title,
content = document.content,
version = document.version,
createdAt = document.createdAt,
updatedAt = document.updatedAt,
)
}
}
@@ -0,0 +1,61 @@
package com.example.editor.persistence
import com.example.editor.domain.ChangeType
import com.example.editor.domain.DocumentHistoryEntry
import jakarta.persistence.Column
import jakarta.persistence.Entity
import jakarta.persistence.EnumType
import jakarta.persistence.Enumerated
import jakarta.persistence.GeneratedValue
import jakarta.persistence.GenerationType
import jakarta.persistence.Id
import jakarta.persistence.Table
import java.time.OffsetDateTime
import java.util.UUID
@Entity
@Table(name = "document_history")
class DocumentHistoryEntity(
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
val id: Long? = null,
@Column(name = "document_id", nullable = false)
val documentId: UUID,
@Column(nullable = false)
val version: Long,
@Column(nullable = false)
val title: String,
@Column(nullable = false, columnDefinition = "text")
val content: String,
@Enumerated(EnumType.STRING)
@Column(name = "change_type", nullable = false, length = 16)
val changeType: ChangeType,
@Column(name = "change_time", nullable = false)
val changeTime: OffsetDateTime,
) {
fun toDomain() = DocumentHistoryEntry(
documentId = documentId,
version = version,
title = title,
content = content,
changeType = changeType,
timestamp = changeTime,
)
companion object {
fun fromDomain(entry: DocumentHistoryEntry) = DocumentHistoryEntity(
documentId = entry.documentId,
version = entry.version,
title = entry.title,
content = entry.content,
changeType = entry.changeType,
changeTime = entry.timestamp,
)
}
}
@@ -0,0 +1,21 @@
package com.example.editor.persistence
import com.example.editor.domain.DocumentHistoryEntry
import com.example.editor.repository.DocumentHistoryRepository
import org.springframework.stereotype.Repository
import java.util.UUID
@Repository
class JpaDocumentHistoryRepository(
private val jpa: DocumentHistoryJpaRepository,
) : DocumentHistoryRepository {
override fun append(entry: DocumentHistoryEntry) {
jpa.save(DocumentHistoryEntity.fromDomain(entry))
}
override fun findByDocumentId(documentId: UUID): List<DocumentHistoryEntry> =
jpa.findByDocumentIdOrderByIdAsc(documentId).map { it.toDomain() }
override fun clear() = jpa.deleteAll()
}
@@ -0,0 +1,35 @@
package com.example.editor.persistence
import com.example.editor.domain.Document
import com.example.editor.repository.DocumentRepository
import org.springframework.stereotype.Repository
import java.util.UUID
/**
* JPA-Adapter fuer das fachliche Repository-Interface.
* Ersetzt die fruehere In-Memory-Implementierung.
*/
@Repository
class JpaDocumentRepository(
private val jpa: DocumentJpaRepository,
) : DocumentRepository {
override fun findAll(): List<Document> =
jpa.findAll().map { it.toDomain() }.sortedBy { it.createdAt }
override fun findById(id: UUID): Document? =
jpa.findById(id).map { it.toDomain() }.orElse(null)
override fun save(document: Document): Document {
jpa.save(DocumentEntity.fromDomain(document))
return document
}
override fun deleteById(id: UUID): Boolean {
if (!jpa.existsById(id)) return false
jpa.deleteById(id)
return true
}
override fun clear() = jpa.deleteAll()
}
@@ -0,0 +1,10 @@
package com.example.editor.persistence
import org.springframework.data.jpa.repository.JpaRepository
import java.util.UUID
interface DocumentJpaRepository : JpaRepository<DocumentEntity, UUID>
interface DocumentHistoryJpaRepository : JpaRepository<DocumentHistoryEntity, Long> {
fun findByDocumentIdOrderByIdAsc(documentId: UUID): List<DocumentHistoryEntity>
}
@@ -0,0 +1,13 @@
package com.example.editor.repository
import com.example.editor.domain.DocumentHistoryEntry
import java.util.UUID
interface DocumentHistoryRepository {
fun append(entry: DocumentHistoryEntry)
/** Alle Einträge zu einem Dokument, älteste zuerst. */
fun findByDocumentId(documentId: UUID): List<DocumentHistoryEntry>
fun clear()
}
@@ -0,0 +1,17 @@
package com.example.editor.repository
import com.example.editor.domain.Document
import java.util.UUID
/**
* Abstraktion über die Persistenz. Die In-Memory-Implementierung ist ein
* Platzhalter und kann später durch JPA/R2DBC ersetzt werden, ohne dass
* Service oder Controller angepasst werden müssen.
*/
interface DocumentRepository {
fun findAll(): List<Document>
fun findById(id: UUID): Document?
fun save(document: Document): Document
fun deleteById(id: UUID): Boolean
fun clear()
}
@@ -0,0 +1,11 @@
package com.example.editor.service
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import java.time.Clock
@Configuration
class ClockConfiguration {
@Bean
fun clock(): Clock = Clock.systemUTC()
}
@@ -0,0 +1,3 @@
package com.example.editor.service
class DocumentConflictException(message: String) : RuntimeException(message)
@@ -0,0 +1,6 @@
package com.example.editor.service
import java.util.UUID
class DocumentNotFoundException(id: UUID) :
RuntimeException("Dokument mit ID $id wurde nicht gefunden")
@@ -0,0 +1,131 @@
package com.example.editor.service
import com.example.editor.domain.ChangeType
import com.example.editor.domain.Document
import com.example.editor.domain.DocumentHistoryEntry
import com.example.editor.repository.DocumentHistoryRepository
import com.example.editor.repository.DocumentRepository
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import java.time.Clock
import java.time.OffsetDateTime
import java.util.UUID
@Service
@Transactional
class DocumentService(
private val repository: DocumentRepository,
private val historyRepository: DocumentHistoryRepository,
private val clock: Clock,
) {
fun findAll(): List<Document> = repository.findAll()
fun findById(id: UUID): Document =
repository.findById(id) ?: throw DocumentNotFoundException(id)
fun create(title: String, content: String): Document {
val now = OffsetDateTime.now(clock)
val document = Document(
id = UUID.randomUUID(),
title = title,
content = content,
version = 1,
createdAt = now,
updatedAt = now,
)
repository.save(document)
recordHistory(document, ChangeType.CREATED)
return document
}
fun update(id: UUID, title: String, content: String): Document {
val existing = findById(id)
val updated = existing.copy(
title = title,
content = content,
version = existing.version + 1,
updatedAt = OffsetDateTime.now(clock),
)
repository.save(updated)
recordHistory(updated, ChangeType.UPDATED)
return updated
}
fun delete(id: UUID) {
val existing = findById(id)
repository.deleteById(id)
// Tombstone-Eintrag: konserviert den letzten Stand und überlebt das DELETE.
recordHistory(
existing.copy(
version = existing.version + 1,
updatedAt = OffsetDateTime.now(clock),
),
ChangeType.DELETED,
)
}
/**
* Historie eines Dokuments funktioniert auch für bereits gelöschte
* Dokumente. 404 nur, wenn die UUID gänzlich unbekannt ist.
*/
fun history(id: UUID): List<DocumentHistoryEntry> {
val entries = historyRepository.findByDocumentId(id)
if (entries.isEmpty()) throw DocumentNotFoundException(id)
return entries
}
/**
* Stellt ein Dokument unter derselben UUID wieder her.
*
* - Ohne [targetVersion]: letzter inhaltlicher Stand vor dem Löschen.
* Existiert das Dokument noch, gibt es einen Konflikt (409).
* - Mit [targetVersion]: Inhalt dieser Version wird als neue Version
* übernommen funktioniert auch als Rollback für existierende Dokumente.
*/
fun restore(id: UUID, targetVersion: Long? = null): Document {
val entries = historyRepository.findByDocumentId(id)
if (entries.isEmpty()) throw DocumentNotFoundException(id)
val existing = repository.findById(id)
if (existing != null && targetVersion == null) {
throw DocumentConflictException(
"Dokument $id existiert noch; zum Rollback bitte eine Zielversion angeben"
)
}
val snapshot = if (targetVersion != null) {
entries.lastOrNull { it.version == targetVersion && it.changeType != ChangeType.DELETED }
?: throw DocumentNotFoundException(id)
} else {
entries.last { it.changeType != ChangeType.DELETED }
}
val now = OffsetDateTime.now(clock)
val lastVersion = maxOf(entries.maxOf { it.version }, existing?.version ?: 0)
val restored = Document(
id = id,
title = snapshot.title,
content = snapshot.content,
version = lastVersion + 1,
createdAt = existing?.createdAt ?: entries.first().timestamp,
updatedAt = now,
)
repository.save(restored)
recordHistory(restored, ChangeType.RESTORED)
return restored
}
private fun recordHistory(document: Document, changeType: ChangeType) {
historyRepository.append(
DocumentHistoryEntry(
documentId = document.id,
version = document.version,
title = document.title,
content = document.content,
changeType = changeType,
timestamp = document.updatedAt,
)
)
}
}
@@ -0,0 +1,22 @@
spring:
application:
name: editor-backend
datasource:
# H2 im File-Modus mit PostgreSQL-Kompatibilitaet.
# Spaeterer Umstieg auf echtes PostgreSQL = im Wesentlichen nur diese URL aendern.
url: jdbc:h2:file:./data/editor;MODE=PostgreSQL;DATABASE_TO_LOWER=TRUE;DEFAULT_NULL_ORDERING=HIGH
username: sa
password: ""
driver-class-name: org.h2.Driver
jpa:
hibernate:
ddl-auto: validate # Schema kommt ausschliesslich von Liquibase
open-in-view: false
liquibase:
change-log: classpath:db/changelog/db.changelog-master.sql
server:
port: 8080
@@ -0,0 +1,28 @@
--liquibase formatted sql
--changeset editor:001-create-document
CREATE TABLE document (
id UUID PRIMARY KEY,
title VARCHAR(255) NOT NULL,
content TEXT NOT NULL,
version BIGINT NOT NULL,
created_at TIMESTAMP WITH TIME ZONE NOT NULL,
updated_at TIMESTAMP WITH TIME ZONE NOT NULL
);
--rollback DROP TABLE document;
--changeset editor:002-create-document-history
CREATE TABLE document_history (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
document_id UUID NOT NULL,
version BIGINT NOT NULL,
title VARCHAR(255) NOT NULL,
content TEXT NOT NULL,
change_type VARCHAR(16) NOT NULL,
change_time TIMESTAMP WITH TIME ZONE NOT NULL
);
--rollback DROP TABLE document_history;
--changeset editor:003-index-document-history
CREATE INDEX idx_document_history_document_id ON document_history (document_id);
--rollback DROP INDEX idx_document_history_document_id;
+308
View File
@@ -0,0 +1,308 @@
openapi: 3.0.3
info:
title: Editor Backend API
description: |
CRUD-Grundgeruest fuer Dokumente.
Vorbereitete Erweiterungen (noch nicht aktiv):
- Autorisierung: securitySchemes.bearerAuth ist definiert, wird aber noch
auf keine Operation angewendet.
- Live-Editing: Das Feld `version` dient spaeter der Konflikterkennung
(Optimistic Locking) und als Basis fuer Delta-Updates via WebSocket.
- Clientseitige Verschluesselung: `content` ist ein opaker String. Der
Server interpretiert den Inhalt nicht, sodass spaeter Ciphertext
transportiert werden kann, ohne die API zu aendern.
version: 0.1.0
servers:
- url: /api/v1
tags:
- name: Documents
description: Verwaltung von Dokumenten
paths:
/documents:
get:
tags: [Documents]
operationId: listDocuments
summary: Alle Dokumente auflisten
responses:
"200":
description: Liste aller Dokumente
content:
application/json:
schema:
type: array
items:
$ref: "#/components/schemas/Document"
post:
tags: [Documents]
operationId: createDocument
summary: Neues Dokument anlegen
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/DocumentCreateRequest"
responses:
"201":
description: Dokument wurde angelegt
content:
application/json:
schema:
$ref: "#/components/schemas/Document"
"400":
$ref: "#/components/responses/BadRequest"
/documents/{documentId}:
parameters:
- name: documentId
in: path
required: true
schema:
type: string
format: uuid
get:
tags: [Documents]
operationId: getDocument
summary: Einzelnes Dokument abrufen
responses:
"200":
description: Das angeforderte Dokument
content:
application/json:
schema:
$ref: "#/components/schemas/Document"
"404":
$ref: "#/components/responses/NotFound"
put:
tags: [Documents]
operationId: updateDocument
summary: Dokument vollstaendig aktualisieren
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/DocumentUpdateRequest"
responses:
"200":
description: Aktualisiertes Dokument
content:
application/json:
schema:
$ref: "#/components/schemas/Document"
"400":
$ref: "#/components/responses/BadRequest"
"404":
$ref: "#/components/responses/NotFound"
"409":
description: Versionskonflikt (fuer spaeteres Optimistic Locking reserviert)
content:
application/problem+json:
schema:
$ref: "#/components/schemas/ProblemDetail"
delete:
tags: [Documents]
operationId: deleteDocument
summary: Dokument loeschen
responses:
"204":
description: Dokument wurde geloescht
"404":
$ref: "#/components/responses/NotFound"
/documents/{documentId}/history:
parameters:
- name: documentId
in: path
required: true
schema:
type: string
format: uuid
get:
tags: [Documents]
operationId: getDocumentHistory
summary: Historie eines Dokuments abrufen
description: >
Liefert alle Versionen eines Dokuments in chronologischer Reihenfolge.
Die Historie bleibt auch nach dem Loeschen des Dokuments erhalten.
responses:
"200":
description: Historie des Dokuments (aelteste zuerst)
content:
application/json:
schema:
type: array
items:
$ref: "#/components/schemas/DocumentHistoryEntry"
"404":
$ref: "#/components/responses/NotFound"
/documents/{documentId}/restore:
parameters:
- name: documentId
in: path
required: true
schema:
type: string
format: uuid
post:
tags: [Documents]
operationId: restoreDocument
summary: Geloeschtes Dokument aus der Historie wiederherstellen
description: >
Stellt ein geloeschtes Dokument unter derselben UUID wieder her.
Ohne Request-Body wird der letzte Stand vor dem Loeschen
wiederhergestellt; optional kann eine bestimmte Version angegeben
werden (auch als Rollback fuer ein noch existierendes Dokument).
requestBody:
required: false
content:
application/json:
schema:
$ref: "#/components/schemas/RestoreRequest"
responses:
"200":
description: Wiederhergestelltes Dokument
content:
application/json:
schema:
$ref: "#/components/schemas/Document"
"404":
$ref: "#/components/responses/NotFound"
"409":
description: >
Dokument existiert noch und es wurde keine Zielversion angegeben.
content:
application/problem+json:
schema:
$ref: "#/components/schemas/ProblemDetail"
components:
responses:
NotFound:
description: Ressource nicht gefunden
content:
application/problem+json:
schema:
$ref: "#/components/schemas/ProblemDetail"
BadRequest:
description: Ungueltige Anfrage
content:
application/problem+json:
schema:
$ref: "#/components/schemas/ProblemDetail"
schemas:
Document:
type: object
required: [id, title, content, version, createdAt, updatedAt]
properties:
id:
type: string
format: uuid
readOnly: true
title:
type: string
maxLength: 255
content:
type: string
description: >
Opaker Inhalt. Bei clientseitiger Verschluesselung enthaelt dieses
Feld spaeter den Ciphertext.
version:
type: integer
format: int64
description: Wird bei jeder Aenderung inkrementiert (Basis fuer Live-Editing/Konflikterkennung).
createdAt:
type: string
format: date-time
readOnly: true
updatedAt:
type: string
format: date-time
readOnly: true
DocumentCreateRequest:
type: object
required: [title, content]
properties:
title:
type: string
minLength: 1
maxLength: 255
content:
type: string
DocumentUpdateRequest:
type: object
required: [title, content]
properties:
title:
type: string
minLength: 1
maxLength: 255
content:
type: string
expectedVersion:
type: integer
format: int64
description: Optional; wird spaeter fuer Optimistic Locking ausgewertet.
DocumentHistoryEntry:
type: object
required: [documentId, version, title, content, changeType, timestamp]
properties:
documentId:
type: string
format: uuid
version:
type: integer
format: int64
title:
type: string
content:
type: string
changeType:
type: string
enum: [CREATED, UPDATED, DELETED, RESTORED]
timestamp:
type: string
format: date-time
RestoreRequest:
type: object
properties:
version:
type: integer
format: int64
description: >
Optionale Zielversion. Ohne Angabe wird der letzte inhaltliche
Stand vor dem Loeschen wiederhergestellt.
ProblemDetail:
type: object
description: Fehlerformat nach RFC 9457 (Problem Details)
properties:
type:
type: string
title:
type: string
status:
type: integer
detail:
type: string
instance:
type: string
securitySchemes:
bearerAuth:
type: http
scheme: bearer
bearerFormat: JWT
description: >
Noch nicht aktiv. Wird bei Einfuehrung der Autorisierung auf die
Operationen angewendet (security: - bearerAuth: []).
@@ -0,0 +1,28 @@
package com.example.editor.bdd
import com.example.editor.repository.DocumentHistoryRepository
import com.example.editor.repository.DocumentRepository
import io.cucumber.java.Before
import io.cucumber.spring.CucumberContextConfiguration
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.resttestclient.autoconfigure.AutoConfigureTestRestTemplate
import org.springframework.boot.test.context.SpringBootTest
@CucumberContextConfiguration
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
// Seit Boot 4 stellt @SpringBootTest die TestRestTemplate-Bean nicht mehr von selbst bereit
@AutoConfigureTestRestTemplate
class CucumberSpringConfiguration {
@Autowired
private lateinit var repository: DocumentRepository
@Autowired
private lateinit var historyRepository: DocumentHistoryRepository
@Before
fun resetState() {
repository.clear()
historyRepository.clear()
}
}
@@ -0,0 +1,15 @@
package com.example.editor.bdd
import io.cucumber.junit.platform.engine.Constants.GLUE_PROPERTY_NAME
import io.cucumber.junit.platform.engine.Constants.PLUGIN_PROPERTY_NAME
import org.junit.platform.suite.api.ConfigurationParameter
import org.junit.platform.suite.api.IncludeEngines
import org.junit.platform.suite.api.SelectClasspathResource
import org.junit.platform.suite.api.Suite
@Suite
@IncludeEngines("cucumber")
@SelectClasspathResource("features")
@ConfigurationParameter(key = GLUE_PROPERTY_NAME, value = "com.example.editor.bdd")
@ConfigurationParameter(key = PLUGIN_PROPERTY_NAME, value = "pretty")
class CucumberTest
@@ -0,0 +1,177 @@
package com.example.editor.bdd
import io.cucumber.java.de.Angenommen
import io.cucumber.java.de.Dann
import io.cucumber.java.de.Und
import io.cucumber.java.de.Wenn
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNotNull
import org.junit.jupiter.api.Assertions.assertTrue
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.resttestclient.TestRestTemplate
import org.springframework.http.HttpEntity
import org.springframework.http.HttpHeaders
import org.springframework.http.HttpMethod
import org.springframework.http.MediaType
import org.springframework.http.ResponseEntity
/**
* Behavior-Tests gegen die laufende Anwendung (RANDOM_PORT), also echtes
* Verhalten der API inklusive Serialisierung, Statuscodes und Fehlerpfaden.
*/
class DocumentStepDefinitions {
@Autowired
private lateinit var rest: TestRestTemplate
private var lastResponse: ResponseEntity<String>? = null
private var currentDocumentId: String? = null
private fun jsonEntity(body: String): HttpEntity<String> {
val headers = HttpHeaders().apply { contentType = MediaType.APPLICATION_JSON }
return HttpEntity(body, headers)
}
private fun createDocument(title: String, content: String): ResponseEntity<String> =
rest.postForEntity(
"/api/v1/documents",
jsonEntity("""{"title":"$title","content":"$content"}"""),
String::class.java,
)
private fun extractId(body: String?): String {
val match = Regex("\"id\"\\s*:\\s*\"([^\"]+)\"").find(body ?: "")
assertNotNull(match, "Antwort enthält keine ID: $body")
return match!!.groupValues[1]
}
// ---------------- Angenommen ----------------
@Angenommen("es existiert ein Dokument mit dem Titel {string}")
fun `es existiert ein Dokument`(titel: String) {
val response = createDocument(titel, "Initialer Inhalt")
assertEquals(201, response.statusCode.value(), "Testdatenanlage fehlgeschlagen")
currentDocumentId = extractId(response.body)
}
// ---------------- Wenn ----------------
@Wenn("ich ein Dokument mit dem Titel {string} und dem Inhalt {string} anlege")
fun `ich lege ein Dokument an`(titel: String, inhalt: String) {
lastResponse = createDocument(titel, inhalt)
currentDocumentId = Regex("\"id\"\\s*:\\s*\"([^\"]+)\"")
.find(lastResponse?.body ?: "")?.groupValues?.get(1)
}
@Wenn("ich alle Dokumente abrufe")
fun `ich rufe alle Dokumente ab`() {
lastResponse = rest.getForEntity("/api/v1/documents", String::class.java)
}
@Wenn("ich dieses Dokument abrufe")
fun `ich rufe dieses Dokument ab`() {
lastResponse = rest.getForEntity("/api/v1/documents/$currentDocumentId", String::class.java)
}
@Wenn("ich ein Dokument mit einer unbekannten ID abrufe")
fun `ich rufe ein unbekanntes Dokument ab`() {
lastResponse = rest.getForEntity(
"/api/v1/documents/00000000-0000-0000-0000-000000000000",
String::class.java,
)
}
@Wenn("ich den Titel dieses Dokuments auf {string} ändere")
fun `ich aendere den Titel`(neuerTitel: String) {
lastResponse = rest.exchange(
"/api/v1/documents/$currentDocumentId",
HttpMethod.PUT,
jsonEntity("""{"title":"$neuerTitel","content":"Aktualisierter Inhalt"}"""),
String::class.java,
)
}
@Wenn("ich dieses Dokument lösche")
fun `ich loesche dieses Dokument`() {
lastResponse = rest.exchange(
"/api/v1/documents/$currentDocumentId",
HttpMethod.DELETE,
HttpEntity.EMPTY,
String::class.java,
)
}
// ---------------- Dann / Und ----------------
@Dann("erhalte ich den Status {int}")
fun `erhalte ich den Status`(status: Int) {
assertEquals(status, lastResponse?.statusCode?.value())
}
@Und("die Antwort enthält den Titel {string}")
fun `die Antwort enthaelt den Titel`(titel: String) {
assertTrue(
lastResponse?.body?.contains("\"title\":\"$titel\"") == true,
"Erwarteter Titel '$titel' nicht in Antwort: ${lastResponse?.body}",
)
}
@Und("die Antwort enthält {int} Dokumente")
fun `die Antwort enthaelt n Dokumente`(anzahl: Int) {
val count = Regex("\"id\"").findAll(lastResponse?.body ?: "").count()
assertEquals(anzahl, count, "Antwort: ${lastResponse?.body}")
}
@Und("die Antwort enthält die Version {long}")
fun `die Antwort enthaelt die Version`(version: Long) {
assertTrue(
lastResponse?.body?.contains("\"version\":$version") == true,
"Erwartete Version $version nicht in Antwort: ${lastResponse?.body}",
)
}
@Und("das Dokument ist nicht mehr abrufbar")
fun `das Dokument ist nicht mehr abrufbar`() {
val response = rest.getForEntity("/api/v1/documents/$currentDocumentId", String::class.java)
assertEquals(404, response.statusCode.value())
}
// ---------------- Historie & Wiederherstellung ----------------
@Wenn("ich die Historie dieses Dokuments abrufe")
fun `ich rufe die Historie ab`() {
lastResponse = rest.getForEntity(
"/api/v1/documents/$currentDocumentId/history",
String::class.java,
)
}
@Wenn("ich dieses Dokument wiederherstelle")
fun `ich stelle dieses Dokument wieder her`() {
lastResponse = rest.postForEntity(
"/api/v1/documents/$currentDocumentId/restore",
jsonEntity("{}"),
String::class.java,
)
}
@Und("die Antwort enthält {int} Historieneinträge")
fun `die Antwort enthaelt n Historieneintraege`(anzahl: Int) {
val count = Regex("\"changeType\"").findAll(lastResponse?.body ?: "").count()
assertEquals(anzahl, count, "Antwort: ${lastResponse?.body}")
}
@Und("die Antwort enthält den Änderungstyp {string}")
fun `die Antwort enthaelt den Aenderungstyp`(typ: String) {
assertTrue(
lastResponse?.body?.contains("\"changeType\":\"$typ\"") == true,
"Erwarteter Änderungstyp '$typ' nicht in Antwort: ${lastResponse?.body}",
)
}
@Und("das Dokument ist wieder abrufbar")
fun `das Dokument ist wieder abrufbar`() {
val response = rest.getForEntity("/api/v1/documents/$currentDocumentId", String::class.java)
assertEquals(200, response.statusCode.value())
}
}
@@ -0,0 +1,210 @@
package com.example.editor.service
import com.example.editor.domain.ChangeType
import com.example.editor.domain.Document
import com.example.editor.domain.DocumentHistoryEntry
import com.example.editor.repository.DocumentHistoryRepository
import com.example.editor.repository.DocumentRepository
import io.mockk.every
import io.mockk.just
import io.mockk.mockk
import io.mockk.runs
import io.mockk.slot
import io.mockk.verify
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertThrows
import org.junit.jupiter.api.Test
import java.time.Clock
import java.time.Instant
import java.time.OffsetDateTime
import java.time.ZoneOffset
import java.util.UUID
class DocumentServiceTest {
private val fixedClock: Clock =
Clock.fixed(Instant.parse("2026-01-01T12:00:00Z"), ZoneOffset.UTC)
private val repository = mockk<DocumentRepository>()
private val historyRepository = mockk<DocumentHistoryRepository>(relaxUnitFun = true)
private val service = DocumentService(repository, historyRepository, fixedClock)
private fun sampleDocument(
id: UUID = UUID.randomUUID(),
version: Long = 1,
) = Document(
id = id,
title = "Titel",
content = "Inhalt",
version = version,
createdAt = OffsetDateTime.now(fixedClock),
updatedAt = OffsetDateTime.now(fixedClock),
)
private fun historyEntry(
id: UUID,
version: Long,
changeType: ChangeType,
title: String = "Titel v$version",
content: String = "Inhalt v$version",
) = DocumentHistoryEntry(
documentId = id,
version = version,
title = title,
content = content,
changeType = changeType,
timestamp = OffsetDateTime.now(fixedClock),
)
@Test
fun `create legt Dokument an und schreibt CREATED-Historieneintrag`() {
val saved = slot<Document>()
every { repository.save(capture(saved)) } answers { saved.captured }
val historyEntry = slot<DocumentHistoryEntry>()
every { historyRepository.append(capture(historyEntry)) } just runs
val result = service.create(title = "Notizen", content = "Hallo")
assertEquals(1, result.version)
assertEquals(ChangeType.CREATED, historyEntry.captured.changeType)
assertEquals(result.id, historyEntry.captured.documentId)
assertEquals("Hallo", historyEntry.captured.content)
}
@Test
fun `update inkrementiert Version und schreibt UPDATED-Historieneintrag`() {
val doc = sampleDocument(version = 3)
every { repository.findById(doc.id) } returns doc
val saved = slot<Document>()
every { repository.save(capture(saved)) } answers { saved.captured }
val historyEntry = slot<DocumentHistoryEntry>()
every { historyRepository.append(capture(historyEntry)) } just runs
val result = service.update(doc.id, title = "Neu", content = "Neuer Inhalt")
assertEquals(4, result.version)
assertEquals(ChangeType.UPDATED, historyEntry.captured.changeType)
assertEquals(4, historyEntry.captured.version)
}
@Test
fun `delete entfernt Dokument und schreibt DELETED-Tombstone`() {
val doc = sampleDocument(version = 2)
every { repository.findById(doc.id) } returns doc
every { repository.deleteById(doc.id) } returns true
val historyEntry = slot<DocumentHistoryEntry>()
every { historyRepository.append(capture(historyEntry)) } just runs
service.delete(doc.id)
verify(exactly = 1) { repository.deleteById(doc.id) }
assertEquals(ChangeType.DELETED, historyEntry.captured.changeType)
assertEquals(3, historyEntry.captured.version)
}
@Test
fun `delete wirft Exception bei unbekannter ID`() {
val id = UUID.randomUUID()
every { repository.findById(id) } returns null
assertThrows(DocumentNotFoundException::class.java) { service.delete(id) }
}
@Test
fun `history liefert Eintraege auch ohne existierendes Dokument`() {
val id = UUID.randomUUID()
val entries = listOf(
historyEntry(id, 1, ChangeType.CREATED),
historyEntry(id, 2, ChangeType.DELETED),
)
every { historyRepository.findByDocumentId(id) } returns entries
assertEquals(entries, service.history(id))
}
@Test
fun `history wirft Exception bei gaenzlich unbekannter ID`() {
val id = UUID.randomUUID()
every { historyRepository.findByDocumentId(id) } returns emptyList()
assertThrows(DocumentNotFoundException::class.java) { service.history(id) }
}
@Test
fun `restore stellt geloeschtes Dokument mit letztem Stand wieder her`() {
val id = UUID.randomUUID()
every { historyRepository.findByDocumentId(id) } returns listOf(
historyEntry(id, 1, ChangeType.CREATED),
historyEntry(id, 2, ChangeType.UPDATED),
historyEntry(id, 3, ChangeType.DELETED),
)
every { repository.findById(id) } returns null
val saved = slot<Document>()
every { repository.save(capture(saved)) } answers { saved.captured }
val historyEntry = slot<DocumentHistoryEntry>()
every { historyRepository.append(capture(historyEntry)) } just runs
val result = service.restore(id)
assertEquals(id, result.id)
assertEquals("Titel v2", result.title)
assertEquals("Inhalt v2", result.content)
assertEquals(4, result.version)
assertEquals(ChangeType.RESTORED, historyEntry.captured.changeType)
}
@Test
fun `restore mit Zielversion funktioniert als Rollback fuer existierendes Dokument`() {
val id = UUID.randomUUID()
val existing = sampleDocument(id = id, version = 3)
every { historyRepository.findByDocumentId(id) } returns listOf(
historyEntry(id, 1, ChangeType.CREATED),
historyEntry(id, 2, ChangeType.UPDATED),
historyEntry(id, 3, ChangeType.UPDATED),
)
every { repository.findById(id) } returns existing
val saved = slot<Document>()
every { repository.save(capture(saved)) } answers { saved.captured }
every { historyRepository.append(any()) } just runs
val result = service.restore(id, targetVersion = 1)
assertEquals("Titel v1", result.title)
assertEquals(4, result.version)
}
@Test
fun `restore ohne Zielversion wirft Konflikt wenn Dokument noch existiert`() {
val id = UUID.randomUUID()
every { historyRepository.findByDocumentId(id) } returns listOf(
historyEntry(id, 1, ChangeType.CREATED),
)
every { repository.findById(id) } returns sampleDocument(id = id)
assertThrows(DocumentConflictException::class.java) { service.restore(id) }
}
@Test
fun `restore wirft Exception bei unbekannter ID`() {
val id = UUID.randomUUID()
every { historyRepository.findByDocumentId(id) } returns emptyList()
assertThrows(DocumentNotFoundException::class.java) { service.restore(id) }
}
@Test
fun `findById wirft Exception bei unbekannter ID`() {
val id = UUID.randomUUID()
every { repository.findById(id) } returns null
assertThrows(DocumentNotFoundException::class.java) { service.findById(id) }
}
@Test
fun `findAll delegiert an das Repository`() {
val docs = listOf(sampleDocument(), sampleDocument())
every { repository.findAll() } returns docs
assertEquals(docs, service.findAll())
}
}
@@ -0,0 +1,12 @@
spring:
datasource:
url: jdbc:h2:mem:editor-test;MODE=PostgreSQL;DATABASE_TO_LOWER=TRUE;DEFAULT_NULL_ORDERING=HIGH;DB_CLOSE_DELAY=-1
username: sa
password: ""
driver-class-name: org.h2.Driver
jpa:
hibernate:
ddl-auto: validate
open-in-view: false
liquibase:
change-log: classpath:db/changelog/db.changelog-master.sql
@@ -0,0 +1,69 @@
# language: de
Funktionalität: Dokumente verwalten
Als Nutzer der API
möchte ich Dokumente anlegen, abrufen, ändern und löschen können,
damit das Backend die Grundlage für den Editor bildet.
Szenario: Ein neues Dokument anlegen
Wenn ich ein Dokument mit dem Titel "Notizen" und dem Inhalt "Hallo Welt" anlege
Dann erhalte ich den Status 201
Und die Antwort enthält den Titel "Notizen"
Und die Antwort enthält die Version 1
Szenario: Alle Dokumente auflisten
Angenommen es existiert ein Dokument mit dem Titel "Erstes"
Und es existiert ein Dokument mit dem Titel "Zweites"
Wenn ich alle Dokumente abrufe
Dann erhalte ich den Status 200
Und die Antwort enthält 2 Dokumente
Szenario: Ein einzelnes Dokument abrufen
Angenommen es existiert ein Dokument mit dem Titel "Protokoll"
Wenn ich dieses Dokument abrufe
Dann erhalte ich den Status 200
Und die Antwort enthält den Titel "Protokoll"
Szenario: Ein Dokument aktualisieren erhöht die Version
Angenommen es existiert ein Dokument mit dem Titel "Entwurf"
Wenn ich den Titel dieses Dokuments auf "Final" ändere
Dann erhalte ich den Status 200
Und die Antwort enthält den Titel "Final"
Und die Antwort enthält die Version 2
Szenario: Ein Dokument löschen
Angenommen es existiert ein Dokument mit dem Titel "Veraltet"
Wenn ich dieses Dokument lösche
Dann erhalte ich den Status 204
Und das Dokument ist nicht mehr abrufbar
Szenario: Ein unbekanntes Dokument abrufen
Wenn ich ein Dokument mit einer unbekannten ID abrufe
Dann erhalte ich den Status 404
Szenario: Die Historie protokolliert alle Änderungen
Angenommen es existiert ein Dokument mit dem Titel "Bericht"
Wenn ich den Titel dieses Dokuments auf "Bericht v2" ändere
Und ich die Historie dieses Dokuments abrufe
Dann erhalte ich den Status 200
Und die Antwort enthält 2 Historieneinträge
Und die Antwort enthält den Änderungstyp "CREATED"
Und die Antwort enthält den Änderungstyp "UPDATED"
Szenario: Die Historie überlebt das Löschen eines Dokuments
Angenommen es existiert ein Dokument mit dem Titel "Wichtig"
Wenn ich dieses Dokument lösche
Und ich die Historie dieses Dokuments abrufe
Dann erhalte ich den Status 200
Und die Antwort enthält den Änderungstyp "DELETED"
Szenario: Ein gelöschtes Dokument wiederherstellen
Angenommen es existiert ein Dokument mit dem Titel "Vertrag"
Wenn ich dieses Dokument lösche
Und ich dieses Dokument wiederherstelle
Dann erhalte ich den Status 200
Und die Antwort enthält den Titel "Vertrag"
Und das Dokument ist wieder abrufbar
Szenario: Wiederherstellen ohne Historie schlägt fehl
Wenn ich ein Dokument mit einer unbekannten ID abrufe
Dann erhalte ich den Status 404