initial backend: Dokumente, Historie, API und Persistenz (D76)

- Liquibase-Schema (`document`, `document_history`) + Rollback-scripts
- Spring Boot with JPA-repositories, entities and services
- REST-API (`/documents`, `/documents/{id}`, `/documents/{id}/history`)
- OpenAPI-specifikation for CRUD-Operationen and history
- config files (`application.yaml`, `Liquibase`, H2 im PostgreSQL-Modus)
- preps for future live-editing/delta-updates
- Exceptions for conflikt- and not-found cases (409/404)
- keeping document hostory even after `delete` for RESTORE functionality
This commit is contained in:
mhoennig
2026-08-26 12:56:27 +02:00
parent 4e0ce51820
commit 0446e3d81e
31 changed files with 2060 additions and 7 deletions
@@ -0,0 +1,28 @@
package com.example.editor.bdd
import com.example.editor.repository.DocumentHistoryRepository
import com.example.editor.repository.DocumentRepository
import io.cucumber.java.Before
import io.cucumber.spring.CucumberContextConfiguration
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.resttestclient.autoconfigure.AutoConfigureTestRestTemplate
import org.springframework.boot.test.context.SpringBootTest
@CucumberContextConfiguration
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
// Seit Boot 4 stellt @SpringBootTest die TestRestTemplate-Bean nicht mehr von selbst bereit
@AutoConfigureTestRestTemplate
class CucumberSpringConfiguration {
@Autowired
private lateinit var repository: DocumentRepository
@Autowired
private lateinit var historyRepository: DocumentHistoryRepository
@Before
fun resetState() {
repository.clear()
historyRepository.clear()
}
}
@@ -0,0 +1,15 @@
package com.example.editor.bdd
import io.cucumber.junit.platform.engine.Constants.GLUE_PROPERTY_NAME
import io.cucumber.junit.platform.engine.Constants.PLUGIN_PROPERTY_NAME
import org.junit.platform.suite.api.ConfigurationParameter
import org.junit.platform.suite.api.IncludeEngines
import org.junit.platform.suite.api.SelectClasspathResource
import org.junit.platform.suite.api.Suite
@Suite
@IncludeEngines("cucumber")
@SelectClasspathResource("features")
@ConfigurationParameter(key = GLUE_PROPERTY_NAME, value = "com.example.editor.bdd")
@ConfigurationParameter(key = PLUGIN_PROPERTY_NAME, value = "pretty")
class CucumberTest
@@ -0,0 +1,177 @@
package com.example.editor.bdd
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 org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNotNull
import org.junit.jupiter.api.Assertions.assertTrue
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.resttestclient.TestRestTemplate
import org.springframework.http.HttpEntity
import org.springframework.http.HttpHeaders
import org.springframework.http.HttpMethod
import org.springframework.http.MediaType
import org.springframework.http.ResponseEntity
/**
* Behavior-Tests gegen die laufende Anwendung (RANDOM_PORT), also echtes
* Verhalten der API inklusive Serialisierung, Statuscodes und Fehlerpfaden.
*/
class DocumentStepDefinitions {
@Autowired
private lateinit var rest: TestRestTemplate
private var lastResponse: ResponseEntity<String>? = null
private var currentDocumentId: String? = null
private fun jsonEntity(body: String): HttpEntity<String> {
val headers = HttpHeaders().apply { contentType = MediaType.APPLICATION_JSON }
return HttpEntity(body, headers)
}
private fun createDocument(title: String, content: String): ResponseEntity<String> =
rest.postForEntity(
"/api/v1/documents",
jsonEntity("""{"title":"$title","content":"$content"}"""),
String::class.java,
)
private fun extractId(body: String?): String {
val match = Regex("\"id\"\\s*:\\s*\"([^\"]+)\"").find(body ?: "")
assertNotNull(match, "Antwort enthält keine ID: $body")
return match!!.groupValues[1]
}
// ---------------- Angenommen ----------------
@Angenommen("es existiert ein Dokument mit dem Titel {string}")
fun `es existiert ein Dokument`(titel: String) {
val response = createDocument(titel, "Initialer Inhalt")
assertEquals(201, response.statusCode.value(), "Testdatenanlage fehlgeschlagen")
currentDocumentId = extractId(response.body)
}
// ---------------- Wenn ----------------
@Wenn("ich ein Dokument mit dem Titel {string} und dem Inhalt {string} anlege")
fun `ich lege ein Dokument an`(titel: String, inhalt: String) {
lastResponse = createDocument(titel, inhalt)
currentDocumentId = Regex("\"id\"\\s*:\\s*\"([^\"]+)\"")
.find(lastResponse?.body ?: "")?.groupValues?.get(1)
}
@Wenn("ich alle Dokumente abrufe")
fun `ich rufe alle Dokumente ab`() {
lastResponse = rest.getForEntity("/api/v1/documents", String::class.java)
}
@Wenn("ich dieses Dokument abrufe")
fun `ich rufe dieses Dokument ab`() {
lastResponse = rest.getForEntity("/api/v1/documents/$currentDocumentId", String::class.java)
}
@Wenn("ich ein Dokument mit einer unbekannten ID abrufe")
fun `ich rufe ein unbekanntes Dokument ab`() {
lastResponse = rest.getForEntity(
"/api/v1/documents/00000000-0000-0000-0000-000000000000",
String::class.java,
)
}
@Wenn("ich den Titel dieses Dokuments auf {string} ändere")
fun `ich aendere den Titel`(neuerTitel: String) {
lastResponse = rest.exchange(
"/api/v1/documents/$currentDocumentId",
HttpMethod.PUT,
jsonEntity("""{"title":"$neuerTitel","content":"Aktualisierter Inhalt"}"""),
String::class.java,
)
}
@Wenn("ich dieses Dokument lösche")
fun `ich loesche dieses Dokument`() {
lastResponse = rest.exchange(
"/api/v1/documents/$currentDocumentId",
HttpMethod.DELETE,
HttpEntity.EMPTY,
String::class.java,
)
}
// ---------------- Dann / Und ----------------
@Dann("erhalte ich den Status {int}")
fun `erhalte ich den Status`(status: Int) {
assertEquals(status, lastResponse?.statusCode?.value())
}
@Und("die Antwort enthält den Titel {string}")
fun `die Antwort enthaelt den Titel`(titel: String) {
assertTrue(
lastResponse?.body?.contains("\"title\":\"$titel\"") == true,
"Erwarteter Titel '$titel' nicht in Antwort: ${lastResponse?.body}",
)
}
@Und("die Antwort enthält {int} Dokumente")
fun `die Antwort enthaelt n Dokumente`(anzahl: Int) {
val count = Regex("\"id\"").findAll(lastResponse?.body ?: "").count()
assertEquals(anzahl, count, "Antwort: ${lastResponse?.body}")
}
@Und("die Antwort enthält die Version {long}")
fun `die Antwort enthaelt die Version`(version: Long) {
assertTrue(
lastResponse?.body?.contains("\"version\":$version") == true,
"Erwartete Version $version nicht in Antwort: ${lastResponse?.body}",
)
}
@Und("das Dokument ist nicht mehr abrufbar")
fun `das Dokument ist nicht mehr abrufbar`() {
val response = rest.getForEntity("/api/v1/documents/$currentDocumentId", String::class.java)
assertEquals(404, response.statusCode.value())
}
// ---------------- Historie & Wiederherstellung ----------------
@Wenn("ich die Historie dieses Dokuments abrufe")
fun `ich rufe die Historie ab`() {
lastResponse = rest.getForEntity(
"/api/v1/documents/$currentDocumentId/history",
String::class.java,
)
}
@Wenn("ich dieses Dokument wiederherstelle")
fun `ich stelle dieses Dokument wieder her`() {
lastResponse = rest.postForEntity(
"/api/v1/documents/$currentDocumentId/restore",
jsonEntity("{}"),
String::class.java,
)
}
@Und("die Antwort enthält {int} Historieneinträge")
fun `die Antwort enthaelt n Historieneintraege`(anzahl: Int) {
val count = Regex("\"changeType\"").findAll(lastResponse?.body ?: "").count()
assertEquals(anzahl, count, "Antwort: ${lastResponse?.body}")
}
@Und("die Antwort enthält den Änderungstyp {string}")
fun `die Antwort enthaelt den Aenderungstyp`(typ: String) {
assertTrue(
lastResponse?.body?.contains("\"changeType\":\"$typ\"") == true,
"Erwarteter Änderungstyp '$typ' nicht in Antwort: ${lastResponse?.body}",
)
}
@Und("das Dokument ist wieder abrufbar")
fun `das Dokument ist wieder abrufbar`() {
val response = rest.getForEntity("/api/v1/documents/$currentDocumentId", String::class.java)
assertEquals(200, response.statusCode.value())
}
}
@@ -0,0 +1,210 @@
package com.example.editor.service
import com.example.editor.domain.ChangeType
import com.example.editor.domain.Document
import com.example.editor.domain.DocumentHistoryEntry
import com.example.editor.repository.DocumentHistoryRepository
import com.example.editor.repository.DocumentRepository
import io.mockk.every
import io.mockk.just
import io.mockk.mockk
import io.mockk.runs
import io.mockk.slot
import io.mockk.verify
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertThrows
import org.junit.jupiter.api.Test
import java.time.Clock
import java.time.Instant
import java.time.OffsetDateTime
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)
private val repository = mockk<DocumentRepository>()
private val historyRepository = mockk<DocumentHistoryRepository>(relaxUnitFun = true)
private val service = DocumentService(repository, historyRepository, fixedClock)
private fun sampleDocument(
id: UUID = UUID.randomUUID(),
version: Long = 1,
) = Document(
id = id,
title = "Titel",
content = "Inhalt",
version = version,
createdAt = OffsetDateTime.now(fixedClock),
updatedAt = OffsetDateTime.now(fixedClock),
)
private fun historyEntry(
id: UUID,
version: Long,
changeType: ChangeType,
title: String = "Titel v$version",
content: String = "Inhalt v$version",
) = DocumentHistoryEntry(
documentId = id,
version = version,
title = title,
content = content,
changeType = changeType,
timestamp = OffsetDateTime.now(fixedClock),
)
@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 result = service.create(title = "Notizen", content = "Hallo")
assertEquals(1, result.version)
assertEquals(ChangeType.CREATED, historyEntry.captured.changeType)
assertEquals(result.id, historyEntry.captured.documentId)
assertEquals("Hallo", historyEntry.captured.content)
}
@Test
fun `update inkrementiert Version und schreibt UPDATED-Historieneintrag`() {
val doc = sampleDocument(version = 3)
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 result = service.update(doc.id, title = "Neu", content = "Neuer Inhalt")
assertEquals(4, result.version)
assertEquals(ChangeType.UPDATED, historyEntry.captured.changeType)
assertEquals(4, historyEntry.captured.version)
}
@Test
fun `delete entfernt Dokument und schreibt DELETED-Tombstone`() {
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
service.delete(doc.id)
verify(exactly = 1) { repository.deleteById(doc.id) }
assertEquals(ChangeType.DELETED, historyEntry.captured.changeType)
assertEquals(3, historyEntry.captured.version)
}
@Test
fun `delete wirft Exception bei unbekannter ID`() {
val id = UUID.randomUUID()
every { repository.findById(id) } returns null
assertThrows(DocumentNotFoundException::class.java) { service.delete(id) }
}
@Test
fun `history liefert Eintraege auch ohne existierendes Dokument`() {
val id = UUID.randomUUID()
val entries = listOf(
historyEntry(id, 1, ChangeType.CREATED),
historyEntry(id, 2, ChangeType.DELETED),
)
every { historyRepository.findByDocumentId(id) } returns entries
assertEquals(entries, service.history(id))
}
@Test
fun `history wirft Exception bei gaenzlich unbekannter ID`() {
val id = UUID.randomUUID()
every { historyRepository.findByDocumentId(id) } returns emptyList()
assertThrows(DocumentNotFoundException::class.java) { service.history(id) }
}
@Test
fun `restore stellt geloeschtes Dokument mit letztem Stand 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 { 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 result = service.restore(id)
assertEquals(id, result.id)
assertEquals("Titel v2", result.title)
assertEquals("Inhalt v2", result.content)
assertEquals(4, result.version)
assertEquals(ChangeType.RESTORED, historyEntry.captured.changeType)
}
@Test
fun `restore mit Zielversion funktioniert als Rollback fuer existierendes Dokument`() {
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 { repository.findById(id) } returns existing
val saved = slot<Document>()
every { repository.save(capture(saved)) } answers { saved.captured }
every { historyRepository.append(any()) } just runs
val result = service.restore(id, targetVersion = 1)
assertEquals("Titel v1", result.title)
assertEquals(4, result.version)
}
@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 { repository.findById(id) } returns sampleDocument(id = id)
assertThrows(DocumentConflictException::class.java) { service.restore(id) }
}
@Test
fun `restore wirft Exception bei unbekannter ID`() {
val id = UUID.randomUUID()
every { historyRepository.findByDocumentId(id) } returns emptyList()
assertThrows(DocumentNotFoundException::class.java) { service.restore(id) }
}
@Test
fun `findById wirft Exception bei unbekannter ID`() {
val id = UUID.randomUUID()
every { repository.findById(id) } returns null
assertThrows(DocumentNotFoundException::class.java) { service.findById(id) }
}
@Test
fun `findAll delegiert an das Repository`() {
val docs = listOf(sampleDocument(), sampleDocument())
every { repository.findAll() } returns docs
assertEquals(docs, service.findAll())
}
}
@@ -0,0 +1,12 @@
spring:
datasource:
url: jdbc:h2:mem:editor-test;MODE=PostgreSQL;DATABASE_TO_LOWER=TRUE;DEFAULT_NULL_ORDERING=HIGH;DB_CLOSE_DELAY=-1
username: sa
password: ""
driver-class-name: org.h2.Driver
jpa:
hibernate:
ddl-auto: validate
open-in-view: false
liquibase:
change-log: classpath:db/changelog/db.changelog-master.sql
@@ -0,0 +1,69 @@
# language: de
Funktionalität: Dokumente verwalten
Als Nutzer der API
möchte ich Dokumente anlegen, abrufen, ändern und löschen können,
damit das Backend die Grundlage für den Editor bildet.
Szenario: Ein neues Dokument anlegen
Wenn ich ein Dokument mit dem Titel "Notizen" und dem Inhalt "Hallo Welt" anlege
Dann erhalte ich den Status 201
Und die Antwort enthält den Titel "Notizen"
Und die Antwort enthält die Version 1
Szenario: Alle Dokumente auflisten
Angenommen es existiert ein Dokument mit dem Titel "Erstes"
Und es existiert ein Dokument mit dem Titel "Zweites"
Wenn ich alle Dokumente abrufe
Dann erhalte ich den Status 200
Und die Antwort enthält 2 Dokumente
Szenario: Ein einzelnes Dokument abrufen
Angenommen es existiert ein Dokument mit dem Titel "Protokoll"
Wenn ich dieses Dokument abrufe
Dann erhalte ich den Status 200
Und die Antwort enthält den Titel "Protokoll"
Szenario: Ein Dokument aktualisieren erhöht die Version
Angenommen es existiert ein Dokument mit dem Titel "Entwurf"
Wenn ich den Titel dieses Dokuments auf "Final" ändere
Dann erhalte ich den Status 200
Und die Antwort enthält den Titel "Final"
Und die Antwort enthält die Version 2
Szenario: Ein Dokument löschen
Angenommen es existiert ein Dokument mit dem Titel "Veraltet"
Wenn ich dieses Dokument lösche
Dann erhalte ich den Status 204
Und das Dokument ist nicht mehr abrufbar
Szenario: Ein unbekanntes Dokument abrufen
Wenn ich ein Dokument mit einer unbekannten ID abrufe
Dann erhalte ich den Status 404
Szenario: Die Historie protokolliert alle Änderungen
Angenommen es existiert ein Dokument mit dem Titel "Bericht"
Wenn ich den Titel dieses Dokuments auf "Bericht v2" ändere
Und ich die Historie dieses Dokuments abrufe
Dann erhalte ich den Status 200
Und die Antwort enthält 2 Historieneinträge
Und die Antwort enthält den Änderungstyp "CREATED"
Und die Antwort enthält den Änderungstyp "UPDATED"
Szenario: Die Historie überlebt das Löschen eines Dokuments
Angenommen es existiert ein Dokument mit dem Titel "Wichtig"
Wenn ich dieses Dokument lösche
Und ich die Historie dieses Dokuments abrufe
Dann erhalte ich den Status 200
Und die Antwort enthält den Änderungstyp "DELETED"
Szenario: Ein gelöschtes Dokument wiederherstellen
Angenommen es existiert ein Dokument mit dem Titel "Vertrag"
Wenn ich dieses Dokument lösche
Und ich dieses Dokument wiederherstelle
Dann erhalte ich den Status 200
Und die Antwort enthält den Titel "Vertrag"
Und das Dokument ist wieder abrufbar
Szenario: Wiederherstellen ohne Historie schlägt fehl
Wenn ich ein Dokument mit einer unbekannten ID abrufe
Dann erhalte ich den Status 404