feat(#248): add TagService.mergeTags() with validateNotSelf/validateNotDescendant/transferDocuments helpers
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,5 @@
|
|||||||
|
package org.raddatz.familienarchiv.dto;
|
||||||
|
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
public record MergeTagDTO(UUID targetId) {}
|
||||||
@@ -21,9 +21,11 @@ import org.springframework.transaction.annotation.Transactional;
|
|||||||
import org.springframework.util.StringUtils;
|
import org.springframework.util.StringUtils;
|
||||||
|
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
|
@Slf4j
|
||||||
public class TagService {
|
public class TagService {
|
||||||
|
|
||||||
static final Set<String> ALLOWED_TAG_COLORS = Set.of(
|
static final Set<String> ALLOWED_TAG_COLORS = Set.of(
|
||||||
@@ -73,6 +75,29 @@ public class TagService {
|
|||||||
tagRepository.delete(getById(id));
|
tagRepository.delete(getById(id));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public Tag mergeTags(UUID sourceId, UUID targetId) {
|
||||||
|
log.info("Merging tag {} into {}", sourceId, targetId);
|
||||||
|
validateNotSelf(sourceId, targetId);
|
||||||
|
getById(sourceId);
|
||||||
|
Tag target = getById(targetId);
|
||||||
|
validateNotDescendant(sourceId, targetId);
|
||||||
|
transferDocuments(sourceId, targetId);
|
||||||
|
tagRepository.reparentChildren(sourceId, targetId);
|
||||||
|
tagRepository.deleteById(sourceId);
|
||||||
|
return target;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public void deleteWithDescendants(UUID id) {
|
||||||
|
log.info("Deleting subtree rooted at {}", id);
|
||||||
|
getById(id);
|
||||||
|
List<UUID> ids = tagRepository.findDescendantIds(id);
|
||||||
|
if (!ids.isEmpty()) tagRepository.deleteDocumentTagsByTagIds(ids);
|
||||||
|
tagRepository.deleteAllById(ids);
|
||||||
|
log.info("Deleted subtree rooted at {}, {} nodes", id, ids.size());
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sets the effective (inherited) color on child tags that have no color of their own.
|
* Sets the effective (inherited) color on child tags that have no color of their own.
|
||||||
* Colors are stored only on root-level tags; children inherit the parent's color.
|
* Colors are stored only on root-level tags; children inherit the parent's color.
|
||||||
@@ -126,6 +151,26 @@ public class TagService {
|
|||||||
|
|
||||||
// ─── private helpers ─────────────────────────────────────────────────────
|
// ─── private helpers ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private void validateNotSelf(UUID sourceId, UUID targetId) {
|
||||||
|
if (sourceId.equals(targetId)) {
|
||||||
|
throw DomainException.badRequest(ErrorCode.TAG_MERGE_SELF,
|
||||||
|
"Source and target must not be the same tag: " + sourceId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void validateNotDescendant(UUID sourceId, UUID targetId) {
|
||||||
|
List<UUID> descendants = tagRepository.findDescendantIds(sourceId);
|
||||||
|
if (descendants.contains(targetId)) {
|
||||||
|
throw DomainException.badRequest(ErrorCode.TAG_MERGE_INVALID_TARGET,
|
||||||
|
"Target " + targetId + " is a descendant of source " + sourceId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void transferDocuments(UUID sourceId, UUID targetId) {
|
||||||
|
tagRepository.reassignDocumentTags(sourceId, targetId);
|
||||||
|
tagRepository.deleteDocumentTagsByTagId(sourceId);
|
||||||
|
}
|
||||||
|
|
||||||
private void validateNoSelfReference(UUID tagId, UUID proposedParentId) {
|
private void validateNoSelfReference(UUID tagId, UUID proposedParentId) {
|
||||||
if (tagId.equals(proposedParentId)) {
|
if (tagId.equals(proposedParentId)) {
|
||||||
throw DomainException.badRequest(ErrorCode.TAG_CYCLE_DETECTED,
|
throw DomainException.badRequest(ErrorCode.TAG_CYCLE_DETECTED,
|
||||||
|
|||||||
@@ -302,6 +302,79 @@ class TagServiceTest {
|
|||||||
assertThat(child2.getColor()).isEqualTo("sienna");
|
assertThat(child2.getColor()).isEqualTo("sienna");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── mergeTags ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void mergeTags_throwsBadRequest_whenSelfMerge() {
|
||||||
|
UUID id = UUID.randomUUID();
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> tagService.mergeTags(id, id))
|
||||||
|
.isInstanceOf(DomainException.class)
|
||||||
|
.extracting(e -> ((DomainException) e).getCode())
|
||||||
|
.isEqualTo(ErrorCode.TAG_MERGE_SELF);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void mergeTags_throwsNotFound_whenSourceMissing() {
|
||||||
|
UUID sourceId = UUID.randomUUID();
|
||||||
|
UUID targetId = UUID.randomUUID();
|
||||||
|
when(tagRepository.findById(sourceId)).thenReturn(Optional.empty());
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> tagService.mergeTags(sourceId, targetId))
|
||||||
|
.isInstanceOf(DomainException.class)
|
||||||
|
.extracting(e -> ((DomainException) e).getCode())
|
||||||
|
.isEqualTo(ErrorCode.TAG_NOT_FOUND);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void mergeTags_throwsNotFound_whenTargetMissing() {
|
||||||
|
UUID sourceId = UUID.randomUUID();
|
||||||
|
UUID targetId = UUID.randomUUID();
|
||||||
|
Tag source = Tag.builder().id(sourceId).name("Source").build();
|
||||||
|
when(tagRepository.findById(sourceId)).thenReturn(Optional.of(source));
|
||||||
|
when(tagRepository.findById(targetId)).thenReturn(Optional.empty());
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> tagService.mergeTags(sourceId, targetId))
|
||||||
|
.isInstanceOf(DomainException.class)
|
||||||
|
.extracting(e -> ((DomainException) e).getCode())
|
||||||
|
.isEqualTo(ErrorCode.TAG_NOT_FOUND);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void mergeTags_throwsBadRequest_whenTargetIsDescendantOfSource() {
|
||||||
|
UUID sourceId = UUID.randomUUID();
|
||||||
|
UUID targetId = UUID.randomUUID();
|
||||||
|
Tag source = Tag.builder().id(sourceId).name("Parent").build();
|
||||||
|
Tag target = Tag.builder().id(targetId).name("Child").parentId(sourceId).build();
|
||||||
|
when(tagRepository.findById(sourceId)).thenReturn(Optional.of(source));
|
||||||
|
when(tagRepository.findById(targetId)).thenReturn(Optional.of(target));
|
||||||
|
when(tagRepository.findDescendantIds(sourceId)).thenReturn(List.of(sourceId, targetId));
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> tagService.mergeTags(sourceId, targetId))
|
||||||
|
.isInstanceOf(DomainException.class)
|
||||||
|
.extracting(e -> ((DomainException) e).getCode())
|
||||||
|
.isEqualTo(ErrorCode.TAG_MERGE_INVALID_TARGET);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void mergeTags_reassignsDocumentsReparentsChildrenAndDeletesSource() {
|
||||||
|
UUID sourceId = UUID.randomUUID();
|
||||||
|
UUID targetId = UUID.randomUUID();
|
||||||
|
Tag source = Tag.builder().id(sourceId).name("Source").build();
|
||||||
|
Tag target = Tag.builder().id(targetId).name("Target").build();
|
||||||
|
when(tagRepository.findById(sourceId)).thenReturn(Optional.of(source));
|
||||||
|
when(tagRepository.findById(targetId)).thenReturn(Optional.of(target));
|
||||||
|
when(tagRepository.findDescendantIds(sourceId)).thenReturn(List.of(sourceId));
|
||||||
|
|
||||||
|
Tag result = tagService.mergeTags(sourceId, targetId);
|
||||||
|
|
||||||
|
verify(tagRepository).reassignDocumentTags(sourceId, targetId);
|
||||||
|
verify(tagRepository).deleteDocumentTagsByTagId(sourceId);
|
||||||
|
verify(tagRepository).reparentChildren(sourceId, targetId);
|
||||||
|
verify(tagRepository).deleteById(sourceId);
|
||||||
|
assertThat(result).isEqualTo(target);
|
||||||
|
}
|
||||||
|
|
||||||
// ─── delete ───────────────────────────────────────────────────────────────
|
// ─── delete ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
Reference in New Issue
Block a user