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
+4
-2
@@ -6,8 +6,10 @@ Status-Sync), später Tenzu-Adapter.
|
||||
|
||||
**Stand:** Gerüst steht — Dokumenten-CRUD mit Historie und Wiederherstellung,
|
||||
API-First aus `src/main/resources/openapi/api.yaml`, H2 mit Liquibase.
|
||||
Kommandos in README.md hier. Live-Editing ist entschieden (D76), aber noch
|
||||
nicht gebaut: `docs/live-editing-proposal.md`.
|
||||
Kommandos in README.md hier. Live-Editing (D76,
|
||||
`docs/live-editing-proposal.md`) ist in Arbeit: Schritte 1 und 2 der
|
||||
Reihenfolge dort sind gebaut (Zeilen-Diff in `de.werkbaum.diff`, Historie in
|
||||
zwei Ebenen), ab Schritt 3 (`PATCH /content`) steht es aus.
|
||||
|
||||
## Konventionen
|
||||
- Kotlin, **Spring Boot 4**, Gradle (Kotlin DSL), JDK 21.
|
||||
|
||||
+31
-15
@@ -61,16 +61,31 @@ 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).
|
||||
- Jede Änderung wird als Snapshot in einer vom Dokument getrennten Historie
|
||||
protokolliert – sie **überlebt ein DELETE**.
|
||||
- **Zwei Ebenen** (D76): **Meilensteine** sind die nutzersichtbare Historie und
|
||||
bleiben; **Sync-Versionen** tragen die Diffs des Live-Editings, sind
|
||||
kurzlebig und werden verdichtet. Ohne die Trennung würde die Historie beim
|
||||
getakteten Schreiben zum Transaktionslog – hunderte Volltext-Snapshots eines
|
||||
40-kB-Dokuments je Sitzung.
|
||||
- Meilenstein wird ein Stand bei einer strukturellen Änderung (Anlegen,
|
||||
Löschen, Wiederherstellen, Rückfall), beim Vollersatz per `PUT` und
|
||||
**nach einer Schreibpause**. Letzteres ohne Zeitgeber: Die nächste
|
||||
Änderung stellt fest, dass eine Pause war, und befördert die Version
|
||||
davor nachträglich. Der Knopfdruck aus dem Konzept ist derselbe
|
||||
Schalter (`milestone = true`), sobald `PATCH /content` ihn durchreicht.
|
||||
- Stellschrauben: `werkbaum.live-editing.milestone-pause` (30 s) und
|
||||
`sync-retention` (1 h).
|
||||
- `GET /api/v1/documents/{uuid}/history` liefert die Meilensteine (älteste
|
||||
zuerst) und immer den jüngsten Stand; 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.
|
||||
derselben UUID wieder her (`RESTORED`, letzter Stand aus dem Tombstone). Mit
|
||||
optionalem Body `{"version": n}` wird eine bestimmte Version übernommen – bei
|
||||
einem noch lebenden Dokument ist das ein Rückfall (`ROLLED_BACK`), kein
|
||||
Wiederherstellen: Der Client hatte nie eine Sperre. Ohne Zielversion
|
||||
antwortet der Server bei existierendem Dokument mit 409, eine bereits
|
||||
verdichtete Zielversion mit 404.
|
||||
|
||||
## Vorbereitete Erweiterungen
|
||||
|
||||
@@ -81,12 +96,13 @@ werden.
|
||||
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.
|
||||
**Live-Editing** (Konzept: `docs/live-editing-proposal.md`, Entscheidung: D76)
|
||||
- **Gebaut:** das Zeilen-Diff als reine Funktionen (`de.werkbaum.diff` –
|
||||
Anwenden, Berechnen, Rebasen, Prüfsumme) und die zweistufige Historie.
|
||||
- **Offen:** `PATCH /content` samt Rebase und Idempotenz, der Änderungsfeed per
|
||||
Long Polling, Master-Passwort für `GET /documents`, Client-Anpassung.
|
||||
- `DocumentUpdateRequest.expectedVersion` ist im Vertrag vorgesehen, wird aber
|
||||
noch nicht ausgewertet.
|
||||
|
||||
**Clientseitige Verschlüsselung**
|
||||
- `content` ist ein opaker String, den der Server nie interpretiert. Der
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
package de.werkbaum
|
||||
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication
|
||||
import org.springframework.boot.context.properties.ConfigurationPropertiesScan
|
||||
import org.springframework.boot.runApplication
|
||||
|
||||
@SpringBootApplication
|
||||
@ConfigurationPropertiesScan
|
||||
class EditorBackendApplication
|
||||
|
||||
fun main(args: Array<String>) {
|
||||
|
||||
@@ -3,12 +3,33 @@ package de.werkbaum.domain
|
||||
import java.time.OffsetDateTime
|
||||
import java.util.UUID
|
||||
|
||||
enum class ChangeType { CREATED, UPDATED, DELETED, RESTORED }
|
||||
/**
|
||||
* Art der Änderung.
|
||||
*
|
||||
* [RESTORED] heißt ausschließlich: ein **gelöschtes** Dokument ist wieder da —
|
||||
* der Client hebt daraufhin seine Sperre auf. [ROLLED_BACK] ist der Rückfall
|
||||
* eines **lebenden** Dokuments auf eine alte Version; für den Client ein
|
||||
* gewöhnlicher Inhaltswechsel. Beide trugen früher denselben Typ; ein Typ, der
|
||||
* zwei Dinge bedeutet, ist die Unschärfe, aus der später Fehler werden (D76).
|
||||
*/
|
||||
enum class ChangeType { CREATED, UPDATED, DELETED, RESTORED, ROLLED_BACK }
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Wiederherstellung und für die Diffs des Live-Editings.
|
||||
*
|
||||
* Die Historie hat **zwei Ebenen** (D76):
|
||||
* - [milestone] `false` — eine **Sync-Version**. Sie trägt das Protokoll
|
||||
* (Diffs zwischen beliebigen Versionen), ist kurzlebig und wird nach einer
|
||||
* Weile verdichtet. Danach beantwortet der Feed betroffene `since`-Werte
|
||||
* mit Volltext.
|
||||
* - [milestone] `true` — ein **Meilenstein**, die nutzersichtbare Historie.
|
||||
* Meilensteine entstehen bei strukturellen Änderungen, nach einer
|
||||
* Schreibpause und auf Knopfdruck; sie werden nie verdichtet.
|
||||
*
|
||||
* Ohne die Trennung würde die Historie bei 1,5 s Debounce zum Transaktionslog:
|
||||
* hunderte Volltext-Snapshots eines 40-kB-Dokuments je Sitzung.
|
||||
*/
|
||||
data class DocumentHistoryEntry(
|
||||
val documentId: UUID,
|
||||
@@ -17,4 +38,5 @@ data class DocumentHistoryEntry(
|
||||
val content: String,
|
||||
val changeType: ChangeType,
|
||||
val timestamp: OffsetDateTime,
|
||||
val milestone: Boolean = true,
|
||||
)
|
||||
|
||||
@@ -38,6 +38,10 @@ class DocumentHistoryEntity(
|
||||
|
||||
@Column(name = "change_time", nullable = false)
|
||||
val changeTime: OffsetDateTime,
|
||||
|
||||
/** Meilenstein (nutzersichtbar, bleibt) oder Sync-Version (wird verdichtet) – D76. */
|
||||
@Column(nullable = false)
|
||||
var milestone: Boolean = true,
|
||||
) {
|
||||
fun toDomain() = DocumentHistoryEntry(
|
||||
documentId = documentId,
|
||||
@@ -46,6 +50,7 @@ class DocumentHistoryEntity(
|
||||
content = content,
|
||||
changeType = changeType,
|
||||
timestamp = changeTime,
|
||||
milestone = milestone,
|
||||
)
|
||||
|
||||
companion object {
|
||||
@@ -56,6 +61,7 @@ class DocumentHistoryEntity(
|
||||
content = entry.content,
|
||||
changeType = entry.changeType,
|
||||
changeTime = entry.timestamp,
|
||||
milestone = entry.milestone,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package de.werkbaum.persistence
|
||||
import de.werkbaum.domain.DocumentHistoryEntry
|
||||
import de.werkbaum.repository.DocumentHistoryRepository
|
||||
import org.springframework.stereotype.Repository
|
||||
import java.time.OffsetDateTime
|
||||
import java.util.UUID
|
||||
|
||||
@Repository
|
||||
@@ -14,8 +15,28 @@ class JpaDocumentHistoryRepository(
|
||||
jpa.save(DocumentHistoryEntity.fromDomain(entry))
|
||||
}
|
||||
|
||||
override fun findByDocumentId(documentId: UUID): List<DocumentHistoryEntry> =
|
||||
jpa.findByDocumentIdOrderByIdAsc(documentId).map { it.toDomain() }
|
||||
override fun exists(documentId: UUID): Boolean = jpa.existsByDocumentId(documentId)
|
||||
|
||||
override fun findVersion(documentId: UUID, version: Long): DocumentHistoryEntry? =
|
||||
jpa.findFirstByDocumentIdAndVersionOrderByIdDesc(documentId, version)?.toDomain()
|
||||
|
||||
override fun findLatest(documentId: UUID): DocumentHistoryEntry? =
|
||||
jpa.findFirstByDocumentIdOrderByIdDesc(documentId)?.toDomain()
|
||||
|
||||
override fun findOldest(documentId: UUID): DocumentHistoryEntry? =
|
||||
jpa.findFirstByDocumentIdOrderByIdAsc(documentId)?.toDomain()
|
||||
|
||||
override fun maxVersion(documentId: UUID): Long? = jpa.maxVersion(documentId)
|
||||
|
||||
override fun findMilestones(documentId: UUID): List<DocumentHistoryEntry> =
|
||||
jpa.findByDocumentIdAndMilestoneTrueOrderByIdAsc(documentId).map { it.toDomain() }
|
||||
|
||||
override fun promoteToMilestone(documentId: UUID, version: Long) {
|
||||
jpa.promoteToMilestone(documentId, version)
|
||||
}
|
||||
|
||||
override fun compact(documentId: UUID, olderThan: OffsetDateTime): Int =
|
||||
jpa.deleteSyncVersionsOlderThan(documentId, olderThan)
|
||||
|
||||
override fun clear() = jpa.deleteAll()
|
||||
}
|
||||
|
||||
@@ -1,10 +1,53 @@
|
||||
package de.werkbaum.persistence
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository
|
||||
import org.springframework.data.jpa.repository.Modifying
|
||||
import org.springframework.data.jpa.repository.Query
|
||||
import org.springframework.data.repository.query.Param
|
||||
import java.time.OffsetDateTime
|
||||
import java.util.UUID
|
||||
|
||||
interface DocumentJpaRepository : JpaRepository<DocumentEntity, UUID>
|
||||
|
||||
interface DocumentHistoryJpaRepository : JpaRepository<DocumentHistoryEntity, Long> {
|
||||
fun findByDocumentIdOrderByIdAsc(documentId: UUID): List<DocumentHistoryEntity>
|
||||
|
||||
fun existsByDocumentId(documentId: UUID): Boolean
|
||||
|
||||
fun findFirstByDocumentIdAndVersionOrderByIdDesc(
|
||||
documentId: UUID,
|
||||
version: Long,
|
||||
): DocumentHistoryEntity?
|
||||
|
||||
fun findFirstByDocumentIdOrderByIdDesc(documentId: UUID): DocumentHistoryEntity?
|
||||
|
||||
fun findFirstByDocumentIdOrderByIdAsc(documentId: UUID): DocumentHistoryEntity?
|
||||
|
||||
fun findByDocumentIdAndMilestoneTrueOrderByIdAsc(documentId: UUID): List<DocumentHistoryEntity>
|
||||
|
||||
@Query("select max(e.version) from DocumentHistoryEntity e where e.documentId = :documentId")
|
||||
fun maxVersion(@Param("documentId") documentId: UUID): Long?
|
||||
|
||||
@Modifying(flushAutomatically = true, clearAutomatically = true)
|
||||
@Query(
|
||||
"""
|
||||
update DocumentHistoryEntity e set e.milestone = true
|
||||
where e.documentId = :documentId and e.version = :version
|
||||
"""
|
||||
)
|
||||
fun promoteToMilestone(
|
||||
@Param("documentId") documentId: UUID,
|
||||
@Param("version") version: Long,
|
||||
): Int
|
||||
|
||||
@Modifying(flushAutomatically = true, clearAutomatically = true)
|
||||
@Query(
|
||||
"""
|
||||
delete from DocumentHistoryEntity e
|
||||
where e.documentId = :documentId and e.milestone = false and e.changeTime < :cutoff
|
||||
"""
|
||||
)
|
||||
fun deleteSyncVersionsOlderThan(
|
||||
@Param("documentId") documentId: UUID,
|
||||
@Param("cutoff") cutoff: OffsetDateTime,
|
||||
): Int
|
||||
}
|
||||
|
||||
@@ -1,13 +1,49 @@
|
||||
package de.werkbaum.repository
|
||||
|
||||
import de.werkbaum.domain.DocumentHistoryEntry
|
||||
import java.time.OffsetDateTime
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
* Zugriff auf die Dokumenthistorie.
|
||||
*
|
||||
* Bewusst **gezielt** statt „lade alles und filtere in Kotlin": Mit dem
|
||||
* Live-Editing entstehen hunderte Versionen je Dokument, und jede trägt den
|
||||
* vollen Text (D76).
|
||||
*/
|
||||
interface DocumentHistoryRepository {
|
||||
fun append(entry: DocumentHistoryEntry)
|
||||
|
||||
/** Alle Einträge zu einem Dokument, älteste zuerst. */
|
||||
fun findByDocumentId(documentId: UUID): List<DocumentHistoryEntry>
|
||||
/** Gibt es zu dieser UUID überhaupt Historie? Auch für gelöschte Dokumente wahr. */
|
||||
fun exists(documentId: UUID): Boolean
|
||||
|
||||
/** Genau eine Version – oder `null`, wenn sie nie existierte oder verdichtet wurde. */
|
||||
fun findVersion(documentId: UUID, version: Long): DocumentHistoryEntry?
|
||||
|
||||
/** Der jüngste Eintrag, gleich welchen Typs (bei gelöschten Dokumenten der Tombstone). */
|
||||
fun findLatest(documentId: UUID): DocumentHistoryEntry?
|
||||
|
||||
/** Der älteste Eintrag – die Anlage des Dokuments, immer ein Meilenstein. */
|
||||
fun findOldest(documentId: UUID): DocumentHistoryEntry?
|
||||
|
||||
/** Höchste vergebene Versionsnummer, auch wenn deren Eintrag verdichtet wurde. */
|
||||
fun maxVersion(documentId: UUID): Long?
|
||||
|
||||
/** Die nutzersichtbare Historie, älteste zuerst. */
|
||||
fun findMilestones(documentId: UUID): List<DocumentHistoryEntry>
|
||||
|
||||
/**
|
||||
* Erhebt eine Sync-Version nachträglich zum Meilenstein – sie war die
|
||||
* letzte vor einer Schreibpause.
|
||||
*/
|
||||
fun promoteToMilestone(documentId: UUID, version: Long)
|
||||
|
||||
/**
|
||||
* Verdichtet: entfernt Sync-Versionen dieses Dokuments, die älter als
|
||||
* [olderThan] sind. Meilensteine bleiben. Liefert die Zahl der entfernten
|
||||
* Einträge.
|
||||
*/
|
||||
fun compact(documentId: UUID, olderThan: OffsetDateTime): Int
|
||||
|
||||
fun clear()
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import de.werkbaum.repository.DocumentRepository
|
||||
import org.springframework.stereotype.Service
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
import java.time.Clock
|
||||
import java.time.Duration
|
||||
import java.time.OffsetDateTime
|
||||
import java.util.UUID
|
||||
|
||||
@@ -17,6 +18,7 @@ class DocumentService(
|
||||
private val repository: DocumentRepository,
|
||||
private val historyRepository: DocumentHistoryRepository,
|
||||
private val clock: Clock,
|
||||
private val properties: LiveEditingProperties,
|
||||
) {
|
||||
|
||||
fun findAll(): List<Document> = repository.findAll()
|
||||
@@ -39,7 +41,16 @@ class DocumentService(
|
||||
return document
|
||||
}
|
||||
|
||||
fun update(id: UUID, title: String, content: String): Document {
|
||||
/**
|
||||
* Ersetzt Titel und Inhalt vollständig.
|
||||
*
|
||||
* [milestone] `false` schreibt eine **Sync-Version** – gedacht für den
|
||||
* getakteten Strom des Live-Editings (D76), der sonst hunderte
|
||||
* nutzersichtbare Stände je Sitzung erzeugte. Der Vollersatz über die API
|
||||
* ist dagegen eine bewusste Handlung (Import, Reparatur) und bleibt
|
||||
* Meilenstein.
|
||||
*/
|
||||
fun update(id: UUID, title: String, content: String, milestone: Boolean = true): Document {
|
||||
val existing = findById(id)
|
||||
val updated = existing.copy(
|
||||
title = title,
|
||||
@@ -48,7 +59,7 @@ class DocumentService(
|
||||
updatedAt = OffsetDateTime.now(clock),
|
||||
)
|
||||
repository.save(updated)
|
||||
recordHistory(updated, ChangeType.UPDATED)
|
||||
recordHistory(updated, ChangeType.UPDATED, milestone)
|
||||
return updated
|
||||
}
|
||||
|
||||
@@ -66,26 +77,42 @@ class DocumentService(
|
||||
}
|
||||
|
||||
/**
|
||||
* Historie eines Dokuments – funktioniert auch für bereits gelöschte
|
||||
* Dokumente. 404 nur, wenn die UUID gänzlich unbekannt ist.
|
||||
* Die **nutzersichtbare** Historie: alle Meilensteine, älteste zuerst,
|
||||
* dazu immer der jüngste Stand. Sync-Versionen bleiben draußen – sie
|
||||
* tragen das Protokoll, nicht die Erzählung (D76).
|
||||
*
|
||||
* Funktioniert auch für bereits gelöschte Dokumente; 404 nur, wenn die
|
||||
* UUID gänzlich unbekannt ist.
|
||||
*/
|
||||
fun history(id: UUID): List<DocumentHistoryEntry> {
|
||||
val entries = historyRepository.findByDocumentId(id)
|
||||
if (entries.isEmpty()) throw DocumentNotFoundException(id)
|
||||
return entries
|
||||
if (!historyRepository.exists(id)) throw DocumentNotFoundException(id)
|
||||
val milestones = historyRepository.findMilestones(id)
|
||||
// Die letzte Version einer noch laufenden Schreibphase ist noch kein
|
||||
// Meilenstein – sichtbar sein muss sie trotzdem.
|
||||
val latest = historyRepository.findLatest(id)
|
||||
return if (latest != null && milestones.none { it.version == latest.version }) {
|
||||
milestones + latest
|
||||
} else {
|
||||
milestones
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stellt ein Dokument unter derselben UUID wieder her.
|
||||
*
|
||||
* - Ohne [targetVersion]: letzter inhaltlicher Stand vor dem Löschen.
|
||||
* - Ohne [targetVersion]: letzter Stand vor dem Löschen ([ChangeType.RESTORED]).
|
||||
* 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.
|
||||
* übernommen. Bei einem lebenden Dokument ist das ein Rückfall
|
||||
* ([ChangeType.ROLLED_BACK]), kein Wiederherstellen – der Client hatte
|
||||
* nie eine Sperre.
|
||||
*
|
||||
* Eine verdichtete Sync-Version ist nicht mehr anzusteuern (404). Das ist
|
||||
* die Zwei-Ebenen-Regel im Betrieb: Angeboten werden Meilensteine, und die
|
||||
* bleiben.
|
||||
*/
|
||||
fun restore(id: UUID, targetVersion: Long? = null): Document {
|
||||
val entries = historyRepository.findByDocumentId(id)
|
||||
if (entries.isEmpty()) throw DocumentNotFoundException(id)
|
||||
if (!historyRepository.exists(id)) throw DocumentNotFoundException(id)
|
||||
|
||||
val existing = repository.findById(id)
|
||||
if (existing != null && targetVersion == null) {
|
||||
@@ -95,28 +122,59 @@ class DocumentService(
|
||||
}
|
||||
|
||||
val snapshot = if (targetVersion != null) {
|
||||
entries.lastOrNull { it.version == targetVersion && it.changeType != ChangeType.DELETED }
|
||||
historyRepository.findVersion(id, targetVersion)
|
||||
?.takeIf { it.changeType != ChangeType.DELETED }
|
||||
?: throw DocumentNotFoundException(id)
|
||||
} else {
|
||||
entries.last { it.changeType != ChangeType.DELETED }
|
||||
// Der Tombstone trägt den letzten Stand – er ist die verlässliche
|
||||
// Quelle, auch wenn die Version davor längst verdichtet wurde.
|
||||
historyRepository.findLatest(id) ?: throw DocumentNotFoundException(id)
|
||||
}
|
||||
|
||||
val now = OffsetDateTime.now(clock)
|
||||
val lastVersion = maxOf(entries.maxOf { it.version }, existing?.version ?: 0)
|
||||
val lastVersion = maxOf(historyRepository.maxVersion(id) ?: 0, existing?.version ?: 0)
|
||||
val restored = Document(
|
||||
id = id,
|
||||
title = snapshot.title,
|
||||
content = snapshot.content,
|
||||
version = lastVersion + 1,
|
||||
createdAt = existing?.createdAt ?: entries.first().timestamp,
|
||||
createdAt = existing?.createdAt
|
||||
?: historyRepository.findOldest(id)?.timestamp
|
||||
?: now,
|
||||
updatedAt = now,
|
||||
)
|
||||
repository.save(restored)
|
||||
recordHistory(restored, ChangeType.RESTORED)
|
||||
recordHistory(
|
||||
restored,
|
||||
if (existing != null) ChangeType.ROLLED_BACK else ChangeType.RESTORED,
|
||||
)
|
||||
return restored
|
||||
}
|
||||
|
||||
private fun recordHistory(document: Document, changeType: ChangeType) {
|
||||
/**
|
||||
* Schreibt einen Historieneintrag und hält dabei die zwei Ebenen instand:
|
||||
*
|
||||
* 1. War die vorige Version eine Sync-Version und liegt sie länger als
|
||||
* [LiveEditingProperties.milestonePause] zurück, war sie die **letzte
|
||||
* vor einer Schreibpause** und wird nachträglich Meilenstein. So
|
||||
* braucht es keinen Zeitgeber – die nächste Änderung stellt fest, dass
|
||||
* eine Pause war.
|
||||
* 2. Strukturelle Änderungen sind immer Meilensteine.
|
||||
* 3. Danach wird verdichtet: Sync-Versionen jenseits der
|
||||
* Aufbewahrungsfrist entfallen.
|
||||
*/
|
||||
private fun recordHistory(
|
||||
document: Document,
|
||||
changeType: ChangeType,
|
||||
milestone: Boolean = true,
|
||||
) {
|
||||
val previous = historyRepository.findLatest(document.id)
|
||||
if (previous != null && !previous.milestone &&
|
||||
Duration.between(previous.timestamp, document.updatedAt) >= properties.milestonePause
|
||||
) {
|
||||
historyRepository.promoteToMilestone(document.id, previous.version)
|
||||
}
|
||||
|
||||
historyRepository.append(
|
||||
DocumentHistoryEntry(
|
||||
documentId = document.id,
|
||||
@@ -125,7 +183,17 @@ class DocumentService(
|
||||
content = document.content,
|
||||
changeType = changeType,
|
||||
timestamp = document.updatedAt,
|
||||
milestone = milestone || changeType.isStructural,
|
||||
)
|
||||
)
|
||||
|
||||
historyRepository.compact(
|
||||
document.id,
|
||||
document.updatedAt.minus(properties.syncRetention),
|
||||
)
|
||||
}
|
||||
|
||||
/** Anlegen, Löschen, Wiederherstellen und Rückfall sind nie bloß Sync-Versionen. */
|
||||
private val ChangeType.isStructural: Boolean
|
||||
get() = this != ChangeType.UPDATED
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
package de.werkbaum.service
|
||||
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties
|
||||
import java.time.Duration
|
||||
|
||||
/**
|
||||
* Stellschrauben des Live-Editings (D76). Bewusst konfigurierbar: Die Werte
|
||||
* sind gesetzt, nicht hergeleitet, und werden nach Erfahrung justiert – wie
|
||||
* die Schwellen im Frontend (D64, D71).
|
||||
*/
|
||||
@ConfigurationProperties(prefix = "werkbaum.live-editing")
|
||||
data class LiveEditingProperties(
|
||||
|
||||
/**
|
||||
* Schreibpause, nach der die letzte Version zum Meilenstein wird. Kürzer
|
||||
* heißt mehr nutzersichtbare Stände, länger heißt gröbere Historie.
|
||||
*/
|
||||
val milestonePause: Duration = Duration.ofSeconds(30),
|
||||
|
||||
/**
|
||||
* Wie lange Sync-Versionen aufgehoben werden. Danach beantwortet der Feed
|
||||
* ein so altes `since` mit Volltext statt mit einem Diff.
|
||||
*/
|
||||
val syncRetention: Duration = Duration.ofHours(1),
|
||||
)
|
||||
@@ -18,5 +18,13 @@ spring:
|
||||
liquibase:
|
||||
change-log: classpath:db/changelog/db.changelog-master.sql
|
||||
|
||||
werkbaum:
|
||||
live-editing:
|
||||
# Schreibpause, nach der die letzte Version zum Meilenstein wird.
|
||||
milestone-pause: 30s
|
||||
# Danach wird eine Sync-Version verdichtet; der Feed antwortet auf ein so
|
||||
# altes "since" dann mit Volltext statt mit einem Diff.
|
||||
sync-retention: 1h
|
||||
|
||||
server:
|
||||
port: 8080
|
||||
|
||||
@@ -26,3 +26,13 @@ CREATE 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;
|
||||
|
||||
--changeset editor:004-history-milestone
|
||||
-- Zwei Ebenen (D76): Meilensteine sind die nutzersichtbare Historie und
|
||||
-- bleiben; Sync-Versionen tragen die Diffs des Live-Editings und werden nach
|
||||
-- einer Weile verdichtet. Bestand ist Meilenstein - er stammt aus der Zeit
|
||||
-- ohne Live-Editing und ist durchweg nutzersichtbar.
|
||||
ALTER TABLE document_history ADD COLUMN milestone BOOLEAN DEFAULT TRUE NOT NULL;
|
||||
CREATE INDEX idx_document_history_version ON document_history (document_id, version);
|
||||
--rollback DROP INDEX idx_document_history_version;
|
||||
--rollback ALTER TABLE document_history DROP COLUMN milestone;
|
||||
|
||||
@@ -127,11 +127,13 @@ paths:
|
||||
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.
|
||||
Liefert die nutzersichtbaren Staende (Meilensteine) in chronologischer
|
||||
Reihenfolge, dazu immer den juengsten Stand. Kurzlebige Sync-Versionen
|
||||
des Live-Editings bleiben aussen vor. Die Historie ueberlebt das
|
||||
Loeschen des Dokuments.
|
||||
responses:
|
||||
"200":
|
||||
description: Historie des Dokuments (aelteste zuerst)
|
||||
description: Meilensteine des Dokuments (aelteste zuerst)
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
@@ -253,6 +255,10 @@ components:
|
||||
description: Optional; wird spaeter fuer Optimistic Locking ausgewertet.
|
||||
|
||||
DocumentHistoryEntry:
|
||||
description: >
|
||||
Ein Stand der nutzersichtbaren Historie. Sync-Versionen des
|
||||
Live-Editings erscheinen hier nicht - sie tragen das Protokoll, nicht
|
||||
die Erzaehlung.
|
||||
type: object
|
||||
required: [documentId, version, title, content, changeType, timestamp]
|
||||
properties:
|
||||
@@ -268,7 +274,11 @@ components:
|
||||
type: string
|
||||
changeType:
|
||||
type: string
|
||||
enum: [CREATED, UPDATED, DELETED, RESTORED]
|
||||
description: >
|
||||
RESTORED heisst: ein geloeschtes Dokument ist wieder da.
|
||||
ROLLED_BACK ist der Rueckfall eines lebenden Dokuments auf eine
|
||||
aeltere Version.
|
||||
enum: [CREATED, UPDATED, DELETED, RESTORED, ROLLED_BACK]
|
||||
timestamp:
|
||||
type: string
|
||||
format: date-time
|
||||
|
||||
@@ -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