feat(geschichte): JourneyItem CRUD API — append, updateNote, delete, reorder (#751) (#788)
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 3m20s
CI / OCR Service Tests (pull_request) Successful in 22s
CI / Backend Unit Tests (pull_request) Successful in 3m48s
CI / fail2ban Regex (pull_request) Successful in 45s
CI / Semgrep Security Scan (pull_request) Successful in 22s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m6s
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 3m20s
CI / OCR Service Tests (pull_request) Successful in 22s
CI / Backend Unit Tests (pull_request) Successful in 3m48s
CI / fail2ban Regex (pull_request) Successful in 45s
CI / Semgrep Security Scan (pull_request) Successful in 22s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m6s
## Summary Implements the backend JourneyItem CRUD API on top of the data model from #750, building towards the full Lesereisen feature (#751). **Completed in this PR:** - `jackson-databind-nullable` 0.2.6 + `JacksonConfig` (`@Bean Module`) for three-way PATCH semantics (`JsonNullable`) - `AuditKind`: `JOURNEY_ITEM_ADDED`, `JOURNEY_ITEM_REMOVED`, `JOURNEY_ITEMS_REORDERED` (last is rollup-eligible) - `ErrorCode`: `JOURNEY_ITEM_NOT_FOUND`, `JOURNEY_ITEM_POSITION_CONFLICT` - V73 migration: `UNIQUE (geschichte_id, position) DEFERRABLE INITIALLY DEFERRED` + `CHECK (position > 0)` on `journey_items` - `JourneyItemConstraintsTest`: verifies deferrable flag via `pg_constraint` query; position check; duplicate position rejection (3 passing tests) - Read models: `DocumentSummary`, `JourneyItemView`, `GeschichteView` (with `AuthorView` to prevent AppUser email leak) - `DocumentService.getSummaryById` — lean lookup without tag-color resolution - `JourneyItemRepository`: extended with `findByGeschichteIdOrderByPosition`, `findByIdAndGeschichteId` (IDOR-safe), `findIdsByGeschichteId`, `findMaxPositionByGeschichteId`, `countByGeschichteId` - DTOs: `JourneyItemCreateDTO`, `JourneyItemUpdateDTO` (`JsonNullable<String> note`), `JourneyReorderDTO` **Still in progress (WIP):** - `JourneyItemService` — `append`, `updateNote`, `delete`, `reorder`, `toSummary`, `toView` (Task 6) - `GeschichteService.getById` → returns `GeschichteView` (Task 7) - New endpoints on `GeschichteController` + slice tests (Task 8) - Frontend error codes + i18n + `npm run generate:api` (Task 9) ## Commits - `0b177247` feat(config): add jackson-databind-nullable for JsonNullable PATCH DTO support - `408ae334` feat(audit,error): add JourneyItem AuditKind values and ErrorCodes - `7b06c3ad` feat(migration): V73 adds UNIQUE DEFERRABLE and CHECK position > 0 on journey_items - `160ca1c3` feat(geschichte): add DocumentSummary, JourneyItemView, GeschichteView read models - `2ad5c36e` feat(geschichte): extend JourneyItemRepository and add item DTOs ## Test plan - [ ] `./mvnw test -Dtest=JourneyItemConstraintsTest` — all 3 constraint tests pass - [ ] `./mvnw clean package -DskipTests` — builds clean after remaining tasks are merged - [ ] Frontend: `npm run generate:api` after Task 9 endpoint additions Co-authored-by: Marcel <marcel@familienarchiv> Reviewed-on: #788
This commit was merged in pull request #788.
This commit is contained in:
@@ -86,7 +86,8 @@ backend/src/main/java/org/raddatz/familienarchiv/
|
||||
│ └── transcription/ TranscriptionBlock, TranscriptionService, TranscriptionBlockQueryService
|
||||
├── exception/ DomainException, ErrorCode, GlobalExceptionHandler
|
||||
├── filestorage/ FileService (S3/MinIO)
|
||||
├── geschichte/ Geschichte (story) domain
|
||||
├── geschichte/ Geschichte (story) domain — GeschichteService, GeschichteQueryService
|
||||
│ └── journeyitem/ JourneyItem sub-domain — JourneyItemService, JourneyItemController
|
||||
├── importing/ CanonicalImportOrchestrator + four loaders (TagTree/PersonRegister/PersonTree/Document) + CanonicalSheetReader
|
||||
├── notification/ Notification domain + SseEmitterRegistry
|
||||
├── ocr/ OCR domain — OcrService, OcrBatchService, training
|
||||
|
||||
@@ -33,7 +33,8 @@ src/main/java/org/raddatz/familienarchiv/
|
||||
│ └── transcription/ # TranscriptionBlock, TranscriptionService, TranscriptionBlockQueryService
|
||||
├── exception/ # DomainException, ErrorCode, GlobalExceptionHandler
|
||||
├── filestorage/ # FileService (S3/MinIO)
|
||||
├── geschichte/ # Geschichte (story) domain
|
||||
├── geschichte/ # Geschichte (story) domain — GeschichteService, GeschichteQueryService
|
||||
│ └── journeyitem/ # JourneyItem sub-domain — JourneyItemService, JourneyItemController
|
||||
├── importing/ # CanonicalImportOrchestrator + 4 loaders + CanonicalSheetReader
|
||||
├── notification/ # Notification domain + SseEmitterRegistry
|
||||
├── ocr/ # OCR domain — OcrService, OcrBatchService, training
|
||||
|
||||
@@ -50,10 +50,25 @@ public enum AuditKind {
|
||||
ADMIN_FORCE_LOGOUT,
|
||||
|
||||
/** Payload: {@code {"ip": "1.2.3.4", "email": "addr"}} — password NEVER included */
|
||||
LOGIN_RATE_LIMITED;
|
||||
LOGIN_RATE_LIMITED,
|
||||
|
||||
// --- Reading Journeys (Lesereisen) ---
|
||||
|
||||
/** Payload: {@code {"geschichteId": "uuid", "itemId": "uuid"}} — documentId is null (journey-scoped, not document-scoped) */
|
||||
JOURNEY_ITEM_ADDED,
|
||||
|
||||
/** Payload: {@code {"geschichteId": "uuid", "itemId": "uuid"}} — documentId is null */
|
||||
JOURNEY_ITEM_REMOVED,
|
||||
|
||||
/** Payload: {@code {"geschichteId": "uuid", "itemId": "uuid"}} — documentId is null */
|
||||
JOURNEY_ITEM_NOTE_UPDATED,
|
||||
|
||||
/** Payload: {@code {"geschichteId": "uuid", "itemCount": 3}} — documentId is null; rolled up in chronik */
|
||||
JOURNEY_ITEMS_REORDERED;
|
||||
|
||||
public static final Set<AuditKind> ROLLUP_ELIGIBLE = Set.of(
|
||||
TEXT_SAVED, FILE_UPLOADED, ANNOTATION_CREATED,
|
||||
BLOCK_REVIEWED, COMMENT_ADDED, MENTION_CREATED
|
||||
BLOCK_REVIEWED, COMMENT_ADDED, MENTION_CREATED,
|
||||
JOURNEY_ITEMS_REORDERED
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1006,6 +1006,28 @@ public class DocumentService {
|
||||
return doc;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lightweight summary lookup for internal use (e.g. journey item append validation).
|
||||
*
|
||||
* <p><strong>Security contract — read before calling:</strong>
|
||||
* <ol>
|
||||
* <li>This method intentionally bypasses per-document scope checks and
|
||||
* tag-colour resolution. It must only be invoked after
|
||||
* {@code @RequirePermission(BLOG_WRITE)} has already been enforced at
|
||||
* the controller layer, guaranteeing the caller is an authenticated
|
||||
* author.</li>
|
||||
* <li>In {@code JourneyItemService.append()}, it is additionally guarded by the
|
||||
* JOURNEY-type check that fires before this call — so the method is never
|
||||
* reached for STORY-type Geschichten.</li>
|
||||
* </ol>
|
||||
* Under the current single-tenant model every authenticated author shares the
|
||||
* same document scope, so skipping per-document scope checks is safe.
|
||||
*/
|
||||
public Document findSummaryByIdInternal(UUID id) {
|
||||
return documentRepository.findById(id)
|
||||
.orElseThrow(() -> DomainException.notFound(ErrorCode.DOCUMENT_NOT_FOUND, "Document not found: " + id));
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads a document for the detail view, additionally flagging whether it has any
|
||||
* transcription to read. Kept separate from {@link #getDocumentById} so the cheap
|
||||
|
||||
@@ -122,6 +122,14 @@ public enum ErrorCode {
|
||||
// --- Geschichten (Stories) ---
|
||||
/** A Geschichte (story) with the given ID does not exist, or is a DRAFT and the caller lacks BLOG_WRITE. 404 */
|
||||
GESCHICHTE_NOT_FOUND,
|
||||
/** A JourneyItem with the given ID does not exist, or belongs to a different journey (IDOR). 404 */
|
||||
JOURNEY_ITEM_NOT_FOUND,
|
||||
/** A position uniqueness conflict occurred on the journey_items table — concurrent append or reorder. 409 */
|
||||
JOURNEY_ITEM_POSITION_CONFLICT,
|
||||
/** The journey already has the maximum allowed number of items (100). 400 */
|
||||
JOURNEY_AT_CAPACITY,
|
||||
/** The Geschichte is not of type JOURNEY — journey-item operations are not allowed on it. 400 */
|
||||
GESCHICHTE_TYPE_MISMATCH,
|
||||
|
||||
// --- Tags ---
|
||||
/** A tag with the given ID does not exist. 404 */
|
||||
|
||||
@@ -78,7 +78,14 @@ public class GlobalExceptionHandler {
|
||||
// Log the constraint NAME only — schema metadata, safe for Loki, and enough to tell which
|
||||
// constraint fired at 2am. Never pass `ex` / `ex.getMessage()`: those embed the SQL + the
|
||||
// offending values (CWE-209). No Sentry: an integrity violation is a 400, not a system fault.
|
||||
log.warn("Rejected a request that violated a database integrity constraint: {}", constraintNameOf(ex));
|
||||
String constraint = constraintNameOf(ex);
|
||||
log.warn("Rejected a request that violated a database integrity constraint: {}", constraint);
|
||||
if ("uq_journey_items_geschichte_position".equals(constraint)) {
|
||||
// DEFERRABLE INITIALLY DEFERRED — fires at commit when concurrent appends/reorders collide
|
||||
return ResponseEntity.status(409)
|
||||
.body(new ErrorResponse(ErrorCode.JOURNEY_ITEM_POSITION_CONFLICT,
|
||||
"A position conflict was detected — another request modified this journey simultaneously"));
|
||||
}
|
||||
return ResponseEntity.badRequest()
|
||||
.body(new ErrorResponse(ErrorCode.VALIDATION_ERROR, "The submitted data violated a database constraint"));
|
||||
}
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
package org.raddatz.familienarchiv.geschichte;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.raddatz.familienarchiv.geschichte.journeyitem.JourneyItemCreateDTO;
|
||||
import org.raddatz.familienarchiv.geschichte.journeyitem.JourneyItemService;
|
||||
import org.raddatz.familienarchiv.geschichte.journeyitem.JourneyItemUpdateDTO;
|
||||
import org.raddatz.familienarchiv.geschichte.journeyitem.JourneyItemView;
|
||||
import org.raddatz.familienarchiv.geschichte.journeyitem.JourneyReorderDTO;
|
||||
import org.raddatz.familienarchiv.security.Permission;
|
||||
import org.raddatz.familienarchiv.security.RequirePermission;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
@@ -10,6 +16,7 @@ import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PatchMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.PutMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
@@ -24,6 +31,7 @@ import java.util.UUID;
|
||||
public class GeschichteController {
|
||||
|
||||
private final GeschichteService geschichteService;
|
||||
private final JourneyItemService journeyItemService;
|
||||
|
||||
@GetMapping
|
||||
public List<GeschichteSummary> list(
|
||||
@@ -37,8 +45,8 @@ public class GeschichteController {
|
||||
}
|
||||
|
||||
@GetMapping("/{id}")
|
||||
public Geschichte getById(@PathVariable UUID id) {
|
||||
return geschichteService.getById(id);
|
||||
public GeschichteView getById(@PathVariable UUID id) {
|
||||
return geschichteService.getView(id);
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
@@ -60,4 +68,45 @@ public class GeschichteController {
|
||||
geschichteService.delete(id);
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
// ─── JourneyItem CRUD ────────────────────────────────────────────────────
|
||||
|
||||
@PostMapping("/{id}/items")
|
||||
@RequirePermission(Permission.BLOG_WRITE)
|
||||
public ResponseEntity<JourneyItemView> appendItem(
|
||||
@PathVariable UUID id,
|
||||
@RequestBody JourneyItemCreateDTO dto) {
|
||||
JourneyItemView view = journeyItemService.append(id, dto);
|
||||
return ResponseEntity.status(HttpStatus.CREATED).body(view);
|
||||
}
|
||||
|
||||
@PatchMapping("/{id}/items/{itemId}")
|
||||
@RequirePermission(Permission.BLOG_WRITE)
|
||||
public JourneyItemView updateItemNote(
|
||||
@PathVariable UUID id,
|
||||
@PathVariable UUID itemId,
|
||||
@RequestBody JourneyItemUpdateDTO dto) {
|
||||
return journeyItemService.updateNote(id, itemId, dto);
|
||||
}
|
||||
|
||||
@DeleteMapping("/{id}/items/{itemId}")
|
||||
@RequirePermission(Permission.BLOG_WRITE)
|
||||
public ResponseEntity<Void> deleteItem(
|
||||
@PathVariable UUID id,
|
||||
@PathVariable UUID itemId) {
|
||||
journeyItemService.delete(id, itemId);
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
@PutMapping("/{id}/items/reorder")
|
||||
@RequirePermission(Permission.BLOG_WRITE)
|
||||
@Operation(
|
||||
summary = "Reorder journey items",
|
||||
description = "itemIds must contain ALL item IDs for the given journey in the desired new order. Sending a partial list returns 400 Bad Request."
|
||||
)
|
||||
public List<JourneyItemView> reorderItems(
|
||||
@PathVariable UUID id,
|
||||
@RequestBody JourneyReorderDTO dto) {
|
||||
return journeyItemService.reorder(id, dto);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
package org.raddatz.familienarchiv.geschichte;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Thin read-only service owning {@link GeschichteRepository}.
|
||||
* Exists so that {@code JourneyItemService} can check Geschichte existence
|
||||
* and load Geschichte instances without holding a direct reference to the
|
||||
* Geschichte repository (cross-domain repository access is not allowed per
|
||||
* layering rules).
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class GeschichteQueryService {
|
||||
|
||||
private final GeschichteRepository geschichteRepository;
|
||||
|
||||
public boolean existsById(UUID id) {
|
||||
return geschichteRepository.existsById(id);
|
||||
}
|
||||
|
||||
public Optional<Geschichte> findById(UUID id) {
|
||||
return geschichteRepository.findById(id);
|
||||
}
|
||||
}
|
||||
@@ -2,11 +2,12 @@ package org.raddatz.familienarchiv.geschichte;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.hibernate.Hibernate;
|
||||
import org.owasp.html.HtmlPolicyBuilder;
|
||||
import org.owasp.html.PolicyFactory;
|
||||
import org.raddatz.familienarchiv.exception.DomainException;
|
||||
import org.raddatz.familienarchiv.exception.ErrorCode;
|
||||
import org.raddatz.familienarchiv.geschichte.journeyitem.JourneyItemService;
|
||||
import org.raddatz.familienarchiv.geschichte.journeyitem.JourneyItemView;
|
||||
import org.raddatz.familienarchiv.user.AppUser;
|
||||
import org.raddatz.familienarchiv.person.Person;
|
||||
import org.raddatz.familienarchiv.security.Permission;
|
||||
@@ -33,10 +34,9 @@ public class GeschichteService {
|
||||
|
||||
private final GeschichteRepository geschichteRepository;
|
||||
private final PersonService personService;
|
||||
// Reserved for lesereisen-editor: JourneyItem document resolution must go through
|
||||
// DocumentService.getDocumentById to enforce existence and scope checks.
|
||||
private final DocumentService documentService;
|
||||
private final UserService userService;
|
||||
private final JourneyItemService journeyItemService;
|
||||
|
||||
/**
|
||||
* Allow-list policy for Geschichte body HTML. Tiptap on the writer side
|
||||
@@ -66,13 +66,37 @@ public class GeschichteService {
|
||||
throw DomainException.notFound(
|
||||
ErrorCode.GESCHICHTE_NOT_FOUND, "Geschichte not found: " + id);
|
||||
}
|
||||
// Force-initialize LAZY items inside this transaction.
|
||||
// open-in-view is FALSE — without this touch, Jackson serializes a closed
|
||||
// Hibernate session and throws LazyInitializationException → HTTP 500.
|
||||
Hibernate.initialize(g.getItems());
|
||||
return g;
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public GeschichteView getView(UUID id) {
|
||||
Geschichte g = getById(id);
|
||||
List<JourneyItemView> items = journeyItemService.getItems(id);
|
||||
return toView(g, items);
|
||||
}
|
||||
|
||||
GeschichteView toView(Geschichte g, List<JourneyItemView> items) {
|
||||
AppUser author = g.getAuthor();
|
||||
GeschichteView.AuthorView authorView = null;
|
||||
if (author != null) {
|
||||
String displayName = PersonNameFormatter.join(author.getFirstName(), author.getLastName());
|
||||
if (displayName.isBlank()) displayName = "[Unbekannt]";
|
||||
authorView = new GeschichteView.AuthorView(author.getId(), displayName);
|
||||
}
|
||||
Set<GeschichteView.PersonView> personViews = new HashSet<>();
|
||||
for (Person p : g.getPersons()) {
|
||||
personViews.add(new GeschichteView.PersonView(p.getId(), p.getFirstName(), p.getLastName()));
|
||||
}
|
||||
return new GeschichteView(
|
||||
g.getId(), g.getTitle(), g.getBody(),
|
||||
g.getStatus(), g.getType(),
|
||||
authorView, personViews,
|
||||
items,
|
||||
g.getPublishedAt(), g.getCreatedAt(), g.getUpdatedAt()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists Geschichten with optional filters. {@code personIds} uses AND semantics: the story
|
||||
* must be associated with every person id supplied. An empty or null list applies no
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
package org.raddatz.familienarchiv.geschichte;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import org.raddatz.familienarchiv.geschichte.journeyitem.JourneyItemView;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Detail-view response for GET /api/geschichten/{id}. Assembled by
|
||||
* GeschichteService — never the raw entity (author AppUser graph must not leak).
|
||||
* items is always present (both STORY and JOURNEY); empty list for stories with no items.
|
||||
*/
|
||||
public record GeschichteView(
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) UUID id,
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) String title,
|
||||
String body,
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) GeschichteStatus status,
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) GeschichteType type,
|
||||
AuthorView author,
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) Set<PersonView> persons,
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) List<JourneyItemView> items,
|
||||
LocalDateTime publishedAt,
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) LocalDateTime createdAt,
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) LocalDateTime updatedAt
|
||||
) {
|
||||
/** Summarised author — exposes only id and displayName, never email or group memberships. */
|
||||
public record AuthorView(
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) UUID id,
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) String displayName
|
||||
) {}
|
||||
|
||||
/** Summarised person — exposes only id, firstName, and lastName. No admin-only fields. */
|
||||
public record PersonView(
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) UUID id,
|
||||
String firstName,
|
||||
String lastName
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package org.raddatz.familienarchiv.geschichte;
|
||||
|
||||
/**
|
||||
* Utility for joining a person's first and last name into a display string.
|
||||
* Centralises the logic that was previously duplicated across GeschichteService
|
||||
* and JourneyItemService.
|
||||
*/
|
||||
public class PersonNameFormatter {
|
||||
|
||||
private PersonNameFormatter() {
|
||||
// utility class — no instances
|
||||
}
|
||||
|
||||
public static String join(String firstName, String lastName) {
|
||||
String first = firstName != null ? firstName.trim() : "";
|
||||
String last = lastName != null ? lastName.trim() : "";
|
||||
if (first.isEmpty() && last.isEmpty()) return "";
|
||||
if (first.isEmpty()) return last;
|
||||
if (last.isEmpty()) return first;
|
||||
return first + " " + last;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package org.raddatz.familienarchiv.geschichte.journeyitem;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import org.raddatz.familienarchiv.document.DatePrecision;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Lean read-model view of a Document for embedding in JourneyItemView.
|
||||
* Built by JourneyItemService.toSummary(Document) — never serialised from
|
||||
* a JPA entity to avoid LazyInitializationException and tag-color overhead.
|
||||
*/
|
||||
public record DocumentSummary(
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) UUID id,
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) String title,
|
||||
LocalDate documentDate,
|
||||
LocalDate documentDateEnd,
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) DatePrecision datePrecision,
|
||||
String senderName,
|
||||
String receiverName,
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) Integer receiverCount
|
||||
) {}
|
||||
@@ -0,0 +1,12 @@
|
||||
package org.raddatz.familienarchiv.geschichte.journeyitem;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
/** Input for POST /api/geschichten/{id}/items. Both fields optional; at least one must be present. */
|
||||
@Data
|
||||
public class JourneyItemCreateDTO {
|
||||
private UUID documentId;
|
||||
private String note;
|
||||
}
|
||||
@@ -1,13 +1,41 @@
|
||||
package org.raddatz.familienarchiv.geschichte.journeyitem;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
|
||||
@Repository
|
||||
public interface JourneyItemRepository extends JpaRepository<JourneyItem, UUID> {
|
||||
|
||||
List<JourneyItem> findAllByGeschichteId(UUID geschichteId);
|
||||
/** Returns items ordered by position ASC for the read-model assembly path. */
|
||||
List<JourneyItem> findByGeschichteIdOrderByPosition(UUID geschichteId);
|
||||
|
||||
/** IDOR-safe lookup: returns empty when itemId exists but belongs to a different journey. */
|
||||
Optional<JourneyItem> findByIdAndGeschichteId(UUID id, UUID geschichteId);
|
||||
|
||||
/** Returns only the IDs — used for set-equality check in reorder. */
|
||||
@Query("SELECT i.id FROM JourneyItem i WHERE i.geschichte.id = :geschichteId")
|
||||
Set<UUID> findIdsByGeschichteId(@Param("geschichteId") UUID geschichteId);
|
||||
|
||||
/** MAX position for computing the next append position; returns empty when journey has no items. */
|
||||
@Query("SELECT MAX(i.position) FROM JourneyItem i WHERE i.geschichte.id = :geschichteId")
|
||||
Optional<Integer> findMaxPositionByGeschichteId(@Param("geschichteId") UUID geschichteId);
|
||||
|
||||
/** COUNT for the 100-item cap check — COUNT(*)-based, never MAX(position)-derived. */
|
||||
long countByGeschichteId(UUID geschichteId);
|
||||
|
||||
/**
|
||||
* Loads journey items with their linked Document in a single JOIN FETCH query,
|
||||
* eliminating the N+1 SELECT that would occur when accessing item.getDocument()
|
||||
* lazily for each item. Items without a document (note-only) are included via
|
||||
* LEFT JOIN. Ordered by position ASC.
|
||||
*/
|
||||
@Query("SELECT ji FROM JourneyItem ji LEFT JOIN FETCH ji.document WHERE ji.geschichte.id = :geschichteId ORDER BY ji.position ASC")
|
||||
List<JourneyItem> findByGeschichteIdWithDocument(@Param("geschichteId") UUID geschichteId);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
package org.raddatz.familienarchiv.geschichte.journeyitem;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.raddatz.familienarchiv.audit.AuditKind;
|
||||
import org.raddatz.familienarchiv.audit.AuditService;
|
||||
import org.raddatz.familienarchiv.document.DatePrecision;
|
||||
import org.raddatz.familienarchiv.document.Document;
|
||||
import org.raddatz.familienarchiv.document.DocumentService;
|
||||
import org.raddatz.familienarchiv.exception.DomainException;
|
||||
import org.raddatz.familienarchiv.exception.ErrorCode;
|
||||
import org.raddatz.familienarchiv.geschichte.Geschichte;
|
||||
import org.raddatz.familienarchiv.geschichte.GeschichteQueryService;
|
||||
import org.raddatz.familienarchiv.geschichte.GeschichteType;
|
||||
import org.raddatz.familienarchiv.geschichte.PersonNameFormatter;
|
||||
import org.raddatz.familienarchiv.person.Person;
|
||||
import org.raddatz.familienarchiv.user.AppUser;
|
||||
import org.raddatz.familienarchiv.user.UserService;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.*;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class JourneyItemService {
|
||||
|
||||
static final int MAX_ITEMS = 100;
|
||||
static final int POSITION_STEP = 10;
|
||||
static final int MAX_NOTE_LENGTH = 5000;
|
||||
|
||||
private final JourneyItemRepository journeyItemRepository;
|
||||
private final GeschichteQueryService geschichteQueryService;
|
||||
private final DocumentService documentService;
|
||||
private final AuditService auditService;
|
||||
private final UserService userService;
|
||||
|
||||
@Transactional
|
||||
public JourneyItemView append(UUID geschichteId, JourneyItemCreateDTO dto) {
|
||||
Geschichte g = geschichteQueryService.findById(geschichteId)
|
||||
.orElseThrow(() -> DomainException.notFound(ErrorCode.GESCHICHTE_NOT_FOUND,
|
||||
"Journey not found: " + geschichteId));
|
||||
|
||||
if (g.getType() != GeschichteType.JOURNEY) {
|
||||
throw DomainException.conflict(ErrorCode.GESCHICHTE_TYPE_MISMATCH,
|
||||
"Journey items can only be added to a JOURNEY-type Geschichte");
|
||||
}
|
||||
|
||||
long count = journeyItemRepository.countByGeschichteId(geschichteId);
|
||||
if (count >= MAX_ITEMS) {
|
||||
throw DomainException.conflict(ErrorCode.JOURNEY_AT_CAPACITY,
|
||||
"Journey has reached the maximum of 100 items");
|
||||
}
|
||||
|
||||
String note = normalizeNote(dto.getNote());
|
||||
|
||||
if (dto.getDocumentId() == null && note == null) {
|
||||
throw DomainException.badRequest(ErrorCode.VALIDATION_ERROR,
|
||||
"At least one of documentId or note must be provided");
|
||||
}
|
||||
|
||||
if (note != null && note.length() > MAX_NOTE_LENGTH) {
|
||||
throw DomainException.badRequest(ErrorCode.VALIDATION_ERROR,
|
||||
"Note exceeds maximum length of " + MAX_NOTE_LENGTH + " characters");
|
||||
}
|
||||
|
||||
Document doc = null;
|
||||
if (dto.getDocumentId() != null) {
|
||||
doc = documentService.findSummaryByIdInternal(dto.getDocumentId());
|
||||
}
|
||||
|
||||
int nextPosition = journeyItemRepository.findMaxPositionByGeschichteId(geschichteId)
|
||||
.map(max -> max + POSITION_STEP)
|
||||
.orElse(POSITION_STEP);
|
||||
|
||||
JourneyItem item = JourneyItem.builder()
|
||||
.geschichte(g)
|
||||
.position(nextPosition)
|
||||
.document(doc)
|
||||
.note(note)
|
||||
.build();
|
||||
JourneyItem saved = journeyItemRepository.save(item);
|
||||
|
||||
UUID actorId = currentUser().getId();
|
||||
auditService.logAfterCommit(AuditKind.JOURNEY_ITEM_ADDED, actorId, null,
|
||||
Map.of("geschichteId", geschichteId, "itemId", saved.getId()));
|
||||
|
||||
return toView(saved);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public JourneyItemView updateNote(UUID geschichteId, UUID itemId, JourneyItemUpdateDTO dto) {
|
||||
JourneyItem item = journeyItemRepository.findByIdAndGeschichteId(itemId, geschichteId)
|
||||
.orElseThrow(() -> DomainException.notFound(ErrorCode.JOURNEY_ITEM_NOT_FOUND,
|
||||
"Journey item not found: " + itemId));
|
||||
|
||||
// null = field absent from JSON → no-op
|
||||
Optional<String> noteField = dto.getNote();
|
||||
if (noteField == null) {
|
||||
return toView(item);
|
||||
}
|
||||
|
||||
String note = normalizeNote(noteField.orElse(null));
|
||||
|
||||
if (note != null && note.length() > MAX_NOTE_LENGTH) {
|
||||
throw DomainException.badRequest(ErrorCode.VALIDATION_ERROR,
|
||||
"Note exceeds maximum length of " + MAX_NOTE_LENGTH + " characters");
|
||||
}
|
||||
|
||||
if (note == null && item.getDocumentId() == null) {
|
||||
throw DomainException.badRequest(ErrorCode.VALIDATION_ERROR,
|
||||
"Cannot clear note on an item that has no linked document");
|
||||
}
|
||||
|
||||
item.setNote(note);
|
||||
JourneyItem saved = journeyItemRepository.save(item);
|
||||
|
||||
UUID actorId = currentUser().getId();
|
||||
auditService.logAfterCommit(AuditKind.JOURNEY_ITEM_NOTE_UPDATED, actorId, null,
|
||||
Map.of("geschichteId", geschichteId, "itemId", itemId));
|
||||
|
||||
return toView(saved);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void delete(UUID geschichteId, UUID itemId) {
|
||||
JourneyItem item = journeyItemRepository.findByIdAndGeschichteId(itemId, geschichteId)
|
||||
.orElseThrow(() -> DomainException.notFound(ErrorCode.JOURNEY_ITEM_NOT_FOUND,
|
||||
"Journey item not found: " + itemId));
|
||||
|
||||
journeyItemRepository.delete(item);
|
||||
|
||||
UUID actorId = currentUser().getId();
|
||||
auditService.logAfterCommit(AuditKind.JOURNEY_ITEM_REMOVED, actorId, null,
|
||||
Map.of("geschichteId", geschichteId, "itemId", itemId));
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public List<JourneyItemView> reorder(UUID geschichteId, JourneyReorderDTO dto) {
|
||||
if (!geschichteQueryService.existsById(geschichteId)) {
|
||||
throw DomainException.notFound(ErrorCode.GESCHICHTE_NOT_FOUND,
|
||||
"Journey not found: " + geschichteId);
|
||||
}
|
||||
Set<UUID> existingIds = journeyItemRepository.findIdsByGeschichteId(geschichteId);
|
||||
List<UUID> requestedIds = dto.getItemIds() != null ? dto.getItemIds() : List.of();
|
||||
|
||||
if (requestedIds.size() != new HashSet<>(requestedIds).size()) {
|
||||
throw DomainException.badRequest(ErrorCode.VALIDATION_ERROR,
|
||||
"Duplicate item IDs in reorder request");
|
||||
}
|
||||
|
||||
if (!existingIds.equals(new HashSet<>(requestedIds))) {
|
||||
throw DomainException.badRequest(ErrorCode.VALIDATION_ERROR,
|
||||
"Requested item IDs do not match the journey's existing items");
|
||||
}
|
||||
|
||||
if (requestedIds.isEmpty()) {
|
||||
return List.of();
|
||||
}
|
||||
|
||||
List<JourneyItem> items = journeyItemRepository.findByGeschichteIdOrderByPosition(geschichteId);
|
||||
Map<UUID, JourneyItem> itemMap = new HashMap<>();
|
||||
for (JourneyItem item : items) {
|
||||
itemMap.put(item.getId(), item);
|
||||
}
|
||||
|
||||
List<JourneyItem> toSave = new ArrayList<>(requestedIds.size());
|
||||
for (int i = 0; i < requestedIds.size(); i++) {
|
||||
JourneyItem item = itemMap.get(requestedIds.get(i));
|
||||
item.setPosition((i + 1) * POSITION_STEP);
|
||||
toSave.add(item);
|
||||
}
|
||||
List<JourneyItem> reordered = journeyItemRepository.saveAll(toSave);
|
||||
|
||||
UUID actorId = currentUser().getId();
|
||||
auditService.logAfterCommit(AuditKind.JOURNEY_ITEMS_REORDERED, actorId, null,
|
||||
Map.of("geschichteId", geschichteId, "itemCount", reordered.size()));
|
||||
|
||||
return reordered.stream().map(this::toView).toList();
|
||||
}
|
||||
|
||||
public List<JourneyItemView> getItems(UUID geschichteId) {
|
||||
return journeyItemRepository.findByGeschichteIdWithDocument(geschichteId)
|
||||
.stream().map(this::toView).toList();
|
||||
}
|
||||
|
||||
DocumentSummary toSummary(Document doc) {
|
||||
String senderName = buildSenderName(doc);
|
||||
Set<Person> receivers = doc.getReceivers();
|
||||
String receiverName = buildCanonicalReceiverName(receivers);
|
||||
|
||||
return new DocumentSummary(
|
||||
doc.getId(),
|
||||
doc.getTitle(),
|
||||
doc.getDocumentDate(),
|
||||
doc.getMetaDateEnd(),
|
||||
doc.getMetaDatePrecision() != null ? doc.getMetaDatePrecision() : DatePrecision.UNKNOWN,
|
||||
senderName,
|
||||
receiverName,
|
||||
receivers != null ? receivers.size() : 0
|
||||
);
|
||||
}
|
||||
|
||||
JourneyItemView toView(JourneyItem item) {
|
||||
DocumentSummary docSummary = null;
|
||||
Document doc = item.getDocument();
|
||||
if (doc != null) {
|
||||
docSummary = toSummary(doc);
|
||||
}
|
||||
return new JourneyItemView(item.getId(), item.getPosition(), docSummary, item.getNote());
|
||||
}
|
||||
|
||||
private static String buildSenderName(Document doc) {
|
||||
Person sender = doc.getSender();
|
||||
if (sender != null) {
|
||||
String name = PersonNameFormatter.join(sender.getFirstName(), sender.getLastName());
|
||||
if (!name.isBlank()) return name;
|
||||
}
|
||||
String senderText = doc.getSenderText();
|
||||
return (senderText != null && !senderText.isBlank()) ? senderText : null;
|
||||
}
|
||||
|
||||
private static String buildCanonicalReceiverName(Set<Person> receivers) {
|
||||
if (receivers == null || receivers.isEmpty()) return null;
|
||||
return receivers.stream()
|
||||
.min(Comparator.comparing(p -> sortKey(p.getLastName()) + " " + sortKey(p.getFirstName())))
|
||||
.map(p -> {
|
||||
String name = PersonNameFormatter.join(p.getFirstName(), p.getLastName());
|
||||
return name.isBlank() ? null : name;
|
||||
})
|
||||
.orElse(null);
|
||||
}
|
||||
|
||||
private static String normalizeNote(String raw) {
|
||||
if (raw == null || raw.isBlank()) return null;
|
||||
return raw.trim();
|
||||
}
|
||||
|
||||
private static String sortKey(String s) {
|
||||
return s != null ? s : "";
|
||||
}
|
||||
|
||||
private AppUser currentUser() {
|
||||
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
|
||||
if (auth == null || !auth.isAuthenticated()) {
|
||||
throw DomainException.unauthorized("Authentication required");
|
||||
}
|
||||
return userService.findByEmail(auth.getName());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package org.raddatz.familienarchiv.geschichte.journeyitem;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* Input for PATCH /api/geschichten/{id}/items/{itemId}.
|
||||
* Three-way semantics via Optional<String>:
|
||||
* null → field absent from JSON → leave note unchanged
|
||||
* Optional.empty() → {"note": null} → clear the note
|
||||
* Optional.of("x") → {"note": "x"} → set the note
|
||||
*
|
||||
* Jackson 3.x maps JSON null to Optional.empty(); absent fields keep the Java default (null).
|
||||
*/
|
||||
@Data
|
||||
public class JourneyItemUpdateDTO {
|
||||
private Optional<String> note = null;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package org.raddatz.familienarchiv.geschichte.journeyitem;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Read-model response for a JourneyItem. Never the JPA entity (which has a
|
||||
* Geschichte back-reference that would leak / hit LazyInitializationException).
|
||||
*/
|
||||
public record JourneyItemView(
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) UUID id,
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) int position,
|
||||
DocumentSummary document,
|
||||
String note
|
||||
) {}
|
||||
@@ -0,0 +1,12 @@
|
||||
package org.raddatz.familienarchiv.geschichte.journeyitem;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
/** Input for PUT /api/geschichten/{id}/items/reorder. */
|
||||
@Data
|
||||
public class JourneyReorderDTO {
|
||||
private List<UUID> itemIds;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
-- Adds the two constraints that V72 deferred:
|
||||
-- 1. UNIQUE(geschichte_id, position) DEFERRABLE INITIALLY DEFERRED
|
||||
-- Allows mid-transaction position swaps during reorder (checked at COMMIT, not per-row).
|
||||
-- Requires transaction-level or session-level connection pooling (prod uses PgBouncer
|
||||
-- in transaction mode — correct today; a future switch to statement-level would silently
|
||||
-- break deferred checking at COMMIT).
|
||||
-- 2. CHECK (position > 0) — defense against off-by-one in the append path.
|
||||
--
|
||||
-- MUST run in a single transaction; Flyway's default per-migration transaction satisfies this.
|
||||
-- Do NOT add executeInTransaction=false or any callback that splits this migration.
|
||||
|
||||
ALTER TABLE journey_items
|
||||
ADD CONSTRAINT uq_journey_items_geschichte_position
|
||||
UNIQUE (geschichte_id, position)
|
||||
DEFERRABLE INITIALLY DEFERRED;
|
||||
|
||||
ALTER TABLE journey_items
|
||||
ADD CONSTRAINT chk_journey_item_position
|
||||
CHECK (position > 0);
|
||||
@@ -2,9 +2,11 @@ package org.raddatz.familienarchiv.geschichte;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.raddatz.familienarchiv.security.SecurityConfig;
|
||||
import org.raddatz.familienarchiv.exception.DomainException;
|
||||
import org.raddatz.familienarchiv.exception.ErrorCode;
|
||||
import org.raddatz.familienarchiv.geschichte.journeyitem.JourneyItemService;
|
||||
import org.raddatz.familienarchiv.geschichte.journeyitem.JourneyItemView;
|
||||
import org.raddatz.familienarchiv.security.SecurityConfig;
|
||||
import org.raddatz.familienarchiv.security.PermissionAspect;
|
||||
import org.raddatz.familienarchiv.user.CustomUserDetailsService;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
@@ -22,6 +24,7 @@ import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.hamcrest.CoreMatchers.nullValue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyInt;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
@@ -32,6 +35,7 @@ import static org.springframework.test.web.servlet.request.MockMvcRequestBuilder
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
@@ -44,11 +48,9 @@ class GeschichteControllerTest {
|
||||
|
||||
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||
|
||||
@MockitoBean
|
||||
GeschichteService geschichteService;
|
||||
|
||||
@MockitoBean
|
||||
CustomUserDetailsService customUserDetailsService;
|
||||
@MockitoBean GeschichteService geschichteService;
|
||||
@MockitoBean JourneyItemService journeyItemService;
|
||||
@MockitoBean CustomUserDetailsService customUserDetailsService;
|
||||
|
||||
// ─── GET /api/geschichten ────────────────────────────────────────────────
|
||||
|
||||
@@ -104,7 +106,7 @@ class GeschichteControllerTest {
|
||||
@WithMockUser(authorities = "READ_ALL")
|
||||
void getById_returns200_whenFound() throws Exception {
|
||||
UUID id = UUID.randomUUID();
|
||||
when(geschichteService.getById(id)).thenReturn(published(id, "Hello"));
|
||||
when(geschichteService.getView(id)).thenReturn(viewStub(id, "Hello"));
|
||||
|
||||
mockMvc.perform(get("/api/geschichten/{id}", id))
|
||||
.andExpect(status().isOk())
|
||||
@@ -116,7 +118,7 @@ class GeschichteControllerTest {
|
||||
@WithMockUser(authorities = "READ_ALL")
|
||||
void getById_returns404_whenServiceThrowsNotFound() throws Exception {
|
||||
UUID id = UUID.randomUUID();
|
||||
when(geschichteService.getById(id))
|
||||
when(geschichteService.getView(id))
|
||||
.thenThrow(DomainException.notFound(ErrorCode.GESCHICHTE_NOT_FOUND, "x"));
|
||||
|
||||
mockMvc.perform(get("/api/geschichten/{id}", id))
|
||||
@@ -205,8 +207,180 @@ class GeschichteControllerTest {
|
||||
verify(geschichteService).delete(id);
|
||||
}
|
||||
|
||||
// ─── POST /api/geschichten/{id}/items ────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void appendItem_returns401_whenUnauthenticated() throws Exception {
|
||||
mockMvc.perform(post("/api/geschichten/{id}/items", UUID.randomUUID()).with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"note\":\"x\"}"))
|
||||
.andExpect(status().isUnauthorized());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "READ_ALL")
|
||||
void appendItem_returns403_whenLackingBlogWrite() throws Exception {
|
||||
mockMvc.perform(post("/api/geschichten/{id}/items", UUID.randomUUID()).with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"note\":\"x\"}"))
|
||||
.andExpect(status().isForbidden());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "BLOG_WRITE")
|
||||
void appendItem_returns201_withBlogWrite() throws Exception {
|
||||
UUID id = UUID.randomUUID();
|
||||
UUID itemId = UUID.randomUUID();
|
||||
when(journeyItemService.append(eq(id), any())).thenReturn(itemViewStub(itemId, 10, "Note"));
|
||||
|
||||
mockMvc.perform(post("/api/geschichten/{id}/items", id).with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"note\":\"Note\"}"))
|
||||
.andExpect(status().isCreated())
|
||||
.andExpect(jsonPath("$.id").value(itemId.toString()))
|
||||
.andExpect(jsonPath("$.position").value(10));
|
||||
}
|
||||
|
||||
// ─── PATCH /api/geschichten/{id}/items/{itemId} ──────────────────────────
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "READ_ALL")
|
||||
void updateItemNote_returns403_whenLackingBlogWrite() throws Exception {
|
||||
mockMvc.perform(patch("/api/geschichten/{id}/items/{itemId}",
|
||||
UUID.randomUUID(), UUID.randomUUID()).with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"note\":\"x\"}"))
|
||||
.andExpect(status().isForbidden());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "BLOG_WRITE")
|
||||
void updateItemNote_returns200_withBlogWrite() throws Exception {
|
||||
UUID id = UUID.randomUUID();
|
||||
UUID itemId = UUID.randomUUID();
|
||||
when(journeyItemService.updateNote(eq(id), eq(itemId), any()))
|
||||
.thenReturn(itemViewStub(itemId, 10, "Updated"));
|
||||
|
||||
mockMvc.perform(patch("/api/geschichten/{id}/items/{itemId}", id, itemId).with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"note\":\"Updated\"}"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.note").value("Updated"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "BLOG_WRITE")
|
||||
void updateItemNote_json_null_note_is_deserialized_as_empty_Optional() throws Exception {
|
||||
UUID id = UUID.randomUUID();
|
||||
UUID itemId = UUID.randomUUID();
|
||||
when(journeyItemService.updateNote(eq(id), eq(itemId), any()))
|
||||
.thenReturn(itemViewStub(itemId, 10, null));
|
||||
|
||||
// Raw JSON — local objectMapper lacks JsonNullableModule
|
||||
mockMvc.perform(patch("/api/geschichten/{id}/items/{itemId}", id, itemId).with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"note\": null}"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.note").value(nullValue()));
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "BLOG_WRITE")
|
||||
void updateItemNote_returns404_whenItemNotFound() throws Exception {
|
||||
UUID id = UUID.randomUUID();
|
||||
UUID itemId = UUID.randomUUID();
|
||||
when(journeyItemService.updateNote(eq(id), eq(itemId), any()))
|
||||
.thenThrow(DomainException.notFound(ErrorCode.JOURNEY_ITEM_NOT_FOUND, "not found"));
|
||||
|
||||
mockMvc.perform(patch("/api/geschichten/{id}/items/{itemId}", id, itemId).with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"note\":\"x\"}"))
|
||||
.andExpect(status().isNotFound())
|
||||
.andExpect(jsonPath("$.code").value("JOURNEY_ITEM_NOT_FOUND"));
|
||||
}
|
||||
|
||||
// ─── DELETE /api/geschichten/{id}/items/{itemId} ─────────────────────────
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "READ_ALL")
|
||||
void deleteItem_returns403_whenLackingBlogWrite() throws Exception {
|
||||
mockMvc.perform(delete("/api/geschichten/{id}/items/{itemId}",
|
||||
UUID.randomUUID(), UUID.randomUUID()).with(csrf()))
|
||||
.andExpect(status().isForbidden());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "BLOG_WRITE")
|
||||
void deleteItem_returns204_withBlogWrite() throws Exception {
|
||||
UUID id = UUID.randomUUID();
|
||||
UUID itemId = UUID.randomUUID();
|
||||
|
||||
mockMvc.perform(delete("/api/geschichten/{id}/items/{itemId}", id, itemId).with(csrf()))
|
||||
.andExpect(status().isNoContent());
|
||||
|
||||
verify(journeyItemService).delete(id, itemId);
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "BLOG_WRITE")
|
||||
void deleteItem_returns404_whenItemNotFound() throws Exception {
|
||||
UUID id = UUID.randomUUID();
|
||||
UUID itemId = UUID.randomUUID();
|
||||
org.mockito.Mockito.doThrow(DomainException.notFound(ErrorCode.JOURNEY_ITEM_NOT_FOUND, "not found"))
|
||||
.when(journeyItemService).delete(id, itemId);
|
||||
|
||||
mockMvc.perform(delete("/api/geschichten/{id}/items/{itemId}", id, itemId).with(csrf()))
|
||||
.andExpect(status().isNotFound())
|
||||
.andExpect(jsonPath("$.code").value("JOURNEY_ITEM_NOT_FOUND"));
|
||||
}
|
||||
|
||||
// ─── PUT /api/geschichten/{id}/items/reorder ─────────────────────────────
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "READ_ALL")
|
||||
void reorderItems_returns403_whenLackingBlogWrite() throws Exception {
|
||||
mockMvc.perform(put("/api/geschichten/{id}/items/reorder", UUID.randomUUID()).with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"itemIds\":[]}"))
|
||||
.andExpect(status().isForbidden());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "BLOG_WRITE")
|
||||
void reorderItems_returns200_withBlogWrite() throws Exception {
|
||||
UUID id = UUID.randomUUID();
|
||||
UUID itemId = UUID.randomUUID();
|
||||
when(journeyItemService.reorder(eq(id), any())).thenReturn(List.of(itemViewStub(itemId, 10, null)));
|
||||
|
||||
mockMvc.perform(put("/api/geschichten/{id}/items/reorder", id).with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"itemIds\":[\"" + itemId + "\"]}"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$[0].id").value(itemId.toString()));
|
||||
}
|
||||
|
||||
// ─── error mapping ───────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "BLOG_WRITE")
|
||||
void appendItem_returns409_on_position_conflict() throws Exception {
|
||||
UUID id = UUID.randomUUID();
|
||||
when(journeyItemService.append(eq(id), any()))
|
||||
.thenThrow(DomainException.conflict(ErrorCode.JOURNEY_ITEM_POSITION_CONFLICT, "conflict"));
|
||||
|
||||
mockMvc.perform(post("/api/geschichten/{id}/items", id).with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"note\":\"x\"}"))
|
||||
.andExpect(status().isConflict())
|
||||
.andExpect(jsonPath("$.code").value("JOURNEY_ITEM_POSITION_CONFLICT"));
|
||||
}
|
||||
|
||||
// ─── helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
private JourneyItemView itemViewStub(UUID id, int position, String note) {
|
||||
return new JourneyItemView(id, position, null, note);
|
||||
}
|
||||
|
||||
private Geschichte published(UUID id, String title) {
|
||||
return Geschichte.builder()
|
||||
.id(id)
|
||||
@@ -233,6 +407,13 @@ class GeschichteControllerTest {
|
||||
.build();
|
||||
}
|
||||
|
||||
private GeschichteView viewStub(UUID id, String title) {
|
||||
return new GeschichteView(id, title, "<p>x</p>",
|
||||
GeschichteStatus.PUBLISHED, GeschichteType.STORY,
|
||||
null, new HashSet<>(), List.of(),
|
||||
LocalDateTime.now(), LocalDateTime.now(), LocalDateTime.now());
|
||||
}
|
||||
|
||||
/** Concrete implementation — Mockito interface mocks are not serialized reliably by Jackson. */
|
||||
private GeschichteSummary summaryStub(String title) {
|
||||
return new GeschichteSummary() {
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
package org.raddatz.familienarchiv.geschichte;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class GeschichteQueryServiceTest {
|
||||
|
||||
@Mock
|
||||
GeschichteRepository geschichteRepository;
|
||||
|
||||
@InjectMocks
|
||||
GeschichteQueryService geschichteQueryService;
|
||||
|
||||
@Test
|
||||
void existsById_returns_true_when_geschichte_exists() {
|
||||
UUID id = UUID.randomUUID();
|
||||
when(geschichteRepository.existsById(id)).thenReturn(true);
|
||||
|
||||
assertThat(geschichteQueryService.existsById(id)).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void existsById_returns_false_when_geschichte_does_not_exist() {
|
||||
UUID id = UUID.randomUUID();
|
||||
when(geschichteRepository.existsById(id)).thenReturn(false);
|
||||
|
||||
assertThat(geschichteQueryService.existsById(id)).isFalse();
|
||||
}
|
||||
}
|
||||
@@ -8,9 +8,12 @@ import org.raddatz.familienarchiv.geschichte.GeschichteUpdateDTO;
|
||||
import org.raddatz.familienarchiv.user.AppUser;
|
||||
import org.raddatz.familienarchiv.geschichte.Geschichte;
|
||||
import org.raddatz.familienarchiv.geschichte.GeschichteStatus;
|
||||
import org.raddatz.familienarchiv.geschichte.GeschichteType;
|
||||
import org.raddatz.familienarchiv.geschichte.GeschichteView;
|
||||
import org.raddatz.familienarchiv.person.Person;
|
||||
import org.raddatz.familienarchiv.user.AppUserRepository;
|
||||
import org.raddatz.familienarchiv.geschichte.GeschichteRepository;
|
||||
import org.raddatz.familienarchiv.geschichte.journeyitem.JourneyItemService;
|
||||
import org.raddatz.familienarchiv.person.PersonRepository;
|
||||
import org.raddatz.familienarchiv.security.Permission;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
@@ -39,6 +42,7 @@ class GeschichteServiceIntegrationTest {
|
||||
S3Client s3Client;
|
||||
|
||||
@Autowired GeschichteService geschichteService;
|
||||
@Autowired JourneyItemService journeyItemService;
|
||||
@Autowired GeschichteRepository geschichteRepository;
|
||||
@Autowired PersonRepository personRepository;
|
||||
@Autowired AppUserRepository appUserRepository;
|
||||
@@ -105,8 +109,9 @@ class GeschichteServiceIntegrationTest {
|
||||
assertThat(geschichteService.list(null, List.of(), 50)).hasSize(1);
|
||||
assertThat(geschichteService.list(null, List.of(franz.getId()), 50)).hasSize(1);
|
||||
Geschichte fetched = geschichteService.getById(draftId);
|
||||
assertThat(fetched.getTitle()).isEqualTo("Erinnerung an Opa Franz");
|
||||
assertThat(fetched.getPersons()).extracting(Person::getId).containsExactly(franz.getId());
|
||||
GeschichteView fetchedView = geschichteService.toView(fetched, journeyItemService.getItems(draftId));
|
||||
assertThat(fetchedView.title()).isEqualTo("Erinnerung an Opa Franz");
|
||||
assertThat(fetchedView.persons()).extracting(GeschichteView.PersonView::id).containsExactly(franz.getId());
|
||||
|
||||
// Delete as writer; join rows go with it
|
||||
authenticateAs(writer, Permission.BLOG_WRITE);
|
||||
|
||||
@@ -9,6 +9,8 @@ import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.raddatz.familienarchiv.exception.DomainException;
|
||||
import org.raddatz.familienarchiv.exception.ErrorCode;
|
||||
import org.raddatz.familienarchiv.geschichte.journeyitem.JourneyItemService;
|
||||
import org.raddatz.familienarchiv.geschichte.journeyitem.JourneyItemView;
|
||||
import org.raddatz.familienarchiv.user.AppUser;
|
||||
import org.raddatz.familienarchiv.person.Person;
|
||||
import org.raddatz.familienarchiv.security.Permission;
|
||||
@@ -32,6 +34,7 @@ import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyLong;
|
||||
import static org.mockito.Mockito.lenient;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
@@ -40,17 +43,13 @@ import static org.mockito.Mockito.when;
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class GeschichteServiceTest {
|
||||
|
||||
@Mock
|
||||
GeschichteRepository geschichteRepository;
|
||||
@Mock
|
||||
PersonService personService;
|
||||
@Mock
|
||||
DocumentService documentService;
|
||||
@Mock
|
||||
UserService userService;
|
||||
@Mock GeschichteRepository geschichteRepository;
|
||||
@Mock PersonService personService;
|
||||
@Mock DocumentService documentService;
|
||||
@Mock UserService userService;
|
||||
@Mock JourneyItemService journeyItemService;
|
||||
|
||||
@InjectMocks
|
||||
GeschichteService geschichteService;
|
||||
@InjectMocks GeschichteService geschichteService;
|
||||
|
||||
AppUser writer;
|
||||
AppUser reader;
|
||||
@@ -91,7 +90,8 @@ class GeschichteServiceTest {
|
||||
|
||||
Geschichte result = geschichteService.getById(id);
|
||||
|
||||
assertThat(result).isSameAs(draft);
|
||||
assertThat(result.getId()).isEqualTo(id);
|
||||
assertThat(result.getStatus()).isEqualTo(GeschichteStatus.DRAFT);
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -103,7 +103,8 @@ class GeschichteServiceTest {
|
||||
|
||||
Geschichte result = geschichteService.getById(id);
|
||||
|
||||
assertThat(result).isSameAs(published);
|
||||
assertThat(result.getId()).isEqualTo(id);
|
||||
assertThat(result.getStatus()).isEqualTo(GeschichteStatus.PUBLISHED);
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -118,6 +119,104 @@ class GeschichteServiceTest {
|
||||
.isEqualTo(ErrorCode.GESCHICHTE_NOT_FOUND);
|
||||
}
|
||||
|
||||
// ─── getView ──────────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void getView_returns_assembled_view_and_delegates_to_journeyItemService() {
|
||||
authenticateAs(reader, Permission.READ_ALL);
|
||||
UUID id = UUID.randomUUID();
|
||||
Geschichte published = published(id);
|
||||
JourneyItemView item = new JourneyItemView(UUID.randomUUID(), 10, null, "Note");
|
||||
when(geschichteRepository.findById(id)).thenReturn(Optional.of(published));
|
||||
when(journeyItemService.getItems(id)).thenReturn(List.of(item));
|
||||
|
||||
GeschichteView view = geschichteService.getView(id);
|
||||
|
||||
assertThat(view.id()).isEqualTo(id);
|
||||
assertThat(view.items()).containsExactly(item);
|
||||
verify(journeyItemService).getItems(id);
|
||||
}
|
||||
|
||||
@Test
|
||||
void getView_throws_NOT_FOUND_when_id_unknown() {
|
||||
authenticateAs(reader, Permission.READ_ALL);
|
||||
UUID id = UUID.randomUUID();
|
||||
when(geschichteRepository.findById(id)).thenReturn(Optional.empty());
|
||||
|
||||
assertThatThrownBy(() -> geschichteService.getView(id))
|
||||
.isInstanceOf(DomainException.class)
|
||||
.extracting("code")
|
||||
.isEqualTo(ErrorCode.GESCHICHTE_NOT_FOUND);
|
||||
}
|
||||
|
||||
@Test
|
||||
void toView_author_displayName_uses_firstName_lastName() {
|
||||
UUID id = UUID.randomUUID();
|
||||
Geschichte published = published(id);
|
||||
published.setAuthor(AppUser.builder()
|
||||
.id(UUID.randomUUID()).email("author@test")
|
||||
.firstName("Hans").lastName("Raddatz").build());
|
||||
|
||||
GeschichteView result = geschichteService.toView(published, List.of());
|
||||
|
||||
assertThat(result.author().displayName()).isEqualTo("Hans Raddatz");
|
||||
}
|
||||
|
||||
@Test
|
||||
void toView_author_displayName_falls_back_to_Unbekannt_when_names_blank() {
|
||||
UUID id = UUID.randomUUID();
|
||||
Geschichte published = published(id);
|
||||
published.setAuthor(AppUser.builder()
|
||||
.id(UUID.randomUUID()).email("anon@test").build());
|
||||
|
||||
GeschichteView result = geschichteService.toView(published, List.of());
|
||||
|
||||
assertThat(result.author().displayName()).isEqualTo("[Unbekannt]");
|
||||
}
|
||||
|
||||
@Test
|
||||
void toView_author_email_is_not_in_author_view() {
|
||||
UUID id = UUID.randomUUID();
|
||||
Geschichte published = published(id);
|
||||
published.setAuthor(AppUser.builder()
|
||||
.id(UUID.randomUUID()).email("secret@test")
|
||||
.firstName("Max").lastName("M").build());
|
||||
|
||||
GeschichteView result = geschichteService.toView(published, List.of());
|
||||
|
||||
// AuthorView exposes only id + displayName — no email field at all
|
||||
assertThat(result.author()).isInstanceOf(GeschichteView.AuthorView.class);
|
||||
assertThat(result.author().displayName()).doesNotContain("secret@test");
|
||||
}
|
||||
|
||||
@Test
|
||||
void toView_persons_are_mapped_to_PersonView() {
|
||||
UUID id = UUID.randomUUID();
|
||||
UUID personId = UUID.randomUUID();
|
||||
Geschichte published = published(id);
|
||||
published.setPersons(new HashSet<>(List.of(
|
||||
Person.builder().id(personId).firstName("Franz").lastName("Raddatz").build()
|
||||
)));
|
||||
|
||||
GeschichteView result = geschichteService.toView(published, List.of());
|
||||
|
||||
assertThat(result.persons()).hasSize(1);
|
||||
GeschichteView.PersonView pv = result.persons().iterator().next();
|
||||
assertThat(pv.id()).isEqualTo(personId);
|
||||
assertThat(pv.firstName()).isEqualTo("Franz");
|
||||
assertThat(pv.lastName()).isEqualTo("Raddatz");
|
||||
}
|
||||
|
||||
@Test
|
||||
void toView_items_are_passed_through() {
|
||||
UUID id = UUID.randomUUID();
|
||||
Geschichte published = published(id);
|
||||
|
||||
GeschichteView result = geschichteService.toView(published, List.of());
|
||||
|
||||
assertThat(result.items()).isEmpty();
|
||||
}
|
||||
|
||||
// ─── list ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
package org.raddatz.familienarchiv.geschichte.journeyitem;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.raddatz.familienarchiv.PostgresContainerConfig;
|
||||
import org.raddatz.familienarchiv.document.Document;
|
||||
import org.raddatz.familienarchiv.document.DocumentRepository;
|
||||
import org.raddatz.familienarchiv.document.DocumentStatus;
|
||||
import org.raddatz.familienarchiv.geschichte.Geschichte;
|
||||
import org.raddatz.familienarchiv.geschichte.GeschichteRepository;
|
||||
import org.raddatz.familienarchiv.geschichte.GeschichteStatus;
|
||||
import org.raddatz.familienarchiv.geschichte.GeschichteType;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.context.annotation.Import;
|
||||
import org.springframework.dao.DataIntegrityViolationException;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.test.context.ActiveProfiles;
|
||||
import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
||||
import software.amazon.awssdk.services.s3.S3Client;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
/**
|
||||
* Raw-SQL constraint tests for journey_items — deliberately NOT @Transactional at class level.
|
||||
* A DataIntegrityViolationException inside a class-level @Transactional marks the tx
|
||||
* rollback-only and cascades into TransactionSystemException on teardown.
|
||||
* Each test inserts via jdbcTemplate and uses explicit SQL teardown.
|
||||
*/
|
||||
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE)
|
||||
@ActiveProfiles("test")
|
||||
@Import(PostgresContainerConfig.class)
|
||||
class JourneyItemConstraintsTest {
|
||||
|
||||
@MockitoBean
|
||||
S3Client s3Client;
|
||||
|
||||
@Autowired JdbcTemplate jdbcTemplate;
|
||||
@Autowired GeschichteRepository geschichteRepository;
|
||||
@Autowired DocumentRepository documentRepository;
|
||||
|
||||
private UUID geschichteId;
|
||||
private UUID documentId;
|
||||
|
||||
@BeforeEach
|
||||
void seed() {
|
||||
jdbcTemplate.execute("DELETE FROM journey_items");
|
||||
Document doc = documentRepository.save(Document.builder()
|
||||
.title("Constraints-Test-Doc")
|
||||
.originalFilename("ct.pdf")
|
||||
.status(DocumentStatus.UPLOADED)
|
||||
.build());
|
||||
documentId = doc.getId();
|
||||
Geschichte g = geschichteRepository.save(Geschichte.builder()
|
||||
.title("Constraints-Test-Journey")
|
||||
.status(GeschichteStatus.DRAFT)
|
||||
.type(GeschichteType.JOURNEY)
|
||||
.build());
|
||||
geschichteId = g.getId();
|
||||
}
|
||||
|
||||
@Test
|
||||
void unique_constraint_is_deferrable_initially_deferred() {
|
||||
Boolean condeferrable = jdbcTemplate.queryForObject(
|
||||
"SELECT condeferrable FROM pg_constraint WHERE conname = 'uq_journey_items_geschichte_position'",
|
||||
Boolean.class);
|
||||
Boolean condeferred = jdbcTemplate.queryForObject(
|
||||
"SELECT condeferred FROM pg_constraint WHERE conname = 'uq_journey_items_geschichte_position'",
|
||||
Boolean.class);
|
||||
assertThat(condeferrable).as("constraint must be deferrable").isTrue();
|
||||
assertThat(condeferred).as("constraint must be initially deferred").isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void position_check_rejects_nonpositive() {
|
||||
UUID itemId = UUID.randomUUID();
|
||||
assertThatThrownBy(() ->
|
||||
jdbcTemplate.update(
|
||||
"INSERT INTO journey_items (id, geschichte_id, position, note) VALUES (?, ?, ?, ?)",
|
||||
itemId, geschichteId, 0, "test"))
|
||||
.isInstanceOf(DataIntegrityViolationException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void unique_constraint_rejects_duplicate_position_per_geschichte() {
|
||||
jdbcTemplate.update(
|
||||
"INSERT INTO journey_items (id, geschichte_id, position, document_id) VALUES (?, ?, ?, ?)",
|
||||
UUID.randomUUID(), geschichteId, 10, documentId);
|
||||
|
||||
assertThatThrownBy(() ->
|
||||
jdbcTemplate.update(
|
||||
"INSERT INTO journey_items (id, geschichte_id, position, document_id) VALUES (?, ?, ?, ?)",
|
||||
UUID.randomUUID(), geschichteId, 10, documentId))
|
||||
.isInstanceOf(DataIntegrityViolationException.class);
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package org.raddatz.familienarchiv.geschichte.journeyitem;
|
||||
|
||||
import jakarta.persistence.EntityManager;
|
||||
import jakarta.persistence.PersistenceContext;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.raddatz.familienarchiv.PostgresContainerConfig;
|
||||
@@ -12,9 +13,15 @@ import org.raddatz.familienarchiv.geschichte.Geschichte;
|
||||
import org.raddatz.familienarchiv.geschichte.GeschichteRepository;
|
||||
import org.raddatz.familienarchiv.geschichte.GeschichteStatus;
|
||||
import org.raddatz.familienarchiv.geschichte.GeschichteType;
|
||||
import org.raddatz.familienarchiv.security.Permission;
|
||||
import org.raddatz.familienarchiv.user.AppUser;
|
||||
import org.raddatz.familienarchiv.user.AppUserRepository;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.context.annotation.Import;
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.test.context.ActiveProfiles;
|
||||
import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
@@ -40,13 +47,20 @@ class JourneyItemIntegrationTest {
|
||||
|
||||
@Autowired GeschichteRepository geschichteRepository;
|
||||
@Autowired JourneyItemRepository journeyItemRepository;
|
||||
@Autowired JourneyItemService journeyItemService;
|
||||
@Autowired DocumentRepository documentRepository;
|
||||
@Autowired AppUserRepository appUserRepository;
|
||||
|
||||
Geschichte journey;
|
||||
Document doc;
|
||||
AppUser writer;
|
||||
|
||||
@BeforeEach
|
||||
void seed() {
|
||||
writer = appUserRepository.save(AppUser.builder()
|
||||
.email("journey-writer@test")
|
||||
.password("hash")
|
||||
.build());
|
||||
doc = documentRepository.save(Document.builder()
|
||||
.title("Testbrief")
|
||||
.originalFilename("testbrief.pdf")
|
||||
@@ -61,6 +75,19 @@ class JourneyItemIntegrationTest {
|
||||
em.clear();
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void clearSecurity() {
|
||||
SecurityContextHolder.clearContext();
|
||||
}
|
||||
|
||||
private void authenticateAs(AppUser user, Permission... permissions) {
|
||||
var authorities = java.util.Arrays.stream(permissions)
|
||||
.map(p -> new SimpleGrantedAuthority(p.name()))
|
||||
.toList();
|
||||
SecurityContextHolder.getContext().setAuthentication(
|
||||
new UsernamePasswordAuthenticationToken(user.getEmail(), null, authorities));
|
||||
}
|
||||
|
||||
// ─── @OrderBy ─────────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
@@ -96,7 +123,7 @@ class JourneyItemIntegrationTest {
|
||||
geschichteRepository.deleteById(geschichteId);
|
||||
em.flush();
|
||||
|
||||
assertThat(journeyItemRepository.findAllByGeschichteId(geschichteId)).isEmpty();
|
||||
assertThat(journeyItemRepository.findByGeschichteIdOrderByPosition(geschichteId)).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -206,4 +233,62 @@ class JourneyItemIntegrationTest {
|
||||
journeyItemRepository.flush();
|
||||
}).isInstanceOf(Exception.class);
|
||||
}
|
||||
|
||||
// ─── JourneyItemService.append — end-to-end persistence ──────────────────
|
||||
|
||||
@Test
|
||||
void append_persists_item_at_position_10() {
|
||||
// Arrange: authenticate as a user with BLOG_WRITE
|
||||
authenticateAs(writer, Permission.BLOG_WRITE);
|
||||
|
||||
JourneyItemCreateDTO dto = new JourneyItemCreateDTO();
|
||||
dto.setNote("First stop");
|
||||
|
||||
// Act
|
||||
JourneyItemView view = journeyItemService.append(journey.getId(), dto);
|
||||
em.flush();
|
||||
em.clear();
|
||||
|
||||
// Assert: item exists in DB at position 10
|
||||
assertThat(view.position()).isEqualTo(10);
|
||||
assertThat(view.note()).isEqualTo("First stop");
|
||||
List<JourneyItem> persisted = journeyItemRepository.findByGeschichteIdOrderByPosition(journey.getId());
|
||||
assertThat(persisted).hasSize(1);
|
||||
assertThat(persisted.get(0).getPosition()).isEqualTo(10);
|
||||
assertThat(persisted.get(0).getNote()).isEqualTo("First stop");
|
||||
}
|
||||
|
||||
// ─── JourneyItemService.reorder — atomicity check ────────────────────────
|
||||
|
||||
@Test
|
||||
void reorder_swaps_positions_atomically() {
|
||||
// Arrange: append two items (pos 10, pos 20)
|
||||
authenticateAs(writer, Permission.BLOG_WRITE);
|
||||
|
||||
JourneyItemCreateDTO dto1 = new JourneyItemCreateDTO();
|
||||
dto1.setNote("Item one");
|
||||
JourneyItemView item1View = journeyItemService.append(journey.getId(), dto1);
|
||||
|
||||
JourneyItemCreateDTO dto2 = new JourneyItemCreateDTO();
|
||||
dto2.setNote("Item two");
|
||||
JourneyItemView item2View = journeyItemService.append(journey.getId(), dto2);
|
||||
|
||||
assertThat(item1View.position()).isEqualTo(10);
|
||||
assertThat(item2View.position()).isEqualTo(20);
|
||||
|
||||
// Act: reorder with [item2, item1]
|
||||
JourneyReorderDTO reorderDto = new JourneyReorderDTO();
|
||||
reorderDto.setItemIds(List.of(item2View.id(), item1View.id()));
|
||||
List<JourneyItemView> reordered = journeyItemService.reorder(journey.getId(), reorderDto);
|
||||
em.flush();
|
||||
em.clear();
|
||||
|
||||
// Assert: item2 is now at position 10, item1 is at position 20
|
||||
List<JourneyItem> persisted = journeyItemRepository.findByGeschichteIdOrderByPosition(journey.getId());
|
||||
assertThat(persisted).hasSize(2);
|
||||
assertThat(persisted.get(0).getId()).isEqualTo(item2View.id());
|
||||
assertThat(persisted.get(0).getPosition()).isEqualTo(10);
|
||||
assertThat(persisted.get(1).getId()).isEqualTo(item1View.id());
|
||||
assertThat(persisted.get(1).getPosition()).isEqualTo(20);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,689 @@
|
||||
package org.raddatz.familienarchiv.geschichte.journeyitem;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.raddatz.familienarchiv.audit.AuditKind;
|
||||
import org.raddatz.familienarchiv.audit.AuditService;
|
||||
import org.raddatz.familienarchiv.document.DatePrecision;
|
||||
import org.raddatz.familienarchiv.document.Document;
|
||||
import org.raddatz.familienarchiv.document.DocumentService;
|
||||
import org.raddatz.familienarchiv.document.DocumentStatus;
|
||||
import org.raddatz.familienarchiv.exception.DomainException;
|
||||
import org.raddatz.familienarchiv.exception.ErrorCode;
|
||||
import org.raddatz.familienarchiv.geschichte.Geschichte;
|
||||
import org.raddatz.familienarchiv.geschichte.GeschichteQueryService;
|
||||
import org.raddatz.familienarchiv.geschichte.GeschichteStatus;
|
||||
import org.raddatz.familienarchiv.geschichte.GeschichteType;
|
||||
import org.raddatz.familienarchiv.person.Person;
|
||||
import org.raddatz.familienarchiv.user.AppUser;
|
||||
import org.raddatz.familienarchiv.user.UserService;
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.ArgumentMatchers.isNull;
|
||||
import static org.mockito.Mockito.lenient;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.verifyNoInteractions;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class JourneyItemServiceTest {
|
||||
|
||||
@Mock JourneyItemRepository journeyItemRepository;
|
||||
@Mock GeschichteQueryService geschichteQueryService;
|
||||
@Mock DocumentService documentService;
|
||||
@Mock AuditService auditService;
|
||||
@Mock UserService userService;
|
||||
|
||||
@InjectMocks JourneyItemService journeyItemService;
|
||||
|
||||
UUID geschichteId = UUID.randomUUID();
|
||||
UUID itemId = UUID.randomUUID();
|
||||
UUID docId = UUID.randomUUID();
|
||||
UUID actorId = UUID.randomUUID();
|
||||
|
||||
@BeforeEach
|
||||
void setupAuth() {
|
||||
AppUser actor = AppUser.builder().id(actorId).email("test@test.de").build();
|
||||
lenient().when(userService.findByEmail("test@test.de")).thenReturn(actor);
|
||||
lenient().when(geschichteQueryService.existsById(geschichteId)).thenReturn(true);
|
||||
SecurityContextHolder.getContext().setAuthentication(
|
||||
new UsernamePasswordAuthenticationToken("test@test.de", null,
|
||||
List.of(new SimpleGrantedAuthority("BLOG_WRITE"))));
|
||||
}
|
||||
|
||||
// ─── toSummary — name composition ────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void toSummary_uses_linked_person_firstName_lastName() {
|
||||
Person sender = Person.builder().firstName("Franz").lastName("Raddatz").build();
|
||||
Document doc = makeDoc(docId, sender, List.of(), null, null);
|
||||
|
||||
var summary = journeyItemService.toSummary(doc);
|
||||
|
||||
assertThat(summary.senderName()).isEqualTo("Franz Raddatz");
|
||||
}
|
||||
|
||||
@Test
|
||||
void toSummary_falls_back_to_senderText_when_no_person() {
|
||||
Document doc = makeDoc(docId, null, List.of(), "Familie Müller", null);
|
||||
|
||||
var summary = journeyItemService.toSummary(doc);
|
||||
|
||||
assertThat(summary.senderName()).isEqualTo("Familie Müller");
|
||||
}
|
||||
|
||||
@Test
|
||||
void toSummary_returns_null_senderName_when_neither_person_nor_text() {
|
||||
Document doc = makeDoc(docId, null, List.of(), null, null);
|
||||
|
||||
var summary = journeyItemService.toSummary(doc);
|
||||
|
||||
assertThat(summary.senderName()).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void toSummary_receiverCount_0_and_null_name_when_no_receiver() {
|
||||
Document doc = makeDoc(docId, null, List.of(), null, null);
|
||||
|
||||
var summary = journeyItemService.toSummary(doc);
|
||||
|
||||
assertThat(summary.receiverCount()).isEqualTo(0);
|
||||
assertThat(summary.receiverName()).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void toSummary_multi_receiver_returns_first_canonical_name_and_total_count() {
|
||||
Person emma = Person.builder().firstName("Emma").lastName("Raddatz").build();
|
||||
Person anna = Person.builder().firstName("Anna").lastName("Amann").build();
|
||||
Document doc = makeDoc(docId, null, List.of(emma, anna), null, null);
|
||||
|
||||
var summary = journeyItemService.toSummary(doc);
|
||||
|
||||
assertThat(summary.receiverCount()).isEqualTo(2);
|
||||
assertThat(summary.receiverName()).isEqualTo("Anna Amann"); // alphabetically first by lastName
|
||||
}
|
||||
|
||||
@Test
|
||||
void toSummary_datePrecision_SEASON_roundtrips() {
|
||||
Document doc = makeDoc(docId, null, List.of(), null, null);
|
||||
doc.setMetaDatePrecision(DatePrecision.SEASON);
|
||||
|
||||
var summary = journeyItemService.toSummary(doc);
|
||||
|
||||
assertThat(summary.datePrecision()).isEqualTo(DatePrecision.SEASON);
|
||||
}
|
||||
|
||||
@Test
|
||||
void toSummary_datePrecision_APPROX_roundtrips() {
|
||||
Document doc = makeDoc(docId, null, List.of(), null, null);
|
||||
doc.setMetaDatePrecision(DatePrecision.APPROX);
|
||||
|
||||
var summary = journeyItemService.toSummary(doc);
|
||||
|
||||
assertThat(summary.datePrecision()).isEqualTo(DatePrecision.APPROX);
|
||||
}
|
||||
|
||||
// ─── append ──────────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void append_to_empty_journey_starts_at_10() {
|
||||
Geschichte journey = journey(geschichteId);
|
||||
when(geschichteQueryService.findById(geschichteId)).thenReturn(Optional.of(journey));
|
||||
when(journeyItemRepository.countByGeschichteId(geschichteId)).thenReturn(0L);
|
||||
when(journeyItemRepository.findMaxPositionByGeschichteId(geschichteId)).thenReturn(Optional.empty());
|
||||
JourneyItem saved = savedItem(itemId, journey, 10, null, "Note");
|
||||
when(journeyItemRepository.save(any())).thenReturn(saved);
|
||||
|
||||
JourneyItemCreateDTO dto = new JourneyItemCreateDTO();
|
||||
dto.setNote("Note");
|
||||
|
||||
JourneyItemView view = journeyItemService.append(geschichteId, dto);
|
||||
|
||||
assertThat(view.position()).isEqualTo(10);
|
||||
}
|
||||
|
||||
@Test
|
||||
void append_after_reorder_continues_from_max_position() {
|
||||
Geschichte journey = journey(geschichteId);
|
||||
when(geschichteQueryService.findById(geschichteId)).thenReturn(Optional.of(journey));
|
||||
when(journeyItemRepository.countByGeschichteId(geschichteId)).thenReturn(2L);
|
||||
when(journeyItemRepository.findMaxPositionByGeschichteId(geschichteId)).thenReturn(Optional.of(40));
|
||||
JourneyItem saved = savedItem(itemId, journey, 50, null, "Note");
|
||||
when(journeyItemRepository.save(any())).thenReturn(saved);
|
||||
|
||||
JourneyItemCreateDTO dto = new JourneyItemCreateDTO();
|
||||
dto.setNote("Note");
|
||||
|
||||
JourneyItemView view = journeyItemService.append(geschichteId, dto);
|
||||
|
||||
assertThat(view.position()).isEqualTo(50);
|
||||
}
|
||||
|
||||
@Test
|
||||
void append_returns400_when_neither_documentId_nor_note() {
|
||||
Geschichte journey = journey(geschichteId);
|
||||
when(geschichteQueryService.findById(geschichteId)).thenReturn(Optional.of(journey));
|
||||
when(journeyItemRepository.countByGeschichteId(geschichteId)).thenReturn(0L);
|
||||
|
||||
JourneyItemCreateDTO dto = new JourneyItemCreateDTO();
|
||||
|
||||
assertThatThrownBy(() -> journeyItemService.append(geschichteId, dto))
|
||||
.isInstanceOf(DomainException.class)
|
||||
.hasMessageContaining("documentId or note");
|
||||
}
|
||||
|
||||
@Test
|
||||
void append_returns400_when_note_trims_to_empty_and_no_document() {
|
||||
Geschichte journey = journey(geschichteId);
|
||||
when(geschichteQueryService.findById(geschichteId)).thenReturn(Optional.of(journey));
|
||||
when(journeyItemRepository.countByGeschichteId(geschichteId)).thenReturn(0L);
|
||||
|
||||
JourneyItemCreateDTO dto = new JourneyItemCreateDTO();
|
||||
dto.setNote(" \n ");
|
||||
|
||||
assertThatThrownBy(() -> journeyItemService.append(geschichteId, dto))
|
||||
.isInstanceOf(DomainException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void append_returns400_when_note_exceeds_5000_chars() {
|
||||
Geschichte journey = journey(geschichteId);
|
||||
when(geschichteQueryService.findById(geschichteId)).thenReturn(Optional.of(journey));
|
||||
when(journeyItemRepository.countByGeschichteId(geschichteId)).thenReturn(0L);
|
||||
|
||||
JourneyItemCreateDTO dto = new JourneyItemCreateDTO();
|
||||
dto.setNote("x".repeat(5001));
|
||||
|
||||
assertThatThrownBy(() -> journeyItemService.append(geschichteId, dto))
|
||||
.isInstanceOf(DomainException.class)
|
||||
.satisfies(e -> assertThat(((DomainException) e).getCode())
|
||||
.isEqualTo(ErrorCode.VALIDATION_ERROR));
|
||||
}
|
||||
|
||||
@Test
|
||||
void append_returns409_on_non_JOURNEY_type() {
|
||||
Geschichte story = Geschichte.builder()
|
||||
.id(geschichteId)
|
||||
.title("Story")
|
||||
.type(GeschichteType.STORY)
|
||||
.status(GeschichteStatus.DRAFT)
|
||||
.build();
|
||||
when(geschichteQueryService.findById(geschichteId)).thenReturn(Optional.of(story));
|
||||
|
||||
JourneyItemCreateDTO dto = new JourneyItemCreateDTO();
|
||||
dto.setNote("Note");
|
||||
|
||||
assertThatThrownBy(() -> journeyItemService.append(geschichteId, dto))
|
||||
.isInstanceOf(DomainException.class)
|
||||
.satisfies(e -> assertThat(((DomainException) e).getCode())
|
||||
.isEqualTo(ErrorCode.GESCHICHTE_TYPE_MISMATCH));
|
||||
}
|
||||
|
||||
@Test
|
||||
void append_never_calls_findSummaryByIdInternal_when_geschichte_type_is_STORY() {
|
||||
// Arrange: mock geschichteQueryService.findById() to return a STORY-type Geschichte
|
||||
UUID storyId = UUID.randomUUID();
|
||||
Geschichte story = Geschichte.builder()
|
||||
.id(storyId)
|
||||
.type(GeschichteType.STORY)
|
||||
.build();
|
||||
when(geschichteQueryService.findById(storyId)).thenReturn(Optional.of(story));
|
||||
|
||||
// Act + Assert: calling append throws GESCHICHTE_TYPE_MISMATCH
|
||||
JourneyItemCreateDTO dto = new JourneyItemCreateDTO();
|
||||
dto.setDocumentId(UUID.randomUUID());
|
||||
assertThatThrownBy(() -> journeyItemService.append(storyId, dto))
|
||||
.isInstanceOf(DomainException.class);
|
||||
|
||||
// Verify: document service was never touched — type guard fired first
|
||||
verifyNoInteractions(documentService);
|
||||
}
|
||||
|
||||
@Test
|
||||
void append_returns404_when_documentId_does_not_exist() {
|
||||
Geschichte journey = journey(geschichteId);
|
||||
when(geschichteQueryService.findById(geschichteId)).thenReturn(Optional.of(journey));
|
||||
when(journeyItemRepository.countByGeschichteId(geschichteId)).thenReturn(0L);
|
||||
when(documentService.findSummaryByIdInternal(docId))
|
||||
.thenThrow(DomainException.notFound(ErrorCode.DOCUMENT_NOT_FOUND, "not found"));
|
||||
|
||||
JourneyItemCreateDTO dto = new JourneyItemCreateDTO();
|
||||
dto.setDocumentId(docId);
|
||||
|
||||
assertThatThrownBy(() -> journeyItemService.append(geschichteId, dto))
|
||||
.isInstanceOf(DomainException.class)
|
||||
.satisfies(e -> assertThat(((DomainException) e).getCode())
|
||||
.isEqualTo(ErrorCode.DOCUMENT_NOT_FOUND));
|
||||
}
|
||||
|
||||
@Test
|
||||
void append_returns409_when_100_items_exist() {
|
||||
Geschichte journey = journey(geschichteId);
|
||||
when(geschichteQueryService.findById(geschichteId)).thenReturn(Optional.of(journey));
|
||||
when(journeyItemRepository.countByGeschichteId(geschichteId)).thenReturn(100L);
|
||||
|
||||
JourneyItemCreateDTO dto = new JourneyItemCreateDTO();
|
||||
dto.setNote("Note");
|
||||
|
||||
assertThatThrownBy(() -> journeyItemService.append(geschichteId, dto))
|
||||
.isInstanceOf(DomainException.class)
|
||||
.satisfies(e -> assertThat(((DomainException) e).getCode())
|
||||
.isEqualTo(ErrorCode.JOURNEY_AT_CAPACITY));
|
||||
}
|
||||
|
||||
@Test
|
||||
void cap_is_COUNT_based_not_MAX_position_based() {
|
||||
// 99 rows with MAX(position)=2000 should still accept the 100th append
|
||||
Geschichte journey = journey(geschichteId);
|
||||
when(geschichteQueryService.findById(geschichteId)).thenReturn(Optional.of(journey));
|
||||
when(journeyItemRepository.countByGeschichteId(geschichteId)).thenReturn(99L);
|
||||
when(journeyItemRepository.findMaxPositionByGeschichteId(geschichteId)).thenReturn(Optional.of(2000));
|
||||
JourneyItem saved = savedItem(itemId, journey, 2010, null, "Note");
|
||||
when(journeyItemRepository.save(any())).thenReturn(saved);
|
||||
|
||||
JourneyItemCreateDTO dto = new JourneyItemCreateDTO();
|
||||
dto.setNote("Note");
|
||||
|
||||
assertThat(journeyItemService.append(geschichteId, dto).position()).isEqualTo(2010);
|
||||
}
|
||||
|
||||
@Test
|
||||
void append_audits_JOURNEY_ITEM_ADDED() {
|
||||
Geschichte journey = journey(geschichteId);
|
||||
when(geschichteQueryService.findById(geschichteId)).thenReturn(Optional.of(journey));
|
||||
when(journeyItemRepository.countByGeschichteId(geschichteId)).thenReturn(0L);
|
||||
when(journeyItemRepository.findMaxPositionByGeschichteId(geschichteId)).thenReturn(Optional.empty());
|
||||
JourneyItem saved = savedItem(itemId, journey, 10, null, "Note");
|
||||
when(journeyItemRepository.save(any())).thenReturn(saved);
|
||||
|
||||
JourneyItemCreateDTO dto = new JourneyItemCreateDTO();
|
||||
dto.setNote("Note");
|
||||
journeyItemService.append(geschichteId, dto);
|
||||
|
||||
verify(auditService).logAfterCommit(eq(AuditKind.JOURNEY_ITEM_ADDED), eq(actorId), isNull(), any());
|
||||
}
|
||||
|
||||
// ─── updateNote ───────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void updateNote_absent_leaves_note_unchanged() {
|
||||
Geschichte journey = journey(geschichteId);
|
||||
JourneyItem item = savedItem(itemId, journey, 10, null, "Original note");
|
||||
when(journeyItemRepository.findByIdAndGeschichteId(itemId, geschichteId)).thenReturn(Optional.of(item));
|
||||
|
||||
JourneyItemUpdateDTO dto = new JourneyItemUpdateDTO();
|
||||
// note is null by default — absent from JSON, no-op
|
||||
|
||||
JourneyItemView view = journeyItemService.updateNote(geschichteId, itemId, dto);
|
||||
|
||||
assertThat(view.note()).isEqualTo("Original note");
|
||||
verify(journeyItemRepository, never()).save(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateNote_null_clears_note_when_document_is_present() {
|
||||
Geschichte journey = journey(geschichteId);
|
||||
Document doc = makeDoc(docId, null, List.of(), null, null);
|
||||
JourneyItem item = savedItemWithDoc(itemId, journey, 10, doc, "Old note");
|
||||
when(journeyItemRepository.findByIdAndGeschichteId(itemId, geschichteId)).thenReturn(Optional.of(item));
|
||||
JourneyItem saved = savedItemWithDoc(itemId, journey, 10, doc, null);
|
||||
when(journeyItemRepository.save(item)).thenReturn(saved);
|
||||
|
||||
JourneyItemUpdateDTO dto = new JourneyItemUpdateDTO();
|
||||
dto.setNote(Optional.empty());
|
||||
|
||||
JourneyItemView view = journeyItemService.updateNote(geschichteId, itemId, dto);
|
||||
|
||||
assertThat(view.note()).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateNote_string_sets_note() {
|
||||
Geschichte journey = journey(geschichteId);
|
||||
JourneyItem item = savedItem(itemId, journey, 10, null, null);
|
||||
item.setNote(null);
|
||||
when(journeyItemRepository.findByIdAndGeschichteId(itemId, geschichteId)).thenReturn(Optional.of(item));
|
||||
JourneyItem saved = savedItem(itemId, journey, 10, null, "New note");
|
||||
when(journeyItemRepository.save(item)).thenReturn(saved);
|
||||
|
||||
JourneyItemUpdateDTO dto = new JourneyItemUpdateDTO();
|
||||
dto.setNote(Optional.of("New note"));
|
||||
|
||||
JourneyItemView view = journeyItemService.updateNote(geschichteId, itemId, dto);
|
||||
|
||||
assertThat(view.note()).isEqualTo("New note");
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateNote_null_returns400_when_item_has_no_document() {
|
||||
Geschichte journey = journey(geschichteId);
|
||||
JourneyItem item = savedItem(itemId, journey, 10, null, "Only note — no doc");
|
||||
when(journeyItemRepository.findByIdAndGeschichteId(itemId, geschichteId)).thenReturn(Optional.of(item));
|
||||
|
||||
JourneyItemUpdateDTO dto = new JourneyItemUpdateDTO();
|
||||
dto.setNote(Optional.empty());
|
||||
|
||||
assertThatThrownBy(() -> journeyItemService.updateNote(geschichteId, itemId, dto))
|
||||
.isInstanceOf(DomainException.class)
|
||||
.satisfies(e -> assertThat(((DomainException) e).getCode())
|
||||
.isEqualTo(ErrorCode.VALIDATION_ERROR));
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateNote_whitespace_only_including_newlines_stored_as_null() {
|
||||
Geschichte journey = journey(geschichteId);
|
||||
Document doc = makeDoc(docId, null, List.of(), null, null);
|
||||
JourneyItem item = savedItemWithDoc(itemId, journey, 10, doc, "Old");
|
||||
when(journeyItemRepository.findByIdAndGeschichteId(itemId, geschichteId)).thenReturn(Optional.of(item));
|
||||
JourneyItem saved = savedItemWithDoc(itemId, journey, 10, doc, null);
|
||||
when(journeyItemRepository.save(item)).thenReturn(saved);
|
||||
|
||||
JourneyItemUpdateDTO dto = new JourneyItemUpdateDTO();
|
||||
dto.setNote(Optional.of("\n \n"));
|
||||
|
||||
JourneyItemView view = journeyItemService.updateNote(geschichteId, itemId, dto);
|
||||
|
||||
assertThat(view.note()).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void patch_returns400_when_note_exceeds_5000_chars() {
|
||||
Geschichte journey = journey(geschichteId);
|
||||
JourneyItem item = savedItem(itemId, journey, 10, null, "Old");
|
||||
when(journeyItemRepository.findByIdAndGeschichteId(itemId, geschichteId)).thenReturn(Optional.of(item));
|
||||
|
||||
JourneyItemUpdateDTO dto = new JourneyItemUpdateDTO();
|
||||
dto.setNote(Optional.of("x".repeat(5001)));
|
||||
|
||||
assertThatThrownBy(() -> journeyItemService.updateNote(geschichteId, itemId, dto))
|
||||
.isInstanceOf(DomainException.class)
|
||||
.satisfies(e -> assertThat(((DomainException) e).getCode())
|
||||
.isEqualTo(ErrorCode.VALIDATION_ERROR));
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateNote_auditsNoteUpdate() {
|
||||
Geschichte journey = journey(geschichteId);
|
||||
JourneyItem item = savedItem(itemId, journey, 10, null, null);
|
||||
when(journeyItemRepository.findByIdAndGeschichteId(itemId, geschichteId)).thenReturn(Optional.of(item));
|
||||
JourneyItem saved = savedItem(itemId, journey, 10, null, "New note");
|
||||
when(journeyItemRepository.save(item)).thenReturn(saved);
|
||||
|
||||
JourneyItemUpdateDTO dto = new JourneyItemUpdateDTO();
|
||||
dto.setNote(Optional.of("New note"));
|
||||
journeyItemService.updateNote(geschichteId, itemId, dto);
|
||||
|
||||
verify(auditService).logAfterCommit(eq(AuditKind.JOURNEY_ITEM_NOTE_UPDATED), eq(actorId), isNull(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void patch_returns404_when_item_belongs_to_different_journey() {
|
||||
when(journeyItemRepository.findByIdAndGeschichteId(itemId, geschichteId)).thenReturn(Optional.empty());
|
||||
|
||||
JourneyItemUpdateDTO dto = new JourneyItemUpdateDTO();
|
||||
dto.setNote(Optional.of("text"));
|
||||
|
||||
assertThatThrownBy(() -> journeyItemService.updateNote(geschichteId, itemId, dto))
|
||||
.isInstanceOf(DomainException.class)
|
||||
.satisfies(e -> assertThat(((DomainException) e).getCode())
|
||||
.isEqualTo(ErrorCode.JOURNEY_ITEM_NOT_FOUND));
|
||||
}
|
||||
|
||||
// ─── delete ───────────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void delete_returns404_when_item_already_deleted() {
|
||||
when(journeyItemRepository.findByIdAndGeschichteId(itemId, geschichteId)).thenReturn(Optional.empty());
|
||||
|
||||
assertThatThrownBy(() -> journeyItemService.delete(geschichteId, itemId))
|
||||
.isInstanceOf(DomainException.class)
|
||||
.satisfies(e -> assertThat(((DomainException) e).getCode())
|
||||
.isEqualTo(ErrorCode.JOURNEY_ITEM_NOT_FOUND));
|
||||
}
|
||||
|
||||
@Test
|
||||
void delete_no_audit_when_item_not_found() {
|
||||
when(journeyItemRepository.findByIdAndGeschichteId(itemId, geschichteId)).thenReturn(Optional.empty());
|
||||
|
||||
assertThatThrownBy(() -> journeyItemService.delete(geschichteId, itemId))
|
||||
.isInstanceOf(DomainException.class);
|
||||
|
||||
verify(auditService, never()).logAfterCommit(any(), any(), any(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void delete_audits_JOURNEY_ITEM_REMOVED_when_item_found() {
|
||||
Geschichte journey = journey(geschichteId);
|
||||
JourneyItem item = savedItem(itemId, journey, 10, null, "Note");
|
||||
when(journeyItemRepository.findByIdAndGeschichteId(itemId, geschichteId)).thenReturn(Optional.of(item));
|
||||
|
||||
journeyItemService.delete(geschichteId, itemId);
|
||||
|
||||
verify(auditService).logAfterCommit(eq(AuditKind.JOURNEY_ITEM_REMOVED), eq(actorId), isNull(), any());
|
||||
}
|
||||
|
||||
// ─── reorder ─────────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void reorder_unknownGeschichteId_throws404() {
|
||||
UUID unknownId = UUID.randomUUID();
|
||||
// geschichteQueryService is not lenient-stubbed for unknownId → returns false
|
||||
|
||||
JourneyReorderDTO dto = new JourneyReorderDTO();
|
||||
dto.setItemIds(List.of());
|
||||
|
||||
assertThatThrownBy(() -> journeyItemService.reorder(unknownId, dto))
|
||||
.isInstanceOf(DomainException.class)
|
||||
.satisfies(e -> assertThat(((DomainException) e).getCode())
|
||||
.isEqualTo(ErrorCode.GESCHICHTE_NOT_FOUND));
|
||||
}
|
||||
|
||||
@Test
|
||||
void reorder_returns400_when_itemIds_contain_duplicates() {
|
||||
UUID id1 = UUID.randomUUID();
|
||||
when(journeyItemRepository.findIdsByGeschichteId(geschichteId)).thenReturn(Set.of(id1));
|
||||
|
||||
JourneyReorderDTO dto = new JourneyReorderDTO();
|
||||
dto.setItemIds(List.of(id1, id1)); // duplicate
|
||||
|
||||
assertThatThrownBy(() -> journeyItemService.reorder(geschichteId, dto))
|
||||
.isInstanceOf(DomainException.class)
|
||||
.satisfies(e -> assertThat(((DomainException) e).getCode())
|
||||
.isEqualTo(ErrorCode.VALIDATION_ERROR));
|
||||
}
|
||||
|
||||
@Test
|
||||
void reorder_returns400_when_itemId_belongs_to_different_journey() {
|
||||
UUID foreignId = UUID.randomUUID();
|
||||
UUID localId = UUID.randomUUID();
|
||||
when(journeyItemRepository.findIdsByGeschichteId(geschichteId)).thenReturn(Set.of(localId));
|
||||
|
||||
JourneyReorderDTO dto = new JourneyReorderDTO();
|
||||
dto.setItemIds(List.of(foreignId));
|
||||
|
||||
assertThatThrownBy(() -> journeyItemService.reorder(geschichteId, dto))
|
||||
.isInstanceOf(DomainException.class)
|
||||
.satisfies(e -> assertThat(((DomainException) e).getCode())
|
||||
.isEqualTo(ErrorCode.VALIDATION_ERROR));
|
||||
}
|
||||
|
||||
@Test
|
||||
void reorder_returns400_when_ids_have_extra_items() {
|
||||
UUID id1 = UUID.randomUUID();
|
||||
UUID id2 = UUID.randomUUID();
|
||||
when(journeyItemRepository.findIdsByGeschichteId(geschichteId)).thenReturn(Set.of(id1));
|
||||
|
||||
JourneyReorderDTO dto = new JourneyReorderDTO();
|
||||
dto.setItemIds(List.of(id1, id2));
|
||||
|
||||
assertThatThrownBy(() -> journeyItemService.reorder(geschichteId, dto))
|
||||
.isInstanceOf(DomainException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void reorder_returns200_when_empty_on_empty_journey() {
|
||||
when(journeyItemRepository.findIdsByGeschichteId(geschichteId)).thenReturn(Set.of());
|
||||
|
||||
JourneyReorderDTO dto = new JourneyReorderDTO();
|
||||
dto.setItemIds(List.of());
|
||||
|
||||
List<JourneyItemView> result = journeyItemService.reorder(geschichteId, dto);
|
||||
|
||||
assertThat(result).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void reorder_returns400_when_empty_on_nonempty_journey() {
|
||||
UUID id1 = UUID.randomUUID();
|
||||
when(journeyItemRepository.findIdsByGeschichteId(geschichteId)).thenReturn(Set.of(id1));
|
||||
|
||||
JourneyReorderDTO dto = new JourneyReorderDTO();
|
||||
dto.setItemIds(List.of());
|
||||
|
||||
assertThatThrownBy(() -> journeyItemService.reorder(geschichteId, dto))
|
||||
.isInstanceOf(DomainException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void reorder_returns_items_in_new_order_starting_at_10() {
|
||||
Geschichte journey = journey(geschichteId);
|
||||
UUID id1 = UUID.randomUUID();
|
||||
UUID id2 = UUID.randomUUID();
|
||||
JourneyItem item1 = savedItem(id1, journey, 20, null, "A");
|
||||
JourneyItem item2 = savedItem(id2, journey, 10, null, "B");
|
||||
when(journeyItemRepository.findIdsByGeschichteId(geschichteId)).thenReturn(Set.of(id1, id2));
|
||||
when(journeyItemRepository.findByGeschichteIdOrderByPosition(geschichteId)).thenReturn(List.of(item2, item1));
|
||||
when(journeyItemRepository.saveAll(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
JourneyReorderDTO dto = new JourneyReorderDTO();
|
||||
dto.setItemIds(List.of(id1, id2)); // want id1 first
|
||||
|
||||
List<JourneyItemView> views = journeyItemService.reorder(geschichteId, dto);
|
||||
|
||||
assertThat(views).hasSize(2);
|
||||
assertThat(views.get(0).id()).isEqualTo(id1);
|
||||
assertThat(views.get(0).position()).isEqualTo(10);
|
||||
assertThat(views.get(1).id()).isEqualTo(id2);
|
||||
assertThat(views.get(1).position()).isEqualTo(20);
|
||||
}
|
||||
|
||||
@Test
|
||||
void reorder_identical_order_returns200() {
|
||||
Geschichte journey = journey(geschichteId);
|
||||
UUID id1 = UUID.randomUUID();
|
||||
JourneyItem item1 = savedItem(id1, journey, 10, null, "A");
|
||||
when(journeyItemRepository.findIdsByGeschichteId(geschichteId)).thenReturn(Set.of(id1));
|
||||
when(journeyItemRepository.findByGeschichteIdOrderByPosition(geschichteId)).thenReturn(List.of(item1));
|
||||
when(journeyItemRepository.saveAll(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
JourneyReorderDTO dto = new JourneyReorderDTO();
|
||||
dto.setItemIds(List.of(id1));
|
||||
|
||||
List<JourneyItemView> views = journeyItemService.reorder(geschichteId, dto);
|
||||
|
||||
assertThat(views).hasSize(1);
|
||||
assertThat(views.get(0).position()).isEqualTo(10);
|
||||
}
|
||||
|
||||
@Test
|
||||
void reorder_of_grandfathered_over_cap_journey_succeeds() {
|
||||
Geschichte journey = journey(geschichteId);
|
||||
// 130-item journey — reorder with all 130 IDs must succeed despite > 100 cap
|
||||
List<UUID> ids = new java.util.ArrayList<>();
|
||||
List<JourneyItem> items = new java.util.ArrayList<>();
|
||||
for (int i = 1; i <= 130; i++) {
|
||||
UUID id = UUID.randomUUID();
|
||||
ids.add(id);
|
||||
items.add(savedItem(id, journey, i * 10, null, "item " + i));
|
||||
}
|
||||
when(journeyItemRepository.findIdsByGeschichteId(geschichteId)).thenReturn(new HashSet<>(ids));
|
||||
when(journeyItemRepository.findByGeschichteIdOrderByPosition(geschichteId)).thenReturn(items);
|
||||
when(journeyItemRepository.saveAll(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
JourneyReorderDTO dto = new JourneyReorderDTO();
|
||||
dto.setItemIds(ids);
|
||||
|
||||
List<JourneyItemView> views = journeyItemService.reorder(geschichteId, dto);
|
||||
|
||||
assertThat(views).hasSize(130);
|
||||
}
|
||||
|
||||
@Test
|
||||
void reorder_audits_JOURNEY_ITEMS_REORDERED() {
|
||||
Geschichte journey = journey(geschichteId);
|
||||
UUID id1 = UUID.randomUUID();
|
||||
JourneyItem item1 = savedItem(id1, journey, 10, null, "A");
|
||||
when(journeyItemRepository.findIdsByGeschichteId(geschichteId)).thenReturn(Set.of(id1));
|
||||
when(journeyItemRepository.findByGeschichteIdOrderByPosition(geschichteId)).thenReturn(List.of(item1));
|
||||
when(journeyItemRepository.saveAll(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
JourneyReorderDTO dto = new JourneyReorderDTO();
|
||||
dto.setItemIds(List.of(id1));
|
||||
journeyItemService.reorder(geschichteId, dto);
|
||||
|
||||
verify(auditService).logAfterCommit(eq(AuditKind.JOURNEY_ITEMS_REORDERED), eq(actorId), isNull(), any());
|
||||
}
|
||||
|
||||
// ─── helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
private Geschichte journey(UUID id) {
|
||||
return Geschichte.builder()
|
||||
.id(id)
|
||||
.title("Test Journey")
|
||||
.type(GeschichteType.JOURNEY)
|
||||
.status(GeschichteStatus.DRAFT)
|
||||
.build();
|
||||
}
|
||||
|
||||
private JourneyItem savedItem(UUID id, Geschichte g, int position, Document doc, String note) {
|
||||
return JourneyItem.builder()
|
||||
.id(id)
|
||||
.geschichte(g)
|
||||
.position(position)
|
||||
.document(null) // no document entity to avoid LAZY issues in unit tests
|
||||
.note(note)
|
||||
.build();
|
||||
}
|
||||
|
||||
private JourneyItem savedItemWithDoc(UUID id, Geschichte g, int position, Document doc, String note) {
|
||||
JourneyItem item = JourneyItem.builder()
|
||||
.id(id)
|
||||
.geschichte(g)
|
||||
.position(position)
|
||||
.document(doc)
|
||||
.note(note)
|
||||
.build();
|
||||
return item;
|
||||
}
|
||||
|
||||
private Document makeDoc(UUID id, Person sender, List<Person> receivers, String senderText, String receiverText) {
|
||||
Document doc = Document.builder()
|
||||
.id(id)
|
||||
.title("Test Doc")
|
||||
.originalFilename("test.pdf")
|
||||
.status(DocumentStatus.UPLOADED)
|
||||
.senderText(senderText)
|
||||
.receiverText(receiverText)
|
||||
.sender(sender)
|
||||
.build();
|
||||
doc.setReceivers(new HashSet<>(receivers));
|
||||
return doc;
|
||||
}
|
||||
}
|
||||
@@ -151,7 +151,13 @@ _See also [Chronik](#chronik-internal)._
|
||||
|
||||
**Geschichte** (`Geschichte`) `[user-facing]` — a narrative story or curated document journey published in the archive. Two subtypes: `STORY` (free-form prose linking `Person`s) and `JOURNEY` (a *Lesereise* — an ordered sequence of `JourneyItem`s). Lifecycle: `DRAFT → PUBLISHED` (see `GeschichteStatus`). DRAFT stories are hidden from users without the `BLOG_WRITE` permission.
|
||||
|
||||
**JourneyItem** (`JourneyItem`, table `journey_items`) `[internal]` — a single stop in a *Lesereise* (`Geschichte` with `type=JOURNEY`). Either document-backed (`document_id IS NOT NULL`) or a note-only interlude (`note IS NOT NULL`). Ordered by `position` (gaps of 1000 leave room for drag-reorder). A CHECK constraint ensures at least one of `document_id` or `note` is present. The FK to `documents` uses `ON DELETE SET NULL`, so deleting a document preserves the item (with `document_id = null`).
|
||||
**JourneyItem** (`JourneyItem`, table `journey_items`) `[internal]` — a single stop in a *Lesereise* (`Geschichte` with `type=JOURNEY`). Either document-backed (`document_id IS NOT NULL`) or a note-only interlude (`note IS NOT NULL`). Ordered by `position` (step of 10; max 100 items per journey). A DEFERRABLE UNIQUE constraint on `(geschichte_id, position)` allows atomic position swaps in the same transaction. A CHECK constraint ensures at least one of `document_id` or `note` is present. The FK to `documents` uses `ON DELETE SET NULL`, so deleting a document preserves the item (with `document_id = null`).
|
||||
|
||||
**GeschichteView** (`GeschichteView`) `[internal]` — lean read-model record returned by `GeschichteService.getById()`. Contains `AuthorView` (id + displayName only — email not exposed) and a `List<JourneyItemView>` loaded via a separate query rather than a lazy collection.
|
||||
|
||||
**JourneyItemView** (`JourneyItemView`) `[internal]` — lean view record for a single `JourneyItem` surface, containing `id`, `position`, an optional `DocumentSummary`, and an optional `note`.
|
||||
|
||||
**DocumentSummary** (`DocumentSummary`) `[internal]` — lean document read-model used inside `JourneyItemView`. Contains title, date, senderName, receiverName, receiverCount, datePrecision — no tags or file storage info.
|
||||
|
||||
**Lesereise** `[user-facing]` — a curated reading journey through a sequence of family documents, optionally annotated with editorial notes. Implemented as a `Geschichte` with `type=JOURNEY`. The reader UI (follow-on issue) renders items as a sequential reading experience.
|
||||
|
||||
|
||||
43
docs/adr/035-optional-string-three-way-patch-semantics.md
Normal file
43
docs/adr/035-optional-string-three-way-patch-semantics.md
Normal file
@@ -0,0 +1,43 @@
|
||||
# ADR-035 — `Optional<String>` for three-way PATCH semantics
|
||||
|
||||
**Status:** Accepted
|
||||
**Date:** 2026-06-08
|
||||
**Issue:** #751 (JourneyItem CRUD API)
|
||||
|
||||
## Context
|
||||
|
||||
The `PATCH /api/geschichten/{id}/items/{itemId}` endpoint must distinguish three cases for the `note` field:
|
||||
|
||||
| JSON body | Intended meaning |
|
||||
|-------------------|-----------------------|
|
||||
| `{"note": "text"}`| Set note to "text" |
|
||||
| `{"note": null}` | Clear the note |
|
||||
| `{}` (absent) | Leave note unchanged |
|
||||
|
||||
The standard library for this on Jackson 2.x is `jackson-databind-nullable` (`JsonNullable<T>` from `org.openapitools`). However, that library targets `com.fasterxml.jackson.*` (Jackson 2.x) and is incompatible with Spring Boot 4.0 / Spring Framework 7, which uses `tools.jackson.*` (Jackson 3.x). The module fails to register and throws at startup.
|
||||
|
||||
## Decision
|
||||
|
||||
Use `Optional<String>` with Java's default field initializer (`= null`) to encode the three states:
|
||||
|
||||
```java
|
||||
@Data
|
||||
public class JourneyItemUpdateDTO {
|
||||
private Optional<String> note = null; // Java default — absent = no-op
|
||||
}
|
||||
```
|
||||
|
||||
| Java value | JSON wire | Semantics |
|
||||
|--------------------|-------------------|---------------|
|
||||
| `null` (default) | field absent | no-op |
|
||||
| `Optional.empty()` | `{"note": null}` | clear |
|
||||
| `Optional.of("x")` | `{"note": "x"}` | set |
|
||||
|
||||
Jackson 3.x natively maps a JSON `null` to `Optional.empty()` and leaves absent fields at their Java default. No custom module is needed.
|
||||
|
||||
## Consequences
|
||||
|
||||
- No external dependency for PATCH semantics — simpler pom.xml.
|
||||
- The DTO field type is `Optional<String>`, not `String` — service code must null-check the field first (`if (noteField == null) return;`) and then call `.orElse(null)` to unwrap.
|
||||
- This pattern applies to any future PATCH DTO that needs three-way semantics on a nullable field.
|
||||
- `jackson-databind-nullable` is removed from `pom.xml`; `JacksonConfig.java` is kept as a placeholder for future custom modules.
|
||||
@@ -18,6 +18,8 @@ System_Boundary(backend, "API Backend (Spring Boot)") {
|
||||
Component(sseRegistry, "SseEmitterRegistry", "Spring Component", "In-memory ConcurrentHashMap of Spring SseEmitter instances per user. Handles registration, deregistration, and JSON event broadcasts.")
|
||||
Component(geschCtrl, "GeschichteController", "Spring MVC — /api/geschichten", "CRUD for publishable stories (STORY) and reading journeys (JOURNEY). Returns GeschichteSummary projections for list; full Geschichte with JourneyItems for detail. Requires BLOG_WRITE permission for write operations.")
|
||||
Component(geschSvc, "GeschichteService", "Spring Service", "Manages story lifecycle (DRAFT → PUBLISHED with timestamp). Supports two subtypes: STORY (prose) and JOURNEY (ordered JourneyItem sequence). Sanitizes HTML body with an allowlist policy.")
|
||||
Component(geschQuerySvc, "GeschichteQueryService", "Spring Service", "Read-only facade over GeschichteRepository. Exposes existsById() and findById() to prevent JourneyItemService from crossing domain boundaries.")
|
||||
Component(journeyItemSvc, "JourneyItemService", "Spring Service", "Manages journey item lifecycle: append (100-item cap), updateNote (three-way PATCH), delete, and reorder (DEFERRABLE position swap). Enforces JOURNEY-type guard on append.")
|
||||
Component(exHandler, "GlobalExceptionHandler", "Spring @RestControllerAdvice", "Converts DomainException, validation errors, and generic exceptions to ErrorResponse JSON with machine-readable ErrorCode and HTTP status.")
|
||||
}
|
||||
|
||||
@@ -38,6 +40,10 @@ Rel(notifCtrl, notifSvc, "Delegates to")
|
||||
Rel(notifCtrl, sseRegistry, "Registers client SSE connection")
|
||||
Rel(notifSvc, sseRegistry, "Broadcasts events to connected clients")
|
||||
Rel(geschCtrl, geschSvc, "Delegates to")
|
||||
Rel(geschCtrl, journeyItemSvc, "Delegates journey item CRUD")
|
||||
Rel(journeyItemSvc, geschQuerySvc, "Checks Geschichte existence and type")
|
||||
Rel(geschQuerySvc, db, "Reads geschichten", "JDBC")
|
||||
Rel(journeyItemSvc, db, "Reads / writes journey_items", "JDBC")
|
||||
Rel(auditSvc, db, "Writes audit_log", "JDBC")
|
||||
Rel(auditQuery, db, "Reads audit_log", "JDBC")
|
||||
Rel(notifSvc, db, "Reads / writes notifications", "JDBC")
|
||||
|
||||
@@ -376,8 +376,10 @@ package "Supporting" {
|
||||
--
|
||||
geschichte_id : UUID <<FK>>
|
||||
document_id : UUID <<FK>>
|
||||
position : INTEGER NOT NULL
|
||||
position : INTEGER NOT NULL CHECK (position > 0)
|
||||
note : TEXT
|
||||
==
|
||||
UNIQUE (geschichte_id, position) DEFERRABLE INITIALLY DEFERRED
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1023,6 +1023,11 @@
|
||||
"nav_stammbaum": "Stammbaum",
|
||||
"nav_geschichten": "Geschichten",
|
||||
"error_geschichte_not_found": "Die Geschichte wurde nicht gefunden.",
|
||||
"error_journey_item_not_found": "Der Reise-Eintrag wurde nicht gefunden.",
|
||||
"error_journey_item_position_conflict": "Die Reihenfolge wurde gerade von jemand anderem geändert – bitte laden Sie die Seite neu.",
|
||||
"error_journey_at_capacity": "Die Lesereise hat bereits die maximale Anzahl von Einträgen (100) erreicht.",
|
||||
"error_geschichte_type_mismatch": "Diese Geschichte ist keine Lesereise – Reise-Einträge sind hier nicht erlaubt.",
|
||||
"journey_item_document_deleted": "[Dokument gelöscht]",
|
||||
"geschichten_index_title": "Geschichten",
|
||||
"geschichten_new_button": "Neue Geschichte",
|
||||
"geschichten_filter_all_pill": "Alle",
|
||||
|
||||
@@ -1023,6 +1023,11 @@
|
||||
"nav_stammbaum": "Family tree",
|
||||
"nav_geschichten": "Stories",
|
||||
"error_geschichte_not_found": "The story was not found.",
|
||||
"error_journey_item_not_found": "The journey item was not found.",
|
||||
"error_journey_item_position_conflict": "The order was just changed by someone else — please reload the page.",
|
||||
"error_journey_at_capacity": "The reading journey has already reached the maximum of 100 items.",
|
||||
"error_geschichte_type_mismatch": "This story is not a reading journey — journey items are not allowed here.",
|
||||
"journey_item_document_deleted": "[Document deleted]",
|
||||
"geschichten_index_title": "Stories",
|
||||
"geschichten_new_button": "New story",
|
||||
"geschichten_filter_all_pill": "All",
|
||||
|
||||
@@ -1023,6 +1023,11 @@
|
||||
"nav_stammbaum": "Árbol genealógico",
|
||||
"nav_geschichten": "Historias",
|
||||
"error_geschichte_not_found": "No se encontró la historia.",
|
||||
"error_journey_item_not_found": "No se encontró el elemento del viaje.",
|
||||
"error_journey_item_position_conflict": "El orden fue cambiado por otra persona — por favor recargue la página.",
|
||||
"error_journey_at_capacity": "El viaje de lectura ya ha alcanzado el máximo de 100 entradas.",
|
||||
"error_geschichte_type_mismatch": "Esta historia no es un viaje de lectura — los elementos de viaje no están permitidos aquí.",
|
||||
"journey_item_document_deleted": "[Documento eliminado]",
|
||||
"geschichten_index_title": "Historias",
|
||||
"geschichten_new_button": "Nueva historia",
|
||||
"geschichten_filter_all_pill": "Todas",
|
||||
|
||||
@@ -692,22 +692,6 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/admin/backfill-titles": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
post: operations["backfillTitles"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/admin/backfill-file-hashes": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -875,7 +859,7 @@ export interface paths {
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get: operations["search_1"];
|
||||
get: operations["search"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
@@ -1339,7 +1323,7 @@ export interface paths {
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get: operations["search_2"];
|
||||
get: operations["search_1"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
@@ -1428,6 +1412,22 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/documents/conversation": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get: operations["getConversation"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/dashboard/resume": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -1758,7 +1758,6 @@ export interface components {
|
||||
sender?: components["schemas"]["Person"];
|
||||
tags?: components["schemas"]["Tag"][];
|
||||
trainingLabels?: ("KURRENT_RECOGNITION" | "KURRENT_SEGMENTATION")[];
|
||||
hasTranscription: boolean;
|
||||
thumbnailUrl?: string;
|
||||
};
|
||||
PersonMention: {
|
||||
@@ -1819,75 +1818,6 @@ export interface components {
|
||||
/** Format: uuid */
|
||||
targetId: string;
|
||||
};
|
||||
Pageable: {
|
||||
/** Format: int32 */
|
||||
page?: number;
|
||||
/** Format: int32 */
|
||||
size?: number;
|
||||
sort?: string[];
|
||||
};
|
||||
ActivityActorDTO: {
|
||||
initials: string;
|
||||
color: string;
|
||||
name?: string;
|
||||
};
|
||||
DocumentListItem: {
|
||||
/** Format: uuid */
|
||||
id: string;
|
||||
title: string;
|
||||
originalFilename: string;
|
||||
thumbnailUrl?: string;
|
||||
/** Format: date */
|
||||
documentDate?: string;
|
||||
/** @enum {string} */
|
||||
metaDatePrecision: "DAY" | "MONTH" | "SEASON" | "YEAR" | "RANGE" | "APPROX" | "UNKNOWN";
|
||||
/** Format: date */
|
||||
metaDateEnd?: string;
|
||||
sender?: components["schemas"]["Person"];
|
||||
receivers: components["schemas"]["Person"][];
|
||||
tags: components["schemas"]["Tag"][];
|
||||
archiveBox?: string;
|
||||
archiveFolder?: string;
|
||||
location?: string;
|
||||
summary?: string;
|
||||
/** Format: int32 */
|
||||
completionPercentage: number;
|
||||
contributors: components["schemas"]["ActivityActorDTO"][];
|
||||
matchData: components["schemas"]["SearchMatchData"];
|
||||
/** Format: date-time */
|
||||
createdAt: string;
|
||||
/** Format: date-time */
|
||||
updatedAt: string;
|
||||
};
|
||||
DocumentSearchResult: {
|
||||
items: components["schemas"]["DocumentListItem"][];
|
||||
/** Format: int64 */
|
||||
totalElements: number;
|
||||
/** Format: int32 */
|
||||
pageNumber: number;
|
||||
/** Format: int32 */
|
||||
pageSize: number;
|
||||
/** Format: int32 */
|
||||
totalPages: number;
|
||||
/** Format: int64 */
|
||||
undatedCount: number;
|
||||
};
|
||||
MatchOffset: {
|
||||
/** Format: int32 */
|
||||
start: number;
|
||||
/** Format: int32 */
|
||||
length: number;
|
||||
};
|
||||
SearchMatchData: {
|
||||
transcriptionSnippet?: string;
|
||||
titleOffsets: components["schemas"]["MatchOffset"][];
|
||||
senderMatched: boolean;
|
||||
matchedReceiverIds: string[];
|
||||
matchedTagIds: string[];
|
||||
snippetOffsets: components["schemas"]["MatchOffset"][];
|
||||
summarySnippet?: string;
|
||||
summaryOffsets: components["schemas"]["MatchOffset"][];
|
||||
};
|
||||
CreateRelationshipRequest: {
|
||||
/** Format: uuid */
|
||||
relatedPersonId: string;
|
||||
@@ -2016,6 +1946,7 @@ export interface components {
|
||||
/** @enum {string} */
|
||||
status?: "DRAFT" | "PUBLISHED";
|
||||
personIds?: string[];
|
||||
documentIds?: string[];
|
||||
};
|
||||
Geschichte: {
|
||||
/** Format: uuid */
|
||||
@@ -2024,11 +1955,9 @@ export interface components {
|
||||
body?: string;
|
||||
/** @enum {string} */
|
||||
status: "DRAFT" | "PUBLISHED";
|
||||
/** @enum {string} */
|
||||
type: "STORY" | "JOURNEY";
|
||||
author?: components["schemas"]["AppUser"];
|
||||
persons?: components["schemas"]["Person"][];
|
||||
items?: components["schemas"]["JourneyItem"][];
|
||||
documents?: components["schemas"]["Document"][];
|
||||
/** Format: date-time */
|
||||
createdAt: string;
|
||||
/** Format: date-time */
|
||||
@@ -2036,32 +1965,6 @@ export interface components {
|
||||
/** Format: date-time */
|
||||
publishedAt?: string;
|
||||
};
|
||||
JourneyItem: {
|
||||
/** Format: uuid */
|
||||
id: string;
|
||||
/** Format: int32 */
|
||||
position: number;
|
||||
/** Format: uuid */
|
||||
documentId?: string;
|
||||
note?: string;
|
||||
};
|
||||
GeschichteSummary: {
|
||||
/** Format: uuid */
|
||||
id: string;
|
||||
title: string;
|
||||
/** @enum {string} */
|
||||
status: "DRAFT" | "PUBLISHED";
|
||||
/** @enum {string} */
|
||||
type: "STORY" | "JOURNEY";
|
||||
author?: {
|
||||
firstName?: string;
|
||||
lastName?: string;
|
||||
email: string;
|
||||
};
|
||||
body?: string;
|
||||
/** Format: date-time */
|
||||
publishedAt?: string;
|
||||
};
|
||||
CreateTranscriptionBlockDTO: {
|
||||
/** Format: int32 */
|
||||
pageNumber?: number;
|
||||
@@ -2300,6 +2203,11 @@ export interface components {
|
||||
/** Format: int64 */
|
||||
transcriptionCount: number;
|
||||
};
|
||||
ActivityActorDTO: {
|
||||
initials: string;
|
||||
color: string;
|
||||
name?: string;
|
||||
};
|
||||
TranscriptionQueueItemDTO: {
|
||||
/** Format: uuid */
|
||||
id: string;
|
||||
@@ -2322,11 +2230,6 @@ export interface components {
|
||||
color?: string;
|
||||
/** Format: int32 */
|
||||
documentCount: number;
|
||||
/**
|
||||
* Format: int32
|
||||
* @description Distinct documents tagged with this tag or any descendant tag (subtree rollup)
|
||||
*/
|
||||
subtreeDocumentCount: number;
|
||||
children?: components["schemas"]["TagTreeNodeDTO"][];
|
||||
/**
|
||||
* Format: uuid
|
||||
@@ -2467,6 +2370,8 @@ export interface components {
|
||||
/** Format: int32 */
|
||||
totalPages?: number;
|
||||
pageable?: components["schemas"]["PageableObject"];
|
||||
first?: boolean;
|
||||
last?: boolean;
|
||||
/** Format: int32 */
|
||||
size?: number;
|
||||
content?: components["schemas"]["NotificationDTO"][];
|
||||
@@ -2475,8 +2380,6 @@ export interface components {
|
||||
sort?: components["schemas"]["SortObject"];
|
||||
/** Format: int32 */
|
||||
numberOfElements?: number;
|
||||
first?: boolean;
|
||||
last?: boolean;
|
||||
empty?: boolean;
|
||||
};
|
||||
PageableObject: {
|
||||
@@ -2540,6 +2443,63 @@ export interface components {
|
||||
/** Format: int32 */
|
||||
totalPages?: number;
|
||||
};
|
||||
DocumentListItem: {
|
||||
/** Format: uuid */
|
||||
id: string;
|
||||
title: string;
|
||||
originalFilename: string;
|
||||
thumbnailUrl?: string;
|
||||
/** Format: date */
|
||||
documentDate?: string;
|
||||
/** @enum {string} */
|
||||
metaDatePrecision: "DAY" | "MONTH" | "SEASON" | "YEAR" | "RANGE" | "APPROX" | "UNKNOWN";
|
||||
/** Format: date */
|
||||
metaDateEnd?: string;
|
||||
sender?: components["schemas"]["Person"];
|
||||
receivers: components["schemas"]["Person"][];
|
||||
tags: components["schemas"]["Tag"][];
|
||||
archiveBox?: string;
|
||||
archiveFolder?: string;
|
||||
location?: string;
|
||||
summary?: string;
|
||||
/** Format: int32 */
|
||||
completionPercentage: number;
|
||||
contributors: components["schemas"]["ActivityActorDTO"][];
|
||||
matchData: components["schemas"]["SearchMatchData"];
|
||||
/** Format: date-time */
|
||||
createdAt: string;
|
||||
/** Format: date-time */
|
||||
updatedAt: string;
|
||||
};
|
||||
DocumentSearchResult: {
|
||||
items: components["schemas"]["DocumentListItem"][];
|
||||
/** Format: int64 */
|
||||
totalElements: number;
|
||||
/** Format: int32 */
|
||||
pageNumber: number;
|
||||
/** Format: int32 */
|
||||
pageSize: number;
|
||||
/** Format: int32 */
|
||||
totalPages: number;
|
||||
/** Format: int64 */
|
||||
undatedCount: number;
|
||||
};
|
||||
MatchOffset: {
|
||||
/** Format: int32 */
|
||||
start: number;
|
||||
/** Format: int32 */
|
||||
length: number;
|
||||
};
|
||||
SearchMatchData: {
|
||||
transcriptionSnippet?: string;
|
||||
titleOffsets: components["schemas"]["MatchOffset"][];
|
||||
senderMatched: boolean;
|
||||
matchedReceiverIds: string[];
|
||||
matchedTagIds: string[];
|
||||
snippetOffsets: components["schemas"]["MatchOffset"][];
|
||||
summarySnippet?: string;
|
||||
summaryOffsets: components["schemas"]["MatchOffset"][];
|
||||
};
|
||||
IncompleteDocumentDTO: {
|
||||
/** Format: uuid */
|
||||
id: string;
|
||||
@@ -3603,6 +3563,7 @@ export interface operations {
|
||||
query?: {
|
||||
status?: "DRAFT" | "PUBLISHED";
|
||||
personId?: string[];
|
||||
documentId?: string;
|
||||
limit?: number;
|
||||
};
|
||||
header?: never;
|
||||
@@ -3617,7 +3578,7 @@ export interface operations {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"*/*": components["schemas"]["GeschichteSummary"][];
|
||||
"*/*": components["schemas"]["Geschichte"][];
|
||||
};
|
||||
};
|
||||
};
|
||||
@@ -4144,26 +4105,6 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
backfillTitles: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"*/*": components["schemas"]["BackfillResult"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
backfillFileHashes: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -4512,7 +4453,7 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
search_1: {
|
||||
search: {
|
||||
parameters: {
|
||||
query?: {
|
||||
q?: string;
|
||||
@@ -5136,7 +5077,7 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
search_2: {
|
||||
search_1: {
|
||||
parameters: {
|
||||
query?: {
|
||||
q?: string;
|
||||
@@ -5306,6 +5247,32 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
getConversation: {
|
||||
parameters: {
|
||||
query: {
|
||||
senderId: string;
|
||||
receiverId?: string;
|
||||
from?: string;
|
||||
to?: string;
|
||||
dir?: string;
|
||||
};
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"*/*": components["schemas"]["Document"][];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
getResume: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
||||
@@ -46,6 +46,10 @@ export type ErrorCode =
|
||||
| 'CIRCULAR_RELATIONSHIP'
|
||||
| 'DUPLICATE_RELATIONSHIP'
|
||||
| 'GESCHICHTE_NOT_FOUND'
|
||||
| 'JOURNEY_ITEM_NOT_FOUND'
|
||||
| 'JOURNEY_ITEM_POSITION_CONFLICT'
|
||||
| 'JOURNEY_AT_CAPACITY'
|
||||
| 'GESCHICHTE_TYPE_MISMATCH'
|
||||
| 'INVALID_CREDENTIALS'
|
||||
| 'SESSION_EXPIRED'
|
||||
| 'MISSING_CREDENTIALS'
|
||||
@@ -164,6 +168,14 @@ export function getErrorMessage(code: ErrorCode | string | undefined): string {
|
||||
return m.error_duplicate_relationship();
|
||||
case 'GESCHICHTE_NOT_FOUND':
|
||||
return m.error_geschichte_not_found();
|
||||
case 'JOURNEY_ITEM_NOT_FOUND':
|
||||
return m.error_journey_item_not_found();
|
||||
case 'JOURNEY_ITEM_POSITION_CONFLICT':
|
||||
return m.error_journey_item_position_conflict();
|
||||
case 'JOURNEY_AT_CAPACITY':
|
||||
return m.error_journey_at_capacity();
|
||||
case 'GESCHICHTE_TYPE_MISMATCH':
|
||||
return m.error_geschichte_type_mismatch();
|
||||
case 'INVALID_CREDENTIALS':
|
||||
return m.error_invalid_credentials();
|
||||
case 'SESSION_EXPIRED':
|
||||
|
||||
Reference in New Issue
Block a user