feat(#221): tag entity hierarchy fields, service, repository, controller

- Tag entity: add parentId (UUID FK) and color (String) fields
- TagUpdateDTO and TagTreeNodeDTO records
- ErrorCode: INVALID_TAG_COLOR, TAG_CYCLE_DETECTED
- TagRepository: findAncestorIds() recursive CTE query
- TagService: cycle detection, color validation, getTagTree()
- TagController: use TagUpdateDTO, add GET /api/tags/tree endpoint

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-04-16 15:26:23 +02:00
parent f9ac963b9f
commit 3fba740469
9 changed files with 313 additions and 10 deletions

View File

@@ -1,6 +1,7 @@
package org.raddatz.familienarchiv.controller;
import org.junit.jupiter.api.Test;
import org.raddatz.familienarchiv.dto.TagTreeNodeDTO;
import org.raddatz.familienarchiv.model.Tag;
import org.raddatz.familienarchiv.security.PermissionAspect;
import org.raddatz.familienarchiv.service.CustomUserDetailsService;
@@ -19,10 +20,10 @@ import org.springframework.test.web.servlet.MockMvc;
import java.util.List;
import java.util.UUID;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.when;
import static org.mockito.ArgumentMatchers.any;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
@WebMvcTest(TagController.class)
@Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class})
@@ -82,6 +83,30 @@ class TagControllerTest {
.andExpect(status().isOk());
}
// ─── GET /api/tags/tree ───────────────────────────────────────────────────
@Test
void getTagTree_returns401_whenUnauthenticated() throws Exception {
mockMvc.perform(get("/api/tags/tree"))
.andExpect(status().isUnauthorized());
}
@Test
@WithMockUser
void getTagTree_returns200_withTreeStructure() throws Exception {
UUID parentId = UUID.randomUUID();
UUID childId = UUID.randomUUID();
TagTreeNodeDTO child = new TagTreeNodeDTO(childId, "Haus", null, 0, List.of());
TagTreeNodeDTO parent = new TagTreeNodeDTO(parentId, "Immobilie", "teal", 0, List.of(child));
when(tagService.getTagTree()).thenReturn(List.of(parent));
mockMvc.perform(get("/api/tags/tree"))
.andExpect(status().isOk())
.andExpect(jsonPath("$[0].name").value("Immobilie"))
.andExpect(jsonPath("$[0].color").value("teal"))
.andExpect(jsonPath("$[0].children[0].name").value("Haus"));
}
// ─── DELETE /api/tags/{id} ────────────────────────────────────────────────
@Test

View File

