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