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,11 @@
package com.example.editor
import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.boot.runApplication
@SpringBootApplication
class EditorBackendApplication
fun main(args: Array<String>) {
runApplication<EditorBackendApplication>(*args)
}
@@ -0,0 +1,88 @@
package com.example.editor.api
import com.example.editor.generated.api.DocumentsApi
import com.example.editor.generated.model.Document as ApiDocument
import com.example.editor.generated.model.DocumentCreateRequest
import com.example.editor.generated.model.DocumentHistoryEntry as ApiHistoryEntry
import com.example.editor.generated.model.DocumentUpdateRequest
import com.example.editor.generated.model.RestoreRequest
import com.example.editor.domain.Document
import com.example.editor.domain.DocumentHistoryEntry
import com.example.editor.service.DocumentService
import org.springframework.http.HttpStatus
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController
import java.util.UUID
/**
* Implementiert das aus der OpenAPI-Spezifikation generierte Interface.
* Aendert sich die Spezifikation, schlaegt hier der Compile fehl so bleibt
* die Implementierung immer synchron zum Vertrag (API First).
*/
@RestController
@RequestMapping("/api/v1")
class DocumentsController(
private val service: DocumentService,
) : DocumentsApi {
override fun listDocuments(): ResponseEntity<List<ApiDocument>> =
ResponseEntity.ok(service.findAll().map { it.toApi() })
override fun createDocument(documentCreateRequest: DocumentCreateRequest): ResponseEntity<ApiDocument> {
val created = service.create(
title = documentCreateRequest.title,
content = documentCreateRequest.content,
)
return ResponseEntity.status(HttpStatus.CREATED).body(created.toApi())
}
override fun getDocument(documentId: UUID): ResponseEntity<ApiDocument> =
ResponseEntity.ok(service.findById(documentId).toApi())
override fun updateDocument(
documentId: UUID,
documentUpdateRequest: DocumentUpdateRequest,
): ResponseEntity<ApiDocument> {
val updated = service.update(
id = documentId,
title = documentUpdateRequest.title,
content = documentUpdateRequest.content,
)
return ResponseEntity.ok(updated.toApi())
}
override fun deleteDocument(documentId: UUID): ResponseEntity<Unit> {
service.delete(documentId)
return ResponseEntity.noContent().build()
}
override fun getDocumentHistory(documentId: UUID): ResponseEntity<List<ApiHistoryEntry>> =
ResponseEntity.ok(service.history(documentId).map { it.toApi() })
override fun restoreDocument(
documentId: UUID,
restoreRequest: RestoreRequest?,
): ResponseEntity<ApiDocument> {
val restored = service.restore(documentId, restoreRequest?.version)
return ResponseEntity.ok(restored.toApi())
}
private fun Document.toApi(): ApiDocument = ApiDocument(
id = id,
title = title,
content = content,
version = version,
createdAt = createdAt,
updatedAt = updatedAt,
)
private fun DocumentHistoryEntry.toApi(): ApiHistoryEntry = ApiHistoryEntry(
documentId = documentId,
version = version,
title = title,
content = content,
changeType = ApiHistoryEntry.ChangeType.valueOf(changeType.name),
timestamp = timestamp,
)
}
@@ -0,0 +1,28 @@
package com.example.editor.api
import com.example.editor.service.DocumentConflictException
import com.example.editor.service.DocumentNotFoundException
import org.springframework.http.HttpStatus
import org.springframework.http.ProblemDetail
import org.springframework.web.bind.annotation.ExceptionHandler
import org.springframework.web.bind.annotation.RestControllerAdvice
/**
* Zentrale Fehlerbehandlung im Problem-Details-Format (RFC 9457),
* passend zum ProblemDetail-Schema der OpenAPI-Spezifikation.
*/
@RestControllerAdvice
class GlobalExceptionHandler {
@ExceptionHandler(DocumentNotFoundException::class)
fun handleNotFound(ex: DocumentNotFoundException): ProblemDetail =
ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.message ?: "Nicht gefunden").apply {
title = "Dokument nicht gefunden"
}
@ExceptionHandler(DocumentConflictException::class)
fun handleConflict(ex: DocumentConflictException): ProblemDetail =
ProblemDetail.forStatusAndDetail(HttpStatus.CONFLICT, ex.message ?: "Konflikt").apply {
title = "Konflikt"
}
}
@@ -0,0 +1,21 @@
package com.example.editor.domain
import java.time.OffsetDateTime
import java.util.UUID
/**
* Internes Domänenmodell (bewusst getrennt vom generierten API-Modell).
*
* [version] wird bei jeder Änderung inkrementiert und dient später als Basis
* für Optimistic Locking und Live-Editing-Konflikterkennung.
* [content] ist ein opaker String bei clientseitiger Verschlüsselung wird
* hier später Ciphertext gespeichert, ohne dass sich das Modell ändert.
*/
data class Document(
val id: UUID,
val title: String,
val content: String,
val version: Long,
val createdAt: OffsetDateTime,
val updatedAt: OffsetDateTime,
)
@@ -0,0 +1,20 @@
package com.example.editor.domain
import java.time.OffsetDateTime
import java.util.UUID
enum class ChangeType { CREATED, UPDATED, DELETED, RESTORED }
/**
* 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.
*/
data class DocumentHistoryEntry(
val documentId: UUID,
val version: Long,
val title: String,
val content: String,
val changeType: ChangeType,
val timestamp: OffsetDateTime,
)
@@ -0,0 +1,51 @@
package com.example.editor.persistence
import com.example.editor.domain.Document
import jakarta.persistence.Column
import jakarta.persistence.Entity
import jakarta.persistence.Id
import jakarta.persistence.Table
import java.time.OffsetDateTime
import java.util.UUID
@Entity
@Table(name = "document")
class DocumentEntity(
@Id
val id: UUID,
@Column(nullable = false)
var title: String,
@Column(nullable = false, columnDefinition = "text")
var content: String,
@Column(nullable = false)
var version: Long,
@Column(name = "created_at", nullable = false)
var createdAt: OffsetDateTime,
@Column(name = "updated_at", nullable = false)
var updatedAt: OffsetDateTime,
) {
fun toDomain() = Document(
id = id,
title = title,
content = content,
version = version,
createdAt = createdAt,
updatedAt = updatedAt,
)
companion object {
fun fromDomain(document: Document) = DocumentEntity(
id = document.id,
title = document.title,
content = document.content,
version = document.version,
createdAt = document.createdAt,
updatedAt = document.updatedAt,
)
}
}
@@ -0,0 +1,61 @@
package com.example.editor.persistence
import com.example.editor.domain.ChangeType
import com.example.editor.domain.DocumentHistoryEntry
import jakarta.persistence.Column
import jakarta.persistence.Entity
import jakarta.persistence.EnumType
import jakarta.persistence.Enumerated
import jakarta.persistence.GeneratedValue
import jakarta.persistence.GenerationType
import jakarta.persistence.Id
import jakarta.persistence.Table
import java.time.OffsetDateTime
import java.util.UUID
@Entity
@Table(name = "document_history")
class DocumentHistoryEntity(
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
val id: Long? = null,
@Column(name = "document_id", nullable = false)
val documentId: UUID,
@Column(nullable = false)
val version: Long,
@Column(nullable = false)
val title: String,
@Column(nullable = false, columnDefinition = "text")
val content: String,
@Enumerated(EnumType.STRING)
@Column(name = "change_type", nullable = false, length = 16)
val changeType: ChangeType,
@Column(name = "change_time", nullable = false)
val changeTime: OffsetDateTime,
) {
fun toDomain() = DocumentHistoryEntry(
documentId = documentId,
version = version,
title = title,
content = content,
changeType = changeType,
timestamp = changeTime,
)
companion object {
fun fromDomain(entry: DocumentHistoryEntry) = DocumentHistoryEntity(
documentId = entry.documentId,
version = entry.version,
title = entry.title,
content = entry.content,
changeType = entry.changeType,
changeTime = entry.timestamp,
)
}
}
@@ -0,0 +1,21 @@
package com.example.editor.persistence
import com.example.editor.domain.DocumentHistoryEntry
import com.example.editor.repository.DocumentHistoryRepository
import org.springframework.stereotype.Repository
import java.util.UUID
@Repository
class JpaDocumentHistoryRepository(
private val jpa: DocumentHistoryJpaRepository,
) : DocumentHistoryRepository {
override fun append(entry: DocumentHistoryEntry) {
jpa.save(DocumentHistoryEntity.fromDomain(entry))
}
override fun findByDocumentId(documentId: UUID): List<DocumentHistoryEntry> =
jpa.findByDocumentIdOrderByIdAsc(documentId).map { it.toDomain() }
override fun clear() = jpa.deleteAll()
}
@@ -0,0 +1,35 @@
package com.example.editor.persistence
import com.example.editor.domain.Document
import com.example.editor.repository.DocumentRepository
import org.springframework.stereotype.Repository
import java.util.UUID
/**
* JPA-Adapter fuer das fachliche Repository-Interface.
* Ersetzt die fruehere In-Memory-Implementierung.
*/
@Repository
class JpaDocumentRepository(
private val jpa: DocumentJpaRepository,
) : DocumentRepository {
override fun findAll(): List<Document> =
jpa.findAll().map { it.toDomain() }.sortedBy { it.createdAt }
override fun findById(id: UUID): Document? =
jpa.findById(id).map { it.toDomain() }.orElse(null)
override fun save(document: Document): Document {
jpa.save(DocumentEntity.fromDomain(document))
return document
}
override fun deleteById(id: UUID): Boolean {
if (!jpa.existsById(id)) return false
jpa.deleteById(id)
return true
}
override fun clear() = jpa.deleteAll()
}
@@ -0,0 +1,10 @@
package com.example.editor.persistence
import org.springframework.data.jpa.repository.JpaRepository
import java.util.UUID
interface DocumentJpaRepository : JpaRepository<DocumentEntity, UUID>
interface DocumentHistoryJpaRepository : JpaRepository<DocumentHistoryEntity, Long> {
fun findByDocumentIdOrderByIdAsc(documentId: UUID): List<DocumentHistoryEntity>
}
@@ -0,0 +1,13 @@
package com.example.editor.repository
import com.example.editor.domain.DocumentHistoryEntry
import java.util.UUID
interface DocumentHistoryRepository {
fun append(entry: DocumentHistoryEntry)
/** Alle Einträge zu einem Dokument, älteste zuerst. */
fun findByDocumentId(documentId: UUID): List<DocumentHistoryEntry>
fun clear()
}
@@ -0,0 +1,17 @@
package com.example.editor.repository
import com.example.editor.domain.Document
import java.util.UUID
/**
* Abstraktion über die Persistenz. Die In-Memory-Implementierung ist ein
* Platzhalter und kann später durch JPA/R2DBC ersetzt werden, ohne dass
* Service oder Controller angepasst werden müssen.
*/
interface DocumentRepository {
fun findAll(): List<Document>
fun findById(id: UUID): Document?
fun save(document: Document): Document
fun deleteById(id: UUID): Boolean
fun clear()
}
@@ -0,0 +1,11 @@
package com.example.editor.service
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import java.time.Clock
@Configuration
class ClockConfiguration {
@Bean
fun clock(): Clock = Clock.systemUTC()
}
@@ -0,0 +1,3 @@
package com.example.editor.service
class DocumentConflictException(message: String) : RuntimeException(message)
@@ -0,0 +1,6 @@
package com.example.editor.service
import java.util.UUID
class DocumentNotFoundException(id: UUID) :
RuntimeException("Dokument mit ID $id wurde nicht gefunden")
@@ -0,0 +1,131 @@
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 org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import java.time.Clock
import java.time.OffsetDateTime
import java.util.UUID
@Service
@Transactional
class DocumentService(
private val repository: DocumentRepository,
private val historyRepository: DocumentHistoryRepository,
private val clock: Clock,
) {
fun findAll(): List<Document> = repository.findAll()
fun findById(id: UUID): Document =
repository.findById(id) ?: throw DocumentNotFoundException(id)
fun create(title: String, content: String): Document {
val now = OffsetDateTime.now(clock)
val document = Document(
id = UUID.randomUUID(),
title = title,
content = content,
version = 1,
createdAt = now,
updatedAt = now,
)
repository.save(document)
recordHistory(document, ChangeType.CREATED)
return document
}
fun update(id: UUID, title: String, content: String): Document {
val existing = findById(id)
val updated = existing.copy(
title = title,
content = content,
version = existing.version + 1,
updatedAt = OffsetDateTime.now(clock),
)
repository.save(updated)
recordHistory(updated, ChangeType.UPDATED)
return updated
}
fun delete(id: UUID) {
val existing = findById(id)
repository.deleteById(id)
// Tombstone-Eintrag: konserviert den letzten Stand und überlebt das DELETE.
recordHistory(
existing.copy(
version = existing.version + 1,
updatedAt = OffsetDateTime.now(clock),
),
ChangeType.DELETED,
)
}
/**
* Historie eines Dokuments 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
}
/**
* Stellt ein Dokument unter derselben UUID wieder her.
*
* - Ohne [targetVersion]: letzter inhaltlicher Stand vor dem Löschen.
* 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.
*/
fun restore(id: UUID, targetVersion: Long? = null): Document {
val entries = historyRepository.findByDocumentId(id)
if (entries.isEmpty()) throw DocumentNotFoundException(id)
val existing = repository.findById(id)
if (existing != null && targetVersion == null) {
throw DocumentConflictException(
"Dokument $id existiert noch; zum Rollback bitte eine Zielversion angeben"
)
}
val snapshot = if (targetVersion != null) {
entries.lastOrNull { it.version == targetVersion && it.changeType != ChangeType.DELETED }
?: throw DocumentNotFoundException(id)
} else {
entries.last { it.changeType != ChangeType.DELETED }
}
val now = OffsetDateTime.now(clock)
val lastVersion = maxOf(entries.maxOf { it.version }, existing?.version ?: 0)
val restored = Document(
id = id,
title = snapshot.title,
content = snapshot.content,
version = lastVersion + 1,
createdAt = existing?.createdAt ?: entries.first().timestamp,
updatedAt = now,
)
repository.save(restored)
recordHistory(restored, ChangeType.RESTORED)
return restored
}
private fun recordHistory(document: Document, changeType: ChangeType) {
historyRepository.append(
DocumentHistoryEntry(
documentId = document.id,
version = document.version,
title = document.title,
content = document.content,
changeType = changeType,
timestamp = document.updatedAt,
)
)
}
}
@@ -0,0 +1,22 @@
spring:
application:
name: editor-backend
datasource:
# H2 im File-Modus mit PostgreSQL-Kompatibilitaet.
# Spaeterer Umstieg auf echtes PostgreSQL = im Wesentlichen nur diese URL aendern.
url: jdbc:h2:file:./data/editor;MODE=PostgreSQL;DATABASE_TO_LOWER=TRUE;DEFAULT_NULL_ORDERING=HIGH
username: sa
password: ""
driver-class-name: org.h2.Driver
jpa:
hibernate:
ddl-auto: validate # Schema kommt ausschliesslich von Liquibase
open-in-view: false
liquibase:
change-log: classpath:db/changelog/db.changelog-master.sql
server:
port: 8080
@@ -0,0 +1,28 @@
--liquibase formatted sql
--changeset editor:001-create-document
CREATE TABLE document (
id UUID PRIMARY KEY,
title VARCHAR(255) NOT NULL,
content TEXT NOT NULL,
version BIGINT NOT NULL,
created_at TIMESTAMP WITH TIME ZONE NOT NULL,
updated_at TIMESTAMP WITH TIME ZONE NOT NULL
);
--rollback DROP TABLE document;
--changeset editor:002-create-document-history
CREATE TABLE document_history (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
document_id UUID NOT NULL,
version BIGINT NOT NULL,
title VARCHAR(255) NOT NULL,
content TEXT NOT NULL,
change_type VARCHAR(16) NOT NULL,
change_time TIMESTAMP WITH TIME ZONE NOT NULL
);
--rollback DROP 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;
+308
View File
@@ -0,0 +1,308 @@
openapi: 3.0.3
info:
title: Editor Backend API
description: |
CRUD-Grundgeruest fuer Dokumente.
Vorbereitete Erweiterungen (noch nicht aktiv):
- Autorisierung: securitySchemes.bearerAuth ist definiert, wird aber noch
auf keine Operation angewendet.
- Live-Editing: Das Feld `version` dient spaeter der Konflikterkennung
(Optimistic Locking) und als Basis fuer Delta-Updates via WebSocket.
- Clientseitige Verschluesselung: `content` ist ein opaker String. Der
Server interpretiert den Inhalt nicht, sodass spaeter Ciphertext
transportiert werden kann, ohne die API zu aendern.
version: 0.1.0
servers:
- url: /api/v1
tags:
- name: Documents
description: Verwaltung von Dokumenten
paths:
/documents:
get:
tags: [Documents]
operationId: listDocuments
summary: Alle Dokumente auflisten
responses:
"200":
description: Liste aller Dokumente
content:
application/json:
schema:
type: array
items:
$ref: "#/components/schemas/Document"
post:
tags: [Documents]
operationId: createDocument
summary: Neues Dokument anlegen
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/DocumentCreateRequest"
responses:
"201":
description: Dokument wurde angelegt
content:
application/json:
schema:
$ref: "#/components/schemas/Document"
"400":
$ref: "#/components/responses/BadRequest"
/documents/{documentId}:
parameters:
- name: documentId
in: path
required: true
schema:
type: string
format: uuid
get:
tags: [Documents]
operationId: getDocument
summary: Einzelnes Dokument abrufen
responses:
"200":
description: Das angeforderte Dokument
content:
application/json:
schema:
$ref: "#/components/schemas/Document"
"404":
$ref: "#/components/responses/NotFound"
put:
tags: [Documents]
operationId: updateDocument
summary: Dokument vollstaendig aktualisieren
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/DocumentUpdateRequest"
responses:
"200":
description: Aktualisiertes Dokument
content:
application/json:
schema:
$ref: "#/components/schemas/Document"
"400":
$ref: "#/components/responses/BadRequest"
"404":
$ref: "#/components/responses/NotFound"
"409":
description: Versionskonflikt (fuer spaeteres Optimistic Locking reserviert)
content:
application/problem+json:
schema:
$ref: "#/components/schemas/ProblemDetail"
delete:
tags: [Documents]
operationId: deleteDocument
summary: Dokument loeschen
responses:
"204":
description: Dokument wurde geloescht
"404":
$ref: "#/components/responses/NotFound"
/documents/{documentId}/history:
parameters:
- name: documentId
in: path
required: true
schema:
type: string
format: uuid
get:
tags: [Documents]
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.
responses:
"200":
description: Historie des Dokuments (aelteste zuerst)
content:
application/json:
schema:
type: array
items:
$ref: "#/components/schemas/DocumentHistoryEntry"
"404":
$ref: "#/components/responses/NotFound"
/documents/{documentId}/restore:
parameters:
- name: documentId
in: path
required: true
schema:
type: string
format: uuid
post:
tags: [Documents]
operationId: restoreDocument
summary: Geloeschtes Dokument aus der Historie wiederherstellen
description: >
Stellt ein geloeschtes Dokument unter derselben UUID wieder her.
Ohne Request-Body wird der letzte Stand vor dem Loeschen
wiederhergestellt; optional kann eine bestimmte Version angegeben
werden (auch als Rollback fuer ein noch existierendes Dokument).
requestBody:
required: false
content:
application/json:
schema:
$ref: "#/components/schemas/RestoreRequest"
responses:
"200":
description: Wiederhergestelltes Dokument
content:
application/json:
schema:
$ref: "#/components/schemas/Document"
"404":
$ref: "#/components/responses/NotFound"
"409":
description: >
Dokument existiert noch und es wurde keine Zielversion angegeben.
content:
application/problem+json:
schema:
$ref: "#/components/schemas/ProblemDetail"
components:
responses:
NotFound:
description: Ressource nicht gefunden
content:
application/problem+json:
schema:
$ref: "#/components/schemas/ProblemDetail"
BadRequest:
description: Ungueltige Anfrage
content:
application/problem+json:
schema:
$ref: "#/components/schemas/ProblemDetail"
schemas:
Document:
type: object
required: [id, title, content, version, createdAt, updatedAt]
properties:
id:
type: string
format: uuid
readOnly: true
title:
type: string
maxLength: 255
content:
type: string
description: >
Opaker Inhalt. Bei clientseitiger Verschluesselung enthaelt dieses
Feld spaeter den Ciphertext.
version:
type: integer
format: int64
description: Wird bei jeder Aenderung inkrementiert (Basis fuer Live-Editing/Konflikterkennung).
createdAt:
type: string
format: date-time
readOnly: true
updatedAt:
type: string
format: date-time
readOnly: true
DocumentCreateRequest:
type: object
required: [title, content]
properties:
title:
type: string
minLength: 1
maxLength: 255
content:
type: string
DocumentUpdateRequest:
type: object
required: [title, content]
properties:
title:
type: string
minLength: 1
maxLength: 255
content:
type: string
expectedVersion:
type: integer
format: int64
description: Optional; wird spaeter fuer Optimistic Locking ausgewertet.
DocumentHistoryEntry:
type: object
required: [documentId, version, title, content, changeType, timestamp]
properties:
documentId:
type: string
format: uuid
version:
type: integer
format: int64
title:
type: string
content:
type: string
changeType:
type: string
enum: [CREATED, UPDATED, DELETED, RESTORED]
timestamp:
type: string
format: date-time
RestoreRequest:
type: object
properties:
version:
type: integer
format: int64
description: >
Optionale Zielversion. Ohne Angabe wird der letzte inhaltliche
Stand vor dem Loeschen wiederhergestellt.
ProblemDetail:
type: object
description: Fehlerformat nach RFC 9457 (Problem Details)
properties:
type:
type: string
title:
type: string
status:
type: integer
detail:
type: string
instance:
type: string
securitySchemes:
bearerAuth:
type: http
scheme: bearer
bearerFormat: JWT
description: >
Noch nicht aktiv. Wird bei Einfuehrung der Autorisierung auf die
Operationen angewendet (security: - bearerAuth: []).
@@ -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