@@ -5,10 +5,14 @@ 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.dto.TagUpdateDTO;
import org.raddatz.familienarchiv.exception.DomainException;
import org.raddatz.familienarchiv.exception.ErrorCode;
import org.raddatz.familienarchiv.model.Tag;
import org.raddatz.familienarchiv.repository.TagRepository;
import org.springframework.web.server.ResponseStatusException;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
@@ -89,22 +93,146 @@ class TagServiceTest {
when(tagRepository.findById(id)).thenReturn(Optional.of(tag));
when(tagRepository.save(tag)).thenAnswer(inv -> inv.getArgument(0));
Tag result = tagService.update(id, "New");
Tag result = tagService.update(id, new TagUpdateDTO("New", null, null));
assertThat(result.getName()).isEqualTo("New");
}
@Test
void update_savesParentId() {
UUID id = UUID.randomUUID();
UUID parentId = UUID.randomUUID();
Tag tag = Tag.builder().id(id).name("Child").build();
Tag parent = Tag.builder().id(parentId).name("Parent").build();
when(tagRepository.findById(id)).thenReturn(Optional.of(tag));
when(tagRepository.findById(parentId)).thenReturn(Optional.of(parent));
when(tagRepository.findAncestorIds(parentId)).thenReturn(List.of());
when(tagRepository.save(tag)).thenAnswer(inv -> inv.getArgument(0));
Tag result = tagService.update(id, new TagUpdateDTO("Child", parentId, null));
assertThat(result.getParentId()).isEqualTo(parentId);
}
@Test
void update_savesColor() {
UUID id = UUID.randomUUID();
Tag tag = Tag.builder().id(id).name("Colored").build();
when(tagRepository.findById(id)).thenReturn(Optional.of(tag));
when(tagRepository.save(tag)).thenAnswer(inv -> inv.getArgument(0));
Tag result = tagService.update(id, new TagUpdateDTO("Colored", null, "sage"));
assertThat(result.getColor()).isEqualTo("sage");
}
@Test
void update_throwsNotFound_whenTagMissing() {
UUID id = UUID.randomUUID();
when(tagRepository.findById(id)).thenReturn(Optional.empty());
assertThatThrownBy(() -> tagService.update(id, "New"))
assertThatThrownBy(() -> tagService.update(id, new TagUpdateDTO("New", null, null)))
.isInstanceOf(ResponseStatusException.class)
.extracting(e -> ((ResponseStatusException) e).getStatusCode().value())
.isEqualTo(404);
}
// ─── color validation ─────────────────────────────────────────────────────
@Test
void update_throwsInvalidTagColor_whenColorNotInAllowedPalette() {
UUID id = UUID.randomUUID();
Tag tag = Tag.builder().id(id).name("Tag").build();
when(tagRepository.findById(id)).thenReturn(Optional.of(tag));
assertThatThrownBy(() -> tagService.update(id, new TagUpdateDTO("Tag", null, "hotpink")))
.isInstanceOf(DomainException.class)
.extracting(e -> ((DomainException) e).getCode())
.isEqualTo(ErrorCode.INVALID_TAG_COLOR);
}
@Test
void update_allowsNullColor() {
UUID id = UUID.randomUUID();
Tag tag = Tag.builder().id(id).name("Tag").color("sage").build();
when(tagRepository.findById(id)).thenReturn(Optional.of(tag));
when(tagRepository.save(tag)).thenAnswer(inv -> inv.getArgument(0));
Tag result = tagService.update(id, new TagUpdateDTO("Tag", null, null));
assertThat(result.getColor()).isNull();
}
// ─── cycle detection ─────────────────────────────────────────────────────
@Test
void update_throwsCycleDetected_whenTagIsAncestorOfProposedParent() {
UUID tagId = UUID.randomUUID();
UUID proposedParentId = UUID.randomUUID();
Tag tag = Tag.builder().id(tagId).name("Tag").build();
when(tagRepository.findById(tagId)).thenReturn(Optional.of(tag));
// tagId appears in the ancestor chain of proposedParentId → cycle
when(tagRepository.findAncestorIds(proposedParentId)).thenReturn(List.of(tagId));
assertThatThrownBy(() -> tagService.update(tagId, new TagUpdateDTO("Tag", proposedParentId, null)))
.isInstanceOf(DomainException.class)
.extracting(e -> ((DomainException) e).getCode())
.isEqualTo(ErrorCode.TAG_CYCLE_DETECTED);
}
@Test
void update_throwsCycleDetected_whenTagIsSameAsProposedParent() {
UUID tagId = UUID.randomUUID();
Tag tag = Tag.builder().id(tagId).name("Tag").build();
when(tagRepository.findById(tagId)).thenReturn(Optional.of(tag));
assertThatThrownBy(() -> tagService.update(tagId, new TagUpdateDTO("Tag", tagId, null)))
.isInstanceOf(DomainException.class)
.extracting(e -> ((DomainException) e).getCode())
.isEqualTo(ErrorCode.TAG_CYCLE_DETECTED);
}
// ─── getTagTree ───────────────────────────────────────────────────────────
@Test
void getTagTree_returnsEmptyList_whenNoTags() {
when(tagRepository.findAll()).thenReturn(List.of());
assertThat(tagService.getTagTree()).isEmpty();
}
@Test
void getTagTree_returnsFlatRootTags_whenNoParentRelationships() {
UUID idA = UUID.randomUUID();
UUID idB = UUID.randomUUID();
List<Tag> tags = List.of(
Tag.builder().id(idA).name("Alpha").build(),
Tag.builder().id(idB).name("Beta").build()
);
when(tagRepository.findAll()).thenReturn(tags);
var tree = tagService.getTagTree();
assertThat(tree).hasSize(2);
assertThat(tree).allSatisfy(node -> assertThat(node.children()).isEmpty());
}
@Test
void getTagTree_nestsChildrenUnderParent() {
UUID parentId = UUID.randomUUID();
UUID childId = UUID.randomUUID();
Tag parent = Tag.builder().id(parentId).name("Parent").build();
Tag child = Tag.builder().id(childId).name("Child").parentId(parentId).build();
when(tagRepository.findAll()).thenReturn(List.of(parent, child));
var tree = tagService.getTagTree();
assertThat(tree).hasSize(1);
assertThat(tree.get(0).id()).isEqualTo(parentId);
assertThat(tree.get(0).children()).hasSize(1);
assertThat(tree.get(0).children().get(0).id()).isEqualTo(childId);
}
// ─── delete ───────────────────────────────────────────────────────────────
@Test