feat(backend): PATCH /content — Diffs einreichen, rebasen, wiederholen (Schritt 3)

Der Server rebased selbst: Ist die Basis veraltet, ueberschneiden sich die
Operationen aber nicht mit den zwischenzeitlichen, verschiebt er sie und
akzeptiert. Reines Ablehnen fuehrte zu Starvation — ein Client mit hoher
Latenz kaeme bei fleissigen Mitschreibern womoeglich nie durch. 409 gibt es
nur bei echter Ueberschneidung, mit allem, was der Client zum Weiterarbeiten
braucht, ohne neu zu laden.

Pruefsumme ist Pflicht (422 bei Abweichung): Die Versionsnummer bestaetigt
nur, dass die Basis dieselbe Version ist, nicht dass beide Seiten sie gleich
lesen. clientId + seq machen den Aufruf wiederholbar — im Mobilnetz ist die
verlorene Antwort der Normalfall.

Die Sperre je Dokument liegt ausserhalb der Transaktion: innen gaebe der
Proxy sie vor dem Commit frei, und der naechste Schreiber laese einen Stand,
der noch nicht steht. Deshalb ist LiveEditingService nicht transaktional und
schreibt ueber DocumentService.

Was das Konzept offenliess, ist jetzt entschieden und in D76-Nachtrag 4
begruendet: die Randfaelle der Einfuege-Ueberschneidung, die Trennung von
400 und 422, die gedeckelte Idempotenz im Speicher.

