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:
+114
-7
@@ -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.
|
||||
|
||||
@@ -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
|
||||
@@ -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 **2–3 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 Konfliktprü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)
|
||||
@@ -0,0 +1,3 @@
|
||||
org.gradle.caching=true
|
||||
org.gradle.parallel=true
|
||||
kotlin.code.style=official
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
+21
@@ -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;
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user