feat(backend): Historie in zwei Ebenen, gezielter Repository-Zugriff (Schritt 2)

Meilensteine sind die nutzersichtbare Historie und bleiben; Sync-Versionen
tragen die Diffs des Live-Editings und werden nach der Aufbewahrungsfrist
verdichtet (D76). Ohne die Trennung wuerde die Historie beim getakteten
Schreiben zum Transaktionslog.

Die Schreibpause braucht keinen Zeitgeber: Die naechste Aenderung stellt
fest, dass eine Pause war, und befoerdert die Version davor nachtraeglich.
Strukturelle Aenderungen sind immer Meilensteine.

Das Historie-Repository greift jetzt gezielt zu (eine Version, juengster,
aeltester, Meilensteine, maxVersion) statt stets alle Eintraege zu laden und
in Kotlin zu filtern — bei hunderten Volltext-Versionen je Dokument war das
untragbar. Restore liest den letzten Stand aus dem Tombstone: der ueberlebt
das Verdichten, die Version davor womoeglich nicht.

Dabei die D76-Unschaerfe aufgeloest: RESTORED heisst nur noch "ein
geloeschtes Dokument ist wieder da" (der Client hebt seine Sperre auf), der
Rueckfall eines lebenden Dokuments ist ROLLED_BACK.