104 Tests, davon 8 Cucumber-Szenarien fuer das Live-Editing. Gegenprobe:
Pruefsumme nicht geprueft, Idempotenz entfernt, veraltete Basis abgelehnt
statt verschoben -> es fallen jeweils genau die danach benannten.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
mhoennig
2026-08-26 17:05:14 +02:00
co-authored by Claude Opus 5
parent d5cdff6058
commit ae85503b79
20 changed files with 1321 additions and 35 deletions
@@ -0,0 +1,183 @@
package de.werkbaum.bdd
import de.werkbaum.diff.LineDiff
import de.werkbaum.generated.model.ContentConflict
import de.werkbaum.generated.model.ContentPatchResult
import de.werkbaum.generated.model.Document as ApiDocument
import tools.jackson.databind.json.JsonMapper
import tools.jackson.module.kotlin.kotlinModule
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 io.kotest.assertions.withClue
import io.kotest.matchers.nulls.shouldNotBeNull
import io.kotest.matchers.shouldBe
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.http.MediaType
import org.springframework.test.web.servlet.client.EntityExchangeResult
import org.springframework.test.web.servlet.client.RestTestClient
/**
* Behavior-Tests des Live-Editings (D76) gegen die laufende Anwendung.
*
* Die Schritte rechnen Basisversion und Prüfsumme selbst aus genau wie ein
* echter Client. Ein fest verdrahteter Hash im Feature wäre bei der ersten
* Textänderung falsch, ohne dass es jemandem auffiele.
*/
class LiveEditingStepDefinitions {
@Autowired
private lateinit var client: RestTestClient
private lateinit var documentId: String
/** Stand, den ein Client zuletzt gesehen hat: Version und Prüfsumme. */
private val knownState = mutableMapOf<String, Pair<Long, String>>()
private var lastResponse: EntityExchangeResult<String>? = null
private var lastRequestBody: String? = null
private fun status(): Int? = lastResponse?.status?.value()
private fun currentDocument(): ApiDocument =
client.get()
.uri("/api/v1/documents/$documentId")
.exchange()
.returnResult(ApiDocument::class.java)
.responseBody
.shouldNotBeNull()
private fun json(text: String): String = buildString {
append('"')
for (c in text) when (c) {
'"' -> append("\\\"")
'\\' -> append("\\\\")
'\n' -> append("\\n")
'\r' -> append("\\r")
'\t' -> append("\\t")
else -> append(c)
}
append('"')
}
private fun sendPatch(body: String) {
lastRequestBody = body
lastResponse = client.patch()
.uri("/api/v1/documents/$documentId/content")
.contentType(MediaType.APPLICATION_JSON)
.body(body)
.exchange()
.returnResult(String::class.java)
}
private fun patchBody(
clientId: String,
ops: String,
baseVersion: Long,
checksum: String,
seq: Long = 1,
) = """
{"baseVersion":$baseVersion,"checksum":${json(checksum)},
"clientId":${json(clientId)},"seq":$seq,"ops":$ops}
""".trimIndent()
private fun baseOf(clientId: String): Pair<Long, String> =
knownState[clientId] ?: currentDocument().let { it.version to LineDiff.checksum(it.content) }
// ---------------- Angenommen ----------------
@Angenommen("es existiert ein Dokument {string} mit den Zeilen:")
fun `es existiert ein Dokument mit Zeilen`(titel: String, inhalt: String) {
val response = client.post()
.uri("/api/v1/documents")
.contentType(MediaType.APPLICATION_JSON)
.body("""{"title":${json(titel)},"content":${json(inhalt)}}""")
.exchange()
.returnResult(ApiDocument::class.java)
withClue("Testdatenanlage fehlgeschlagen") { response.status.value() shouldBe 201 }
documentId = response.responseBody.shouldNotBeNull().id.toString()
knownState.clear()
}
@Angenommen("Client {string} kennt den aktuellen Stand")
fun `Client kennt den aktuellen Stand`(clientId: String) {
val doc = currentDocument()
knownState[clientId] = doc.version to LineDiff.checksum(doc.content)
}
@Angenommen("dieses Dokument gelöscht wird")
fun `dieses Dokument wird geloescht`() {
client.delete()
.uri("/api/v1/documents/$documentId")
.exchange()
.returnResult(String::class.java)
.status.value() shouldBe 204
}
// ---------------- Wenn ----------------
@Wenn("Client {string} folgendes Diff einreicht:")
fun `Client reicht ein Diff ein`(clientId: String, ops: String) {
val (version, checksum) = baseOf(clientId)
sendPatch(patchBody(clientId, ops, version, checksum))
}
@Wenn("Client {string} folgendes Diff mit falscher Prüfsumme einreicht:")
fun `Client reicht ein Diff mit falscher Pruefsumme ein`(clientId: String, ops: String) {
val (version, _) = baseOf(clientId)
sendPatch(patchBody(clientId, ops, version, "sha256:" + "0".repeat(64)))
}
@Wenn("dieselbe Anfrage noch einmal gesendet wird")
fun `dieselbe Anfrage noch einmal`() {
sendPatch(lastRequestBody.shouldNotBeNull())
}
// ---------------- Dann / Und ----------------
@Dann("erhalte ich für das Diff den Status {int}")
fun `erhalte ich fuer das Diff den Status`(erwartet: Int) {
withClue("Antwort: ${lastResponse?.responseBody}") { status() shouldBe erwartet }
}
@Und("das Dokument hat die Zeilen:")
fun `das Dokument hat die Zeilen`(erwartet: String) {
currentDocument().content shouldBe erwartet
}
@Und("das Dokument hat die Version {long}")
fun `das Dokument hat die Version`(erwartet: Long) {
currentDocument().version shouldBe erwartet
}
@Und("die Antwort meldet die Version {long}")
fun `die Antwort meldet die Version`(erwartet: Long) {
lastBody<ContentPatchResult>().version shouldBe erwartet
}
@Und("die Antwort enthält {int} fremde Operationen")
fun `die Antwort enthaelt n fremde Operationen`(anzahl: Int) {
lastBody<ContentPatchResult>().opsSinceBase.size shouldBe anzahl
}
@Und("die Konfliktantwort nennt die aktuelle Version {long} und {int} fremde Operationen")
fun `die Konfliktantwort nennt`(version: Long, anzahl: Int) {
val conflict = lastBody<ContentConflict>()
conflict.currentVersion shouldBe version
conflict.opsSinceBase.size shouldBe anzahl
}
/**
* Der Rumpf der letzten Antwort, typisiert gelesen. Bewusst aus dem
* gemerkten Text und nicht durch erneutes Senden: Ein zweiter Aufruf wäre
* zwar idempotent, würde aber genau den Fehler verdecken, den er prüfen
* soll.
*/
private inline fun <reified T : Any> lastBody(): T =
mapper.readValue(lastResponse?.responseBody.shouldNotBeNull(), T::class.java)
private companion object {
val mapper: JsonMapper = JsonMapper.builder().addModule(kotlinModule()).build()
}
}
@@ -0,0 +1,254 @@
package de.werkbaum.service
import de.werkbaum.diff.DiffNotApplicableException
import de.werkbaum.diff.LineDiff
import de.werkbaum.diff.LineOp
import de.werkbaum.domain.ChangeAuthor
import de.werkbaum.domain.ChangeType
import de.werkbaum.domain.ContentPatch
import de.werkbaum.domain.ContentPatchOutcome
import de.werkbaum.domain.Document
import de.werkbaum.domain.DocumentHistoryEntry
import de.werkbaum.repository.DocumentHistoryRepository
import io.kotest.assertions.throwables.shouldThrow
import io.kotest.matchers.shouldBe
import io.mockk.every
import io.mockk.mockk
import io.mockk.slot
import io.mockk.verify
import org.junit.jupiter.api.Test
import java.time.OffsetDateTime
import java.util.UUID
class LiveEditingServiceTest {
private val id = UUID.randomUUID()
private val documents = mockk<DocumentService>()
private val history = mockk<DocumentHistoryRepository>(relaxed = true)
private val properties = LiveEditingProperties(maxOps = 3, maxContentLength = 40)
private val service = LiveEditingService(documents, history, properties)
private val basis = "eins\nzwei\ndrei"
private fun document(content: String = basis, version: Long = 7) = Document(
id = id,
title = "Plan",
content = content,
version = version,
createdAt = OffsetDateTime.parse("2026-01-01T12:00:00Z"),
updatedAt = OffsetDateTime.parse("2026-01-01T12:00:00Z"),
)
private fun patch(
ops: List<LineOp> = listOf(LineOp.Replace(1, 1, listOf("ZWEI"))),
baseVersion: Long = 7,
checksum: String = LineDiff.checksum(basis),
clientId: String = "anna",
seq: Long = 1,
milestone: Boolean = false,
) = ContentPatch(
baseVersion = baseVersion,
checksum = checksum,
author = ChangeAuthor(clientId, "Anna"),
seq = seq,
ops = ops,
milestone = milestone,
)
private fun expectUpdate(content: String, newVersion: Long = 8) {
every { documents.update(id, "Plan", content, any(), any()) } returns
document(content, newVersion)
}
// -----------------------------------------------------------------------
@Test
fun `eine Aenderung auf aktueller Basis wird angewendet`() {
every { documents.findByIdOrNull(id) } returns document()
val content = slot<String>()
every { documents.update(id, "Plan", capture(content), any(), any()) } answers {
document(content.captured, 8)
}
val outcome = service.patchContent(id, patch())
outcome shouldBe ContentPatchOutcome(8, emptyList())
content.captured shouldBe "eins\nZWEI\ndrei"
}
@Test
fun `der Autor wandert in die Historie`() {
every { documents.findByIdOrNull(id) } returns document()
expectUpdate("eins\nZWEI\ndrei")
val autor = slot<ChangeAuthor>()
every { documents.update(id, any(), any(), any(), capture(autor)) } returns document()
service.patchContent(id, patch())
autor.captured shouldBe ChangeAuthor("anna", "Anna")
}
@Test
fun `der getaktete Strom schreibt Sync-Versionen, der Knopfdruck einen Meilenstein`() {
every { documents.findByIdOrNull(id) } returns document()
val meilenstein = slot<Boolean>()
every { documents.update(id, any(), any(), capture(meilenstein), any()) } returns document()
service.patchContent(id, patch(seq = 1))
meilenstein.captured shouldBe false
service.patchContent(id, patch(seq = 2, milestone = true))
meilenstein.captured shouldBe true
}
// -----------------------------------------------------------------------
// Rebasen und Konflikt
// -----------------------------------------------------------------------
@Test
fun `eine veraltete Basis ohne Ueberschneidung wird verschoben`() {
// Basis v6: "eins/zwei/drei". Fremd: eine Zeile vorn eingefuegt -> v7.
val aktuell = "null\neins\nzwei\ndrei"
every { documents.findByIdOrNull(id) } returns document(aktuell, 7)
every { history.findVersion(id, 6) } returns historyEntry(basis, 6)
val content = slot<String>()
every { documents.update(id, "Plan", capture(content), any(), any()) } answers {
document(content.captured, 8)
}
val outcome = service.patchContent(id, patch(baseVersion = 6))
outcome.version shouldBe 8
outcome.opsSinceBase shouldBe listOf(LineOp.Insert(0, listOf("null")))
content.captured shouldBe "null\neins\nZWEI\ndrei"
}
@Test
fun `eine echte Ueberschneidung meldet Konflikt samt fremdem Diff`() {
val aktuell = "eins\nfremd\ndrei"
every { documents.findByIdOrNull(id) } returns document(aktuell, 7)
every { history.findVersion(id, 6) } returns historyEntry(basis, 6)
val konflikt = shouldThrow<ContentConflictException> {
service.patchContent(id, patch(baseVersion = 6))
}
konflikt.currentVersion shouldBe 7
konflikt.opsSinceBase shouldBe listOf(LineOp.Replace(1, 1, listOf("fremd")))
verify(exactly = 0) { documents.update(any(), any(), any(), any(), any()) }
}
// -----------------------------------------------------------------------
// Wiederholung
// -----------------------------------------------------------------------
@Test
fun `dieselbe Sequenznummer wirkt nur einmal`() {
every { documents.findByIdOrNull(id) } returns document()
expectUpdate("eins\nZWEI\ndrei")
val erste = service.patchContent(id, patch(seq = 4))
val zweite = service.patchContent(id, patch(seq = 4))
zweite shouldBe erste
verify(exactly = 1) { documents.update(any(), any(), any(), any(), any()) }
}
@Test
fun `ein anderer Client teilt die Sequenznummer nicht`() {
every { documents.findByIdOrNull(id) } returns document()
expectUpdate("eins\nZWEI\ndrei")
service.patchContent(id, patch(seq = 4, clientId = "anna"))
service.patchContent(id, patch(seq = 4, clientId = "ben"))
verify(exactly = 2) { documents.update(any(), any(), any(), any(), any()) }
}
@Test
fun `eine veraltete Sequenznummer wird nicht angewendet`() {
every { documents.findByIdOrNull(id) } returns document()
expectUpdate("eins\nZWEI\ndrei")
service.patchContent(id, patch(seq = 4))
shouldThrow<StalePatchSequenceException> { service.patchContent(id, patch(seq = 3)) }
}
// -----------------------------------------------------------------------
// Nicht anwendbar
// -----------------------------------------------------------------------
@Test
fun `eine falsche Pruefsumme wird nicht angewendet`() {
every { documents.findByIdOrNull(id) } returns document()
shouldThrow<DiffNotApplicableException> {
service.patchContent(id, patch(checksum = LineDiff.checksum("etwas anderes")))
}
verify(exactly = 0) { documents.update(any(), any(), any(), any(), any()) }
}
@Test
fun `eine verdichtete Basisversion laesst sich nicht mehr rebasen`() {
every { documents.findByIdOrNull(id) } returns document(version = 7)
every { history.findVersion(id, 2) } returns null
shouldThrow<DiffNotApplicableException> { service.patchContent(id, patch(baseVersion = 2)) }
}
@Test
fun `eine Basisversion aus der Zukunft wird abgewiesen`() {
every { documents.findByIdOrNull(id) } returns document(version = 7)
shouldThrow<DiffNotApplicableException> { service.patchContent(id, patch(baseVersion = 9)) }
}
@Test
fun `ein geloeschtes Dokument nennt den Weg zurueck`() {
every { documents.findByIdOrNull(id) } returns null
every { history.exists(id) } returns true
val ex = shouldThrow<DocumentDeletedException> { service.patchContent(id, patch()) }
ex.message!!.contains("restore") shouldBe true
}
@Test
fun `eine gaenzlich unbekannte UUID ist schlicht nicht gefunden`() {
every { documents.findByIdOrNull(id) } returns null
every { history.exists(id) } returns false
shouldThrow<DocumentNotFoundException> { service.patchContent(id, patch()) }
}
// -----------------------------------------------------------------------
// Grenzen
// -----------------------------------------------------------------------
@Test
fun `zu viele Operationen werden abgewiesen, bevor irgendetwas geschieht`() {
val zuViele = (0..3).map { LineOp.Insert(0, listOf("x")) }
shouldThrow<InvalidPatchException> { service.patchContent(id, patch(ops = zuViele)) }
verify(exactly = 0) { documents.findByIdOrNull(any()) }
}
@Test
fun `ein zu langes Ergebnis wird abgewiesen`() {
every { documents.findByIdOrNull(id) } returns document()
shouldThrow<InvalidPatchException> {
service.patchContent(id, patch(ops = listOf(LineOp.Insert(0, listOf("x".repeat(60))))))
}
verify(exactly = 0) { documents.update(any(), any(), any(), any(), any()) }
}
private fun historyEntry(content: String, version: Long) = DocumentHistoryEntry(
documentId = id,
version = version,
title = "Plan",
content = content,
changeType = ChangeType.UPDATED,
timestamp = OffsetDateTime.parse("2026-01-01T12:00:00Z"),
milestone = false,
)
}
@@ -0,0 +1,159 @@
# language: de
Funktionalität: Gemeinsam am selben Dokument arbeiten
Als mehrere Bearbeiter eines Plans
möchte ich Änderungen als Zeilen-Diff einreichen,
damit niemand den Text der anderen überschreibt und nichts neu geladen
werden muss.
Grundlage: Jede Änderung ist ein Diff gegen eine Basisversion. Ist die Basis
veraltet, verschiebt der Server sie selbst abgelehnt wird nur, was sich
wirklich überschneidet.
Szenario: Eine Änderung auf aktueller Basis wird angewendet
Angenommen es existiert ein Dokument "Plan" mit den Zeilen:
"""
- [ ] Eins
- [ ] Zwei
- [ ] Drei
"""
Wenn Client "anna" folgendes Diff einreicht:
"""
[{"op":"replace","index":1,"count":1,"lines":["- [x] Zwei"]}]
"""
Dann erhalte ich für das Diff den Status 200
Und die Antwort meldet die Version 2
Und die Antwort enthält 0 fremde Operationen
Und das Dokument hat die Zeilen:
"""
- [ ] Eins
- [x] Zwei
- [ ] Drei
"""
Szenario: Eine veraltete Basis ohne Überschneidung wird serverseitig verschoben
Angenommen es existiert ein Dokument "Plan" mit den Zeilen:
"""
- [ ] Eins
- [ ] Zwei
- [ ] Drei
"""
Und Client "anna" kennt den aktuellen Stand
Wenn Client "ben" folgendes Diff einreicht:
"""
[{"op":"replace","index":0,"count":1,"lines":["- [x] Eins"]}]
"""
Und Client "anna" folgendes Diff einreicht:
"""
[{"op":"replace","index":2,"count":1,"lines":["- [x] Drei"]}]
"""
Dann erhalte ich für das Diff den Status 200
Und die Antwort enthält 1 fremde Operationen
Und das Dokument hat die Zeilen:
"""
- [x] Eins
- [ ] Zwei
- [x] Drei
"""
Szenario: Eine veraltete Basis mit Überschneidung meldet einen Konflikt
Angenommen es existiert ein Dokument "Plan" mit den Zeilen:
"""
- [ ] Eins
- [ ] Zwei
"""
Und Client "anna" kennt den aktuellen Stand
Wenn Client "ben" folgendes Diff einreicht:
"""
[{"op":"replace","index":0,"count":1,"lines":["- [x] Eins, von Ben"]}]
"""
Und Client "anna" folgendes Diff einreicht:
"""
[{"op":"replace","index":0,"count":1,"lines":["- [x] Eins, von Anna"]}]
"""
Dann erhalte ich für das Diff den Status 409
Und die Konfliktantwort nennt die aktuelle Version 2 und 1 fremde Operationen
Und das Dokument hat die Zeilen:
"""
- [x] Eins, von Ben
- [ ] Zwei
"""
Szenario: Zwei Einfügungen an derselben Stelle sind kein Konflikt
Angenommen es existiert ein Dokument "Plan" mit den Zeilen:
"""
- [ ] Eins
- [ ] Zwei
"""
Und Client "anna" kennt den aktuellen Stand
Wenn Client "ben" folgendes Diff einreicht:
"""
[{"op":"insert","index":1,"lines":["- [ ] Von Ben"]}]
"""
Und Client "anna" folgendes Diff einreicht:
"""
[{"op":"insert","index":1,"lines":["- [ ] Von Anna"]}]
"""
Dann erhalte ich für das Diff den Status 200
Und das Dokument hat die Zeilen:
"""
- [ ] Eins
- [ ] Von Ben
- [ ] Von Anna
- [ ] Zwei
"""
Szenario: Dieselbe Änderung zweimal gesendet wirkt nur einmal
Angenommen es existiert ein Dokument "Plan" mit den Zeilen:
"""
- [ ] Eins
"""
Wenn Client "anna" folgendes Diff einreicht:
"""
[{"op":"insert","index":1,"lines":["- [ ] Zwei"]}]
"""
Und dieselbe Anfrage noch einmal gesendet wird
Dann erhalte ich für das Diff den Status 200
Und die Antwort meldet die Version 2
Und das Dokument hat die Version 2
Und das Dokument hat die Zeilen:
"""
- [ ] Eins
- [ ] Zwei
"""
Szenario: Eine falsche Prüfsumme wird nicht angewendet
Angenommen es existiert ein Dokument "Plan" mit den Zeilen:
"""
- [ ] Eins
"""
Wenn Client "anna" folgendes Diff mit falscher Prüfsumme einreicht:
"""
[{"op":"replace","index":0,"count":1,"lines":["- [x] Eins"]}]
"""
Dann erhalte ich für das Diff den Status 422
Und das Dokument hat die Version 1
Szenario: Ein Index außerhalb des Dokuments wird nicht angewendet
Angenommen es existiert ein Dokument "Plan" mit den Zeilen:
"""
- [ ] Eins
"""
Wenn Client "anna" folgendes Diff einreicht:
"""
[{"op":"delete","index":7,"count":1}]
"""
Dann erhalte ich für das Diff den Status 422
Und das Dokument hat die Version 1
Szenario: Ein gelöschtes Dokument nimmt keine Änderung mehr an
Angenommen es existiert ein Dokument "Plan" mit den Zeilen:
"""
- [ ] Eins
"""
Und Client "anna" kennt den aktuellen Stand
Und dieses Dokument gelöscht wird
Wenn Client "anna" folgendes Diff einreicht:
"""
[{"op":"replace","index":0,"count":1,"lines":["- [x] Eins"]}]
"""
Dann erhalte ich für das Diff den Status 404