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:
co-authored by
Claude Opus 5
parent
d5cdff6058
commit
ae85503b79
@@ -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
|
||||
Reference in New Issue
Block a user