81 Tests. Gegenprobe: Schreibpause ignoriert -> genau die danach benannte
Zusicherung faellt; Rueckfall wieder als RESTORED -> Unit- und
Cucumber-Test dazu; juengster Stand aus der Historie genommen -> genau einer.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
mhoennig
2026-08-26 16:51:46 +02:00
co-authored by Claude Opus 5
parent 0f3bd8f5b8
commit d5cdff6058
16 changed files with 530 additions and 94 deletions
@@ -159,6 +159,16 @@ class DocumentStepDefinitions {
.returnResult(String::class.java)
}
@Wenn("ich dieses Dokument auf Version {long} zurücksetze")
fun `ich setze dieses Dokument zurueck`(version: Long) {
lastResponse = client.post()
.uri("/api/v1/documents/$currentDocumentId/restore")
.contentType(MediaType.APPLICATION_JSON)
.body("""{"version":$version}""")
.exchange()
.returnResult(String::class.java)
}
@Und("die Antwort enthält {int} Historieneinträge")
fun `die Antwort enthaelt n Historieneintraege`(anzahl: Int) {
val count = Regex("\"changeType\"").findAll(body()).count()
@@ -8,26 +8,37 @@ import de.werkbaum.repository.DocumentRepository
import io.kotest.assertions.throwables.shouldThrow
import io.kotest.matchers.shouldBe
import io.mockk.every
import io.mockk.just
import io.mockk.mockk
import io.mockk.runs
import io.mockk.CapturingSlot
import io.mockk.slot
import io.mockk.verify
import org.junit.jupiter.api.Test
import java.time.Clock
import java.time.Duration
import java.time.Instant
import java.time.OffsetDateTime
import java.time.ZoneId
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)
/** Uhr, die sich im Test weiterstellen lässt für die Schreibpause-Regel. */
private class TestClock(var moment: Instant) : Clock() {
override fun getZone(): ZoneId = ZoneOffset.UTC
override fun withZone(zone: ZoneId?): Clock = this
override fun instant(): Instant = moment
}
private val clock = TestClock(Instant.parse("2026-01-01T12:00:00Z"))
private val properties = LiveEditingProperties(
milestonePause = Duration.ofSeconds(30),
syncRetention = Duration.ofHours(1),
)
private val repository = mockk<DocumentRepository>()
private val historyRepository = mockk<DocumentHistoryRepository>(relaxUnitFun = true)
private val service = DocumentService(repository, historyRepository, fixedClock)
private val historyRepository = mockk<DocumentHistoryRepository>(relaxed = true)
private val service = DocumentService(repository, historyRepository, clock, properties)
private fun sampleDocument(
id: UUID = UUID.randomUUID(),
@@ -37,8 +48,8 @@ class DocumentServiceTest {
title = "Titel",
content = "Inhalt",
version = version,
createdAt = OffsetDateTime.now(fixedClock),
updatedAt = OffsetDateTime.now(fixedClock),
createdAt = OffsetDateTime.now(clock),
updatedAt = OffsetDateTime.now(clock),
)
private fun historyEntry(
@@ -47,28 +58,40 @@ class DocumentServiceTest {
changeType: ChangeType,
title: String = "Titel v$version",
content: String = "Inhalt v$version",
milestone: Boolean = true,
timestamp: OffsetDateTime = OffsetDateTime.now(clock),
) = DocumentHistoryEntry(
documentId = id,
version = version,
title = title,
content = content,
changeType = changeType,
timestamp = OffsetDateTime.now(fixedClock),
timestamp = timestamp,
milestone = milestone,
)
private fun captureAppended(): CapturingSlot<DocumentHistoryEntry> {
val slot = slot<DocumentHistoryEntry>()
every { historyRepository.append(capture(slot)) } returns Unit
return slot
}
// -----------------------------------------------------------------------
// Anlegen, Ändern, Löschen
// -----------------------------------------------------------------------
@Test
fun `create legt Dokument an und schreibt CREATED-Historieneintrag`() {
val saved = slot<Document>()
every { repository.save(capture(saved)) } answers { saved.captured }
val historyEntry = slot<DocumentHistoryEntry>()
every { historyRepository.append(capture(historyEntry)) } just runs
val entry = captureAppended()
val result = service.create(title = "Notizen", content = "Hallo")
result.version shouldBe 1
historyEntry.captured.changeType shouldBe ChangeType.CREATED
historyEntry.captured.documentId shouldBe result.id
historyEntry.captured.content shouldBe "Hallo"
entry.captured.changeType shouldBe ChangeType.CREATED
entry.captured.documentId shouldBe result.id
entry.captured.content shouldBe "Hallo"
}
@Test
@@ -77,14 +100,13 @@ class DocumentServiceTest {
every { repository.findById(doc.id) } returns doc
val saved = slot<Document>()
every { repository.save(capture(saved)) } answers { saved.captured }
val historyEntry = slot<DocumentHistoryEntry>()
every { historyRepository.append(capture(historyEntry)) } just runs
val entry = captureAppended()
val result = service.update(doc.id, title = "Neu", content = "Neuer Inhalt")
result.version shouldBe 4
historyEntry.captured.changeType shouldBe ChangeType.UPDATED
historyEntry.captured.version shouldBe 4
entry.captured.changeType shouldBe ChangeType.UPDATED
entry.captured.version shouldBe 4
}
@Test
@@ -92,14 +114,13 @@ class DocumentServiceTest {
val doc = sampleDocument(version = 2)
every { repository.findById(doc.id) } returns doc
every { repository.deleteById(doc.id) } returns true
val historyEntry = slot<DocumentHistoryEntry>()
every { historyRepository.append(capture(historyEntry)) } just runs
val entry = captureAppended()
service.delete(doc.id)
verify(exactly = 1) { repository.deleteById(doc.id) }
historyEntry.captured.changeType shouldBe ChangeType.DELETED
historyEntry.captured.version shouldBe 3
entry.captured.changeType shouldBe ChangeType.DELETED
entry.captured.version shouldBe 3
}
@Test
@@ -110,39 +131,156 @@ class DocumentServiceTest {
shouldThrow<DocumentNotFoundException> { service.delete(id) }
}
// -----------------------------------------------------------------------
// Zwei Ebenen: Meilensteine und Sync-Versionen
// -----------------------------------------------------------------------
@Test
fun `history liefert Eintraege auch ohne existierendes Dokument`() {
fun `ein gewoehnliches Update ist ein Meilenstein`() {
val doc = sampleDocument(version = 1)
every { repository.findById(doc.id) } returns doc
every { repository.save(any()) } answers { firstArg() }
val entry = captureAppended()
service.update(doc.id, "T", "C")
entry.captured.milestone shouldBe true
}
@Test
fun `ein getaktetes Update schreibt eine Sync-Version`() {
val doc = sampleDocument(version = 1)
every { repository.findById(doc.id) } returns doc
every { repository.save(any()) } answers { firstArg() }
val entry = captureAppended()
service.update(doc.id, "T", "C", milestone = false)
entry.captured.milestone shouldBe false
}
@Test
fun `strukturelle Aenderungen sind immer Meilensteine`() {
val doc = sampleDocument(version = 1)
every { repository.findById(doc.id) } returns doc
every { repository.deleteById(doc.id) } returns true
val entry = captureAppended()
service.delete(doc.id)
entry.captured.milestone shouldBe true
}
@Test
fun `die letzte Version vor einer Schreibpause wird nachtraeglich Meilenstein`() {
val doc = sampleDocument(version = 5)
every { repository.findById(doc.id) } returns doc
every { repository.save(any()) } answers { firstArg() }
every { historyRepository.findLatest(doc.id) } returns
historyEntry(doc.id, 5, ChangeType.UPDATED, milestone = false)
clock.moment = clock.moment.plusSeconds(31)
service.update(doc.id, "T", "C", milestone = false)
verify(exactly = 1) { historyRepository.promoteToMilestone(doc.id, 5) }
}
@Test
fun `innerhalb der Schreibpause wird nichts befoerdert`() {
val doc = sampleDocument(version = 5)
every { repository.findById(doc.id) } returns doc
every { repository.save(any()) } answers { firstArg() }
every { historyRepository.findLatest(doc.id) } returns
historyEntry(doc.id, 5, ChangeType.UPDATED, milestone = false)
clock.moment = clock.moment.plusSeconds(29)
service.update(doc.id, "T", "C", milestone = false)
verify(exactly = 0) { historyRepository.promoteToMilestone(any(), any()) }
}
@Test
fun `ein Meilenstein wird nicht noch einmal befoerdert`() {
val doc = sampleDocument(version = 5)
every { repository.findById(doc.id) } returns doc
every { repository.save(any()) } answers { firstArg() }
every { historyRepository.findLatest(doc.id) } returns
historyEntry(doc.id, 5, ChangeType.UPDATED, milestone = true)
clock.moment = clock.moment.plusSeconds(3600)
service.update(doc.id, "T", "C", milestone = false)
verify(exactly = 0) { historyRepository.promoteToMilestone(any(), any()) }
}
@Test
fun `nach jeder Aenderung wird jenseits der Aufbewahrungsfrist verdichtet`() {
val doc = sampleDocument(version = 1)
every { repository.findById(doc.id) } returns doc
every { repository.save(any()) } answers { firstArg() }
service.update(doc.id, "T", "C")
verify(exactly = 1) {
historyRepository.compact(doc.id, OffsetDateTime.now(clock).minusHours(1))
}
}
// -----------------------------------------------------------------------
// Historie
// -----------------------------------------------------------------------
@Test
fun `history liefert die Meilensteine, auch ohne existierendes Dokument`() {
val id = UUID.randomUUID()
val entries = listOf(
val meilensteine = listOf(
historyEntry(id, 1, ChangeType.CREATED),
historyEntry(id, 2, ChangeType.DELETED),
)
every { historyRepository.findByDocumentId(id) } returns entries
every { historyRepository.exists(id) } returns true
every { historyRepository.findMilestones(id) } returns meilensteine
every { historyRepository.findLatest(id) } returns meilensteine.last()
service.history(id) shouldBe entries
service.history(id) shouldBe meilensteine
}
@Test
fun `history zeigt den juengsten Stand auch als noch nicht befoerderte Sync-Version`() {
// Sonst fehlte in einer laufenden Schreibphase ausgerechnet der aktuelle Stand.
val id = UUID.randomUUID()
val meilenstein = historyEntry(id, 1, ChangeType.CREATED)
val laufend = historyEntry(id, 7, ChangeType.UPDATED, milestone = false)
every { historyRepository.exists(id) } returns true
every { historyRepository.findMilestones(id) } returns listOf(meilenstein)
every { historyRepository.findLatest(id) } returns laufend
service.history(id) shouldBe listOf(meilenstein, laufend)
}
@Test
fun `history wirft Exception bei gaenzlich unbekannter ID`() {
val id = UUID.randomUUID()
every { historyRepository.findByDocumentId(id) } returns emptyList()
every { historyRepository.exists(id) } returns false
shouldThrow<DocumentNotFoundException> { service.history(id) }
}
// -----------------------------------------------------------------------
// Wiederherstellen und Rückfall
// -----------------------------------------------------------------------
@Test
fun `restore stellt geloeschtes Dokument mit letztem Stand wieder her`() {
fun `restore stellt geloeschtes Dokument aus dem Tombstone 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 { historyRepository.exists(id) } returns true
every { historyRepository.findLatest(id) } returns
historyEntry(id, 3, ChangeType.DELETED, title = "Titel v2", content = "Inhalt v2")
every { historyRepository.maxVersion(id) } returns 3
every { historyRepository.findOldest(id) } returns historyEntry(id, 1, ChangeType.CREATED)
every { repository.findById(id) } returns null
val saved = slot<Document>()
every { repository.save(capture(saved)) } answers { saved.captured }
val historyEntry = slot<DocumentHistoryEntry>()
every { historyRepository.append(capture(historyEntry)) } just runs
val entry = captureAppended()
val result = service.restore(id)
@@ -150,35 +288,43 @@ class DocumentServiceTest {
result.title shouldBe "Titel v2"
result.content shouldBe "Inhalt v2"
result.version shouldBe 4
historyEntry.captured.changeType shouldBe ChangeType.RESTORED
entry.captured.changeType shouldBe ChangeType.RESTORED
}
@Test
fun `restore mit Zielversion funktioniert als Rollback fuer existierendes Dokument`() {
fun `restore mit Zielversion ist bei lebendem Dokument ein Rueckfall`() {
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 { historyRepository.exists(id) } returns true
every { historyRepository.findVersion(id, 1) } returns
historyEntry(id, 1, ChangeType.CREATED)
every { historyRepository.maxVersion(id) } returns 3
every { repository.findById(id) } returns existing
val saved = slot<Document>()
every { repository.save(capture(saved)) } answers { saved.captured }
every { historyRepository.append(any()) } just runs
every { repository.save(any()) } answers { firstArg() }
val entry = captureAppended()
val result = service.restore(id, targetVersion = 1)
result.title shouldBe "Titel v1"
result.version shouldBe 4
// RESTORED hiesse "die Sperre ist aufgehoben" - hier gab es nie eine.
entry.captured.changeType shouldBe ChangeType.ROLLED_BACK
}
@Test
fun `eine verdichtete Zielversion ist nicht mehr anzusteuern`() {
val id = UUID.randomUUID()
every { historyRepository.exists(id) } returns true
every { historyRepository.findVersion(id, 42) } returns null
every { repository.findById(id) } returns sampleDocument(id = id)
shouldThrow<DocumentNotFoundException> { service.restore(id, targetVersion = 42) }
}
@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 { historyRepository.exists(id) } returns true
every { repository.findById(id) } returns sampleDocument(id = id)
shouldThrow<DocumentConflictException> { service.restore(id) }
@@ -187,11 +333,13 @@ class DocumentServiceTest {
@Test
fun `restore wirft Exception bei unbekannter ID`() {
val id = UUID.randomUUID()
every { historyRepository.findByDocumentId(id) } returns emptyList()
every { historyRepository.exists(id) } returns false
shouldThrow<DocumentNotFoundException> { service.restore(id) }
}
// -----------------------------------------------------------------------
@Test
fun `findById wirft Exception bei unbekannter ID`() {
val id = UUID.randomUUID()
@@ -64,6 +64,15 @@ Funktionalität: Dokumente verwalten
Und die Antwort enthält den Titel "Vertrag"
Und das Dokument ist wieder abrufbar
Szenario: Ein Rückfall auf eine alte Version ist kein Wiederherstellen
Angenommen es existiert ein Dokument mit dem Titel "Satzung"
Wenn ich den Titel dieses Dokuments auf "Satzung v2" ändere
Und ich dieses Dokument auf Version 1 zurücksetze
Dann erhalte ich den Status 200
Und die Antwort enthält den Titel "Satzung"
Wenn ich die Historie dieses Dokuments abrufe
Dann die Antwort enthält den Änderungstyp "ROLLED_BACK"
Szenario: Wiederherstellen ohne Historie schlägt fehl
Wenn ich ein Dokument mit einer unbekannten ID abrufe
Dann erhalte ich den Status 404