From 0446e3d81e6b4489895c7d7f93fdfc6948cd2656 Mon Sep 17 00:00:00 2001 From: mhoennig Date: Wed, 26 Aug 2026 12:56:27 +0200 Subject: [PATCH] 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 --- backend/README.md | 121 ++++++- backend/build.gradle.kts | 133 ++++++++ .../docs/client-live-editing-instructions.md | 223 +++++++++++++ backend/docs/live-editing-proposal.md | 190 +++++++++++ backend/gradle.properties | 3 + backend/settings.gradle.kts | 1 + .../editor/EditorBackendApplication.kt | 11 + .../example/editor/api/DocumentsController.kt | 88 +++++ .../editor/api/GlobalExceptionHandler.kt | 28 ++ .../com/example/editor/domain/Document.kt | 21 ++ .../editor/domain/DocumentHistoryEntry.kt | 20 ++ .../editor/persistence/DocumentEntity.kt | 51 +++ .../persistence/DocumentHistoryEntity.kt | 61 ++++ .../JpaDocumentHistoryRepository.kt | 21 ++ .../persistence/JpaDocumentRepository.kt | 35 ++ .../persistence/SpringDataRepositories.kt | 10 + .../repository/DocumentHistoryRepository.kt | 13 + .../editor/repository/DocumentRepository.kt | 17 + .../editor/service/ClockConfiguration.kt | 11 + .../service/DocumentConflictException.kt | 3 + .../service/DocumentNotFoundException.kt | 6 + .../example/editor/service/DocumentService.kt | 131 ++++++++ backend/src/main/resources/application.yaml | 22 ++ .../db/changelog/db.changelog-master.sql | 28 ++ backend/src/main/resources/openapi/api.yaml | 308 ++++++++++++++++++ .../editor/bdd/CucumberSpringConfiguration.kt | 28 ++ .../com/example/editor/bdd/CucumberTest.kt | 15 + .../editor/bdd/DocumentStepDefinitions.kt | 177 ++++++++++ .../editor/service/DocumentServiceTest.kt | 210 ++++++++++++ backend/src/test/resources/application.yaml | 12 + .../test/resources/features/dokumente.feature | 69 ++++ 31 files changed, 2060 insertions(+), 7 deletions(-) create mode 100644 backend/build.gradle.kts create mode 100644 backend/docs/client-live-editing-instructions.md create mode 100644 backend/docs/live-editing-proposal.md create mode 100644 backend/gradle.properties create mode 100644 backend/settings.gradle.kts create mode 100644 backend/src/main/kotlin/com/example/editor/EditorBackendApplication.kt create mode 100644 backend/src/main/kotlin/com/example/editor/api/DocumentsController.kt create mode 100644 backend/src/main/kotlin/com/example/editor/api/GlobalExceptionHandler.kt create mode 100644 backend/src/main/kotlin/com/example/editor/domain/Document.kt create mode 100644 backend/src/main/kotlin/com/example/editor/domain/DocumentHistoryEntry.kt create mode 100644 backend/src/main/kotlin/com/example/editor/persistence/DocumentEntity.kt create mode 100644 backend/src/main/kotlin/com/example/editor/persistence/DocumentHistoryEntity.kt create mode 100644 backend/src/main/kotlin/com/example/editor/persistence/JpaDocumentHistoryRepository.kt create mode 100644 backend/src/main/kotlin/com/example/editor/persistence/JpaDocumentRepository.kt create mode 100644 backend/src/main/kotlin/com/example/editor/persistence/SpringDataRepositories.kt create mode 100644 backend/src/main/kotlin/com/example/editor/repository/DocumentHistoryRepository.kt create mode 100644 backend/src/main/kotlin/com/example/editor/repository/DocumentRepository.kt create mode 100644 backend/src/main/kotlin/com/example/editor/service/ClockConfiguration.kt create mode 100644 backend/src/main/kotlin/com/example/editor/service/DocumentConflictException.kt create mode 100644 backend/src/main/kotlin/com/example/editor/service/DocumentNotFoundException.kt create mode 100644 backend/src/main/kotlin/com/example/editor/service/DocumentService.kt create mode 100644 backend/src/main/resources/application.yaml create mode 100644 backend/src/main/resources/db/changelog/db.changelog-master.sql create mode 100644 backend/src/main/resources/openapi/api.yaml create mode 100644 backend/src/test/kotlin/com/example/editor/bdd/CucumberSpringConfiguration.kt create mode 100644 backend/src/test/kotlin/com/example/editor/bdd/CucumberTest.kt create mode 100644 backend/src/test/kotlin/com/example/editor/bdd/DocumentStepDefinitions.kt create mode 100644 backend/src/test/kotlin/com/example/editor/service/DocumentServiceTest.kt create mode 100644 backend/src/test/resources/application.yaml create mode 100644 backend/src/test/resources/features/dokumente.feature diff --git a/backend/README.md b/backend/README.md index dda8911..cda79fc 100644 --- a/backend/README.md +++ b/backend/README.md @@ -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. diff --git a/backend/build.gradle.kts b/backend/build.gradle.kts new file mode 100644 index 0000000..7589ad2 --- /dev/null +++ b/backend/build.gradle.kts @@ -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().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) +} diff --git a/backend/docs/client-live-editing-instructions.md b/backend/docs/client-live-editing-instructions.md new file mode 100644 index 0000000..0416b00 --- /dev/null +++ b/backend/docs/client-live-editing-instructions.md @@ -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 diff --git a/backend/docs/live-editing-proposal.md b/backend/docs/live-editing-proposal.md new file mode 100644 index 0000000..1f0f76e --- /dev/null +++ b/backend/docs/live-editing-proposal.md @@ -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 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) diff --git a/backend/gradle.properties b/backend/gradle.properties new file mode 100644 index 0000000..6e1e412 --- /dev/null +++ b/backend/gradle.properties @@ -0,0 +1,3 @@ +org.gradle.caching=true +org.gradle.parallel=true +kotlin.code.style=official diff --git a/backend/settings.gradle.kts b/backend/settings.gradle.kts new file mode 100644 index 0000000..297bf37 --- /dev/null +++ b/backend/settings.gradle.kts @@ -0,0 +1 @@ +rootProject.name = "editor-backend" diff --git a/backend/src/main/kotlin/com/example/editor/EditorBackendApplication.kt b/backend/src/main/kotlin/com/example/editor/EditorBackendApplication.kt new file mode 100644 index 0000000..55ab989 --- /dev/null +++ b/backend/src/main/kotlin/com/example/editor/EditorBackendApplication.kt @@ -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) { + runApplication(*args) +} diff --git a/backend/src/main/kotlin/com/example/editor/api/DocumentsController.kt b/backend/src/main/kotlin/com/example/editor/api/DocumentsController.kt new file mode 100644 index 0000000..e43ee76 --- /dev/null +++ b/backend/src/main/kotlin/com/example/editor/api/DocumentsController.kt @@ -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> = + ResponseEntity.ok(service.findAll().map { it.toApi() }) + + override fun createDocument(documentCreateRequest: DocumentCreateRequest): ResponseEntity { + val created = service.create( + title = documentCreateRequest.title, + content = documentCreateRequest.content, + ) + return ResponseEntity.status(HttpStatus.CREATED).body(created.toApi()) + } + + override fun getDocument(documentId: UUID): ResponseEntity = + ResponseEntity.ok(service.findById(documentId).toApi()) + + override fun updateDocument( + documentId: UUID, + documentUpdateRequest: DocumentUpdateRequest, + ): ResponseEntity { + val updated = service.update( + id = documentId, + title = documentUpdateRequest.title, + content = documentUpdateRequest.content, + ) + return ResponseEntity.ok(updated.toApi()) + } + + override fun deleteDocument(documentId: UUID): ResponseEntity { + service.delete(documentId) + return ResponseEntity.noContent().build() + } + + override fun getDocumentHistory(documentId: UUID): ResponseEntity> = + ResponseEntity.ok(service.history(documentId).map { it.toApi() }) + + override fun restoreDocument( + documentId: UUID, + restoreRequest: RestoreRequest?, + ): ResponseEntity { + 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, + ) +} diff --git a/backend/src/main/kotlin/com/example/editor/api/GlobalExceptionHandler.kt b/backend/src/main/kotlin/com/example/editor/api/GlobalExceptionHandler.kt new file mode 100644 index 0000000..21ddf08 --- /dev/null +++ b/backend/src/main/kotlin/com/example/editor/api/GlobalExceptionHandler.kt @@ -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" + } +} diff --git a/backend/src/main/kotlin/com/example/editor/domain/Document.kt b/backend/src/main/kotlin/com/example/editor/domain/Document.kt new file mode 100644 index 0000000..e8514d5 --- /dev/null +++ b/backend/src/main/kotlin/com/example/editor/domain/Document.kt @@ -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, +) diff --git a/backend/src/main/kotlin/com/example/editor/domain/DocumentHistoryEntry.kt b/backend/src/main/kotlin/com/example/editor/domain/DocumentHistoryEntry.kt new file mode 100644 index 0000000..ac37060 --- /dev/null +++ b/backend/src/main/kotlin/com/example/editor/domain/DocumentHistoryEntry.kt @@ -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, +) diff --git a/backend/src/main/kotlin/com/example/editor/persistence/DocumentEntity.kt b/backend/src/main/kotlin/com/example/editor/persistence/DocumentEntity.kt new file mode 100644 index 0000000..d51a2e8 --- /dev/null +++ b/backend/src/main/kotlin/com/example/editor/persistence/DocumentEntity.kt @@ -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, + ) + } +} diff --git a/backend/src/main/kotlin/com/example/editor/persistence/DocumentHistoryEntity.kt b/backend/src/main/kotlin/com/example/editor/persistence/DocumentHistoryEntity.kt new file mode 100644 index 0000000..fb277c3 --- /dev/null +++ b/backend/src/main/kotlin/com/example/editor/persistence/DocumentHistoryEntity.kt @@ -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, + ) + } +} diff --git a/backend/src/main/kotlin/com/example/editor/persistence/JpaDocumentHistoryRepository.kt b/backend/src/main/kotlin/com/example/editor/persistence/JpaDocumentHistoryRepository.kt new file mode 100644 index 0000000..9f6556b --- /dev/null +++ b/backend/src/main/kotlin/com/example/editor/persistence/JpaDocumentHistoryRepository.kt @@ -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 = + jpa.findByDocumentIdOrderByIdAsc(documentId).map { it.toDomain() } + + override fun clear() = jpa.deleteAll() +} diff --git a/backend/src/main/kotlin/com/example/editor/persistence/JpaDocumentRepository.kt b/backend/src/main/kotlin/com/example/editor/persistence/JpaDocumentRepository.kt new file mode 100644 index 0000000..c583f41 --- /dev/null +++ b/backend/src/main/kotlin/com/example/editor/persistence/JpaDocumentRepository.kt @@ -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 = + 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() +} diff --git a/backend/src/main/kotlin/com/example/editor/persistence/SpringDataRepositories.kt b/backend/src/main/kotlin/com/example/editor/persistence/SpringDataRepositories.kt new file mode 100644 index 0000000..b0479a1 --- /dev/null +++ b/backend/src/main/kotlin/com/example/editor/persistence/SpringDataRepositories.kt @@ -0,0 +1,10 @@ +package com.example.editor.persistence + +import org.springframework.data.jpa.repository.JpaRepository +import java.util.UUID + +interface DocumentJpaRepository : JpaRepository + +interface DocumentHistoryJpaRepository : JpaRepository { + fun findByDocumentIdOrderByIdAsc(documentId: UUID): List +} diff --git a/backend/src/main/kotlin/com/example/editor/repository/DocumentHistoryRepository.kt b/backend/src/main/kotlin/com/example/editor/repository/DocumentHistoryRepository.kt new file mode 100644 index 0000000..70f4875 --- /dev/null +++ b/backend/src/main/kotlin/com/example/editor/repository/DocumentHistoryRepository.kt @@ -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 + + fun clear() +} diff --git a/backend/src/main/kotlin/com/example/editor/repository/DocumentRepository.kt b/backend/src/main/kotlin/com/example/editor/repository/DocumentRepository.kt new file mode 100644 index 0000000..e422d1f --- /dev/null +++ b/backend/src/main/kotlin/com/example/editor/repository/DocumentRepository.kt @@ -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 + fun findById(id: UUID): Document? + fun save(document: Document): Document + fun deleteById(id: UUID): Boolean + fun clear() +} diff --git a/backend/src/main/kotlin/com/example/editor/service/ClockConfiguration.kt b/backend/src/main/kotlin/com/example/editor/service/ClockConfiguration.kt new file mode 100644 index 0000000..8c5a4f7 --- /dev/null +++ b/backend/src/main/kotlin/com/example/editor/service/ClockConfiguration.kt @@ -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() +} diff --git a/backend/src/main/kotlin/com/example/editor/service/DocumentConflictException.kt b/backend/src/main/kotlin/com/example/editor/service/DocumentConflictException.kt new file mode 100644 index 0000000..3a448d8 --- /dev/null +++ b/backend/src/main/kotlin/com/example/editor/service/DocumentConflictException.kt @@ -0,0 +1,3 @@ +package com.example.editor.service + +class DocumentConflictException(message: String) : RuntimeException(message) diff --git a/backend/src/main/kotlin/com/example/editor/service/DocumentNotFoundException.kt b/backend/src/main/kotlin/com/example/editor/service/DocumentNotFoundException.kt new file mode 100644 index 0000000..5dda58b --- /dev/null +++ b/backend/src/main/kotlin/com/example/editor/service/DocumentNotFoundException.kt @@ -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") diff --git a/backend/src/main/kotlin/com/example/editor/service/DocumentService.kt b/backend/src/main/kotlin/com/example/editor/service/DocumentService.kt new file mode 100644 index 0000000..6c5eede --- /dev/null +++ b/backend/src/main/kotlin/com/example/editor/service/DocumentService.kt @@ -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 = 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 { + 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, + ) + ) + } +} diff --git a/backend/src/main/resources/application.yaml b/backend/src/main/resources/application.yaml new file mode 100644 index 0000000..ea336cb --- /dev/null +++ b/backend/src/main/resources/application.yaml @@ -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 diff --git a/backend/src/main/resources/db/changelog/db.changelog-master.sql b/backend/src/main/resources/db/changelog/db.changelog-master.sql new file mode 100644 index 0000000..e03cd31 --- /dev/null +++ b/backend/src/main/resources/db/changelog/db.changelog-master.sql @@ -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; diff --git a/backend/src/main/resources/openapi/api.yaml b/backend/src/main/resources/openapi/api.yaml new file mode 100644 index 0000000..3b880e7 --- /dev/null +++ b/backend/src/main/resources/openapi/api.yaml @@ -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: []). diff --git a/backend/src/test/kotlin/com/example/editor/bdd/CucumberSpringConfiguration.kt b/backend/src/test/kotlin/com/example/editor/bdd/CucumberSpringConfiguration.kt new file mode 100644 index 0000000..81a4f0f --- /dev/null +++ b/backend/src/test/kotlin/com/example/editor/bdd/CucumberSpringConfiguration.kt @@ -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() + } +} diff --git a/backend/src/test/kotlin/com/example/editor/bdd/CucumberTest.kt b/backend/src/test/kotlin/com/example/editor/bdd/CucumberTest.kt new file mode 100644 index 0000000..3fa21aa --- /dev/null +++ b/backend/src/test/kotlin/com/example/editor/bdd/CucumberTest.kt @@ -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 diff --git a/backend/src/test/kotlin/com/example/editor/bdd/DocumentStepDefinitions.kt b/backend/src/test/kotlin/com/example/editor/bdd/DocumentStepDefinitions.kt new file mode 100644 index 0000000..19d10d3 --- /dev/null +++ b/backend/src/test/kotlin/com/example/editor/bdd/DocumentStepDefinitions.kt @@ -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? = null + private var currentDocumentId: String? = null + + private fun jsonEntity(body: String): HttpEntity { + val headers = HttpHeaders().apply { contentType = MediaType.APPLICATION_JSON } + return HttpEntity(body, headers) + } + + private fun createDocument(title: String, content: String): ResponseEntity = + 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()) + } +} diff --git a/backend/src/test/kotlin/com/example/editor/service/DocumentServiceTest.kt b/backend/src/test/kotlin/com/example/editor/service/DocumentServiceTest.kt new file mode 100644 index 0000000..85364c1 --- /dev/null +++ b/backend/src/test/kotlin/com/example/editor/service/DocumentServiceTest.kt @@ -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() + private val historyRepository = mockk(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() + every { repository.save(capture(saved)) } answers { saved.captured } + val historyEntry = slot() + 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() + every { repository.save(capture(saved)) } answers { saved.captured } + val historyEntry = slot() + 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() + 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() + every { repository.save(capture(saved)) } answers { saved.captured } + val historyEntry = slot() + 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() + 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()) + } +} diff --git a/backend/src/test/resources/application.yaml b/backend/src/test/resources/application.yaml new file mode 100644 index 0000000..5077933 --- /dev/null +++ b/backend/src/test/resources/application.yaml @@ -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 diff --git a/backend/src/test/resources/features/dokumente.feature b/backend/src/test/resources/features/dokumente.feature new file mode 100644 index 0000000..460b4ad --- /dev/null +++ b/backend/src/test/resources/features/dokumente.feature @@ -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