diff --git a/core/src/main/java/com/loremind/application/campaigncontext/CampaignService.java b/core/src/main/java/com/loremind/application/campaigncontext/CampaignService.java index dc58d4b..6cc3cb9 100644 --- a/core/src/main/java/com/loremind/application/campaigncontext/CampaignService.java +++ b/core/src/main/java/com/loremind/application/campaigncontext/CampaignService.java @@ -1,8 +1,15 @@ package com.loremind.application.campaigncontext; +import com.loremind.domain.campaigncontext.Arc; import com.loremind.domain.campaigncontext.Campaign; +import com.loremind.domain.campaigncontext.Chapter; +import com.loremind.domain.campaigncontext.ports.ArcRepository; import com.loremind.domain.campaigncontext.ports.CampaignRepository; +import com.loremind.domain.campaigncontext.ports.ChapterRepository; +import com.loremind.domain.campaigncontext.ports.CharacterRepository; +import com.loremind.domain.campaigncontext.ports.SceneRepository; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; import java.util.List; import java.util.Optional; @@ -16,9 +23,22 @@ import java.util.Optional; public class CampaignService { private final CampaignRepository campaignRepository; + private final ArcRepository arcRepository; + private final ChapterRepository chapterRepository; + private final SceneRepository sceneRepository; + private final CharacterRepository characterRepository; - public CampaignService(CampaignRepository campaignRepository) { + public CampaignService( + CampaignRepository campaignRepository, + ArcRepository arcRepository, + ChapterRepository chapterRepository, + SceneRepository sceneRepository, + CharacterRepository characterRepository) { this.campaignRepository = campaignRepository; + this.arcRepository = arcRepository; + this.chapterRepository = chapterRepository; + this.sceneRepository = sceneRepository; + this.characterRepository = characterRepository; } /** @@ -30,6 +50,12 @@ public class CampaignService { */ public record CampaignData(String name, String description, String loreId, String gameSystemId) {} + /** + * Compte des entités qui seront supprimées en cascade si la campagne est effacée. + * Utilisé par l'UI pour afficher un récapitulatif dans le dialogue de confirmation. + */ + public record DeletionImpact(int arcs, int chapters, int scenes, int characters) {} + public Campaign createCampaign(CampaignData data) { Campaign campaign = Campaign.builder() .name(data.name()) @@ -71,7 +97,48 @@ public class CampaignService { return (id == null || id.isBlank()) ? null : id; } + /** + * Calcule l'impact d'une suppression en cascade : nombre d'arcs, chapitres, + * scènes et personnages qui disparaîtront avec la campagne. Utilisé par l'UI + * pour afficher "X arcs, Y chapitres, Z scènes seront supprimés". + */ + public DeletionImpact getDeletionImpact(String id) { + List arcs = arcRepository.findByCampaignId(id); + int chapterTotal = 0; + int sceneTotal = 0; + for (Arc arc : arcs) { + List chapters = chapterRepository.findByArcId(arc.getId()); + chapterTotal += chapters.size(); + for (Chapter chapter : chapters) { + sceneTotal += sceneRepository.findByChapterId(chapter.getId()).size(); + } + } + int characterTotal = characterRepository.findByCampaignId(id).size(); + return new DeletionImpact(arcs.size(), chapterTotal, sceneTotal, characterTotal); + } + + /** + * Supprime la campagne et toutes ses entités dépendantes (arcs → chapitres → + * scènes, plus les personnages). L'opération est transactionnelle : soit + * tout disparaît, soit rien ne change. Les FKs applicatives n'ayant pas + * de contrainte CASCADE au niveau DB, on orchestre la cascade ici. + */ + @Transactional public void deleteCampaign(String id) { + List arcs = arcRepository.findByCampaignId(id); + for (Arc arc : arcs) { + List chapters = chapterRepository.findByArcId(arc.getId()); + for (Chapter chapter : chapters) { + for (var scene : sceneRepository.findByChapterId(chapter.getId())) { + sceneRepository.deleteById(scene.getId()); + } + chapterRepository.deleteById(chapter.getId()); + } + arcRepository.deleteById(arc.getId()); + } + for (var character : characterRepository.findByCampaignId(id)) { + characterRepository.deleteById(character.getId()); + } campaignRepository.deleteById(id); } diff --git a/core/src/main/java/com/loremind/application/lorecontext/LoreNodeService.java b/core/src/main/java/com/loremind/application/lorecontext/LoreNodeService.java index b70ac37..b5f6be0 100644 --- a/core/src/main/java/com/loremind/application/lorecontext/LoreNodeService.java +++ b/core/src/main/java/com/loremind/application/lorecontext/LoreNodeService.java @@ -1,9 +1,13 @@ package com.loremind.application.lorecontext; import com.loremind.domain.lorecontext.LoreNode; +import com.loremind.domain.lorecontext.Page; import com.loremind.domain.lorecontext.ports.LoreNodeRepository; +import com.loremind.domain.lorecontext.ports.PageRepository; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import java.util.ArrayList; import java.util.List; import java.util.Optional; @@ -16,11 +20,20 @@ import java.util.Optional; public class LoreNodeService { private final LoreNodeRepository loreNodeRepository; + private final PageRepository pageRepository; - public LoreNodeService(LoreNodeRepository loreNodeRepository) { + public LoreNodeService(LoreNodeRepository loreNodeRepository, PageRepository pageRepository) { this.loreNodeRepository = loreNodeRepository; + this.pageRepository = pageRepository; } + /** + * Compte des entités qui seront supprimées en cascade si le dossier est effacé : + * le dossier lui-même n'est pas compté, seuls les descendants (sous-dossiers + * récursifs + pages de l'ensemble du sous-arbre). + */ + public record DeletionImpact(int folders, int pages) {} + /** * Crée un LoreNode (dossier) à partir d'un "objet changes" porteur des valeurs * souhaitées (pattern Parameter Object) : évite les signatures qui gonflent @@ -68,7 +81,64 @@ public class LoreNodeService { return loreNodeRepository.save(existing); } + /** + * Calcule l'impact d'une suppression en cascade : nombre de sous-dossiers + * (récursif, sans compter la racine) et de pages dans l'ensemble du sous-arbre. + */ + public DeletionImpact getDeletionImpact(String id) { + List descendants = collectDescendants(id); + int pageTotal = pageRepository.findByNodeId(id).size(); + for (LoreNode descendant : descendants) { + pageTotal += pageRepository.findByNodeId(descendant.getId()).size(); + } + return new DeletionImpact(descendants.size(), pageTotal); + } + + /** + * Supprime le dossier et tout son sous-arbre (sous-dossiers récursifs + pages). + * Suppression en profondeur d'abord (feuilles → racine) pour limiter les + * références orphelines en cours de transaction. Les FKs applicatives n'ayant + * pas de CASCADE en DB, on orchestre la descente ici. + */ + @Transactional public void deleteLoreNode(String id) { + List descendants = collectDescendants(id); + // Descendants retournés en ordre BFS (haut → bas) : on inverse pour + // supprimer les feuilles en premier, puis on finit par la racine. + for (int i = descendants.size() - 1; i >= 0; i--) { + String descendantId = descendants.get(i).getId(); + deletePagesOfNode(descendantId); + loreNodeRepository.deleteById(descendantId); + } + deletePagesOfNode(id); loreNodeRepository.deleteById(id); } + + private void deletePagesOfNode(String nodeId) { + for (Page page : pageRepository.findByNodeId(nodeId)) { + pageRepository.deleteById(page.getId()); + } + } + + /** + * Retourne tous les descendants (hors racine) d'un dossier, en ordre BFS. + * Parcours itératif pour éviter tout risque de débordement de pile sur + * une arborescence profonde malicieuse. + */ + private List collectDescendants(String rootId) { + List result = new ArrayList<>(); + List frontier = new ArrayList<>(); + frontier.add(rootId); + while (!frontier.isEmpty()) { + List nextFrontier = new ArrayList<>(); + for (String parentId : frontier) { + for (LoreNode child : loreNodeRepository.findByParentId(parentId)) { + result.add(child); + nextFrontier.add(child.getId()); + } + } + frontier = nextFrontier; + } + return result; + } } diff --git a/core/src/main/java/com/loremind/application/lorecontext/LoreService.java b/core/src/main/java/com/loremind/application/lorecontext/LoreService.java index 6144613..b4c8963 100644 --- a/core/src/main/java/com/loremind/application/lorecontext/LoreService.java +++ b/core/src/main/java/com/loremind/application/lorecontext/LoreService.java @@ -1,10 +1,17 @@ package com.loremind.application.lorecontext; +import com.loremind.domain.campaigncontext.Campaign; +import com.loremind.domain.campaigncontext.ports.CampaignRepository; import com.loremind.domain.lorecontext.Lore; +import com.loremind.domain.lorecontext.LoreNode; +import com.loremind.domain.lorecontext.Page; +import com.loremind.domain.lorecontext.Template; import com.loremind.domain.lorecontext.ports.LoreNodeRepository; import com.loremind.domain.lorecontext.ports.LoreRepository; import com.loremind.domain.lorecontext.ports.PageRepository; +import com.loremind.domain.lorecontext.ports.TemplateRepository; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; import java.util.List; import java.util.Optional; @@ -26,15 +33,28 @@ public class LoreService { private final LoreRepository loreRepository; private final LoreNodeRepository loreNodeRepository; private final PageRepository pageRepository; + private final TemplateRepository templateRepository; + private final CampaignRepository campaignRepository; public LoreService(LoreRepository loreRepository, LoreNodeRepository loreNodeRepository, - PageRepository pageRepository) { + PageRepository pageRepository, + TemplateRepository templateRepository, + CampaignRepository campaignRepository) { this.loreRepository = loreRepository; this.loreNodeRepository = loreNodeRepository; this.pageRepository = pageRepository; + this.templateRepository = templateRepository; + this.campaignRepository = campaignRepository; } + /** + * Compte des entités qui seront supprimées / détachées en cascade si le Lore + * est effacé. `detachedCampaigns` : campagnes qui perdront leur référence à + * ce Lore (leur loreId sera nullé) mais resteront présentes. + */ + public record DeletionImpact(int folders, int pages, int templates, int detachedCampaigns) {} + public Lore createLore(String name, String description) { Lore lore = Lore.builder() .name(name) @@ -76,14 +96,61 @@ public class LoreService { if (existingLore.isEmpty()) { throw new IllegalArgumentException("Lore non trouvé avec l'ID: " + id); } - + Lore lore = existingLore.get(); lore.setName(name); lore.setDescription(description); return loreRepository.save(lore); } + /** + * Calcule l'impact d'une suppression de Lore en cascade : dossiers + pages + * + templates supprimés, et campagnes qui seront détachées (loreId → null + * sans être supprimées, car une campagne peut vivre sans univers). + */ + public DeletionImpact getDeletionImpact(String id) { + int folders = (int) loreNodeRepository.countByLoreId(id); + int pages = (int) pageRepository.countByLoreId(id); + int templates = templateRepository.findByLoreId(id).size(); + int detached = countCampaignsReferencingLore(id); + return new DeletionImpact(folders, pages, templates, detached); + } + + /** + * Supprime le Lore et toutes ses entités dépendantes (dossiers, pages, templates). + * Les campagnes qui référençaient ce Lore sont conservées — leur loreId est + * mis à null (une campagne peut légitimement exister sans univers associé). + * Opération transactionnelle : atomique. + */ + @Transactional public void deleteLore(String id) { + // Pages d'abord : elles référencent nodeId ET loreId, on les supprime + // globalement via loreId pour éviter d'en rater une rattachée à un + // node orphelin (ne devrait pas arriver, mais ceinture+bretelles). + for (Page page : pageRepository.findByLoreId(id)) { + pageRepository.deleteById(page.getId()); + } + for (LoreNode node : loreNodeRepository.findByLoreId(id)) { + loreNodeRepository.deleteById(node.getId()); + } + for (Template template : templateRepository.findByLoreId(id)) { + templateRepository.deleteById(template.getId()); + } + // Détache les campagnes : on garde la campagne, on nulle juste la référence. + for (Campaign campaign : campaignRepository.findAll()) { + if (id.equals(campaign.getLoreId())) { + campaign.setLoreId(null); + campaignRepository.save(campaign); + } + } loreRepository.deleteById(id); } + + private int countCampaignsReferencingLore(String id) { + int count = 0; + for (Campaign campaign : campaignRepository.findAll()) { + if (id.equals(campaign.getLoreId())) count++; + } + return count; + } } diff --git a/core/src/main/java/com/loremind/infrastructure/web/controller/CampaignController.java b/core/src/main/java/com/loremind/infrastructure/web/controller/CampaignController.java index 962a72d..097cec7 100644 --- a/core/src/main/java/com/loremind/infrastructure/web/controller/CampaignController.java +++ b/core/src/main/java/com/loremind/infrastructure/web/controller/CampaignController.java @@ -74,4 +74,16 @@ public class CampaignController { campaignService.deleteCampaign(id); return ResponseEntity.noContent().build(); } + + /** + * Récapitulatif des entités qui seront supprimées en cascade : utilisé par + * l'UI pour afficher "X arcs, Y chapitres, Z scènes..." dans la confirmation. + */ + @GetMapping("/{id}/deletion-impact") + public ResponseEntity getDeletionImpact(@PathVariable String id) { + if (!campaignService.campaignExists(id)) { + return ResponseEntity.notFound().build(); + } + return ResponseEntity.ok(campaignService.getDeletionImpact(id)); + } } diff --git a/core/src/main/java/com/loremind/infrastructure/web/controller/LoreController.java b/core/src/main/java/com/loremind/infrastructure/web/controller/LoreController.java index 2c6cec2..90f60aa 100644 --- a/core/src/main/java/com/loremind/infrastructure/web/controller/LoreController.java +++ b/core/src/main/java/com/loremind/infrastructure/web/controller/LoreController.java @@ -69,4 +69,17 @@ public class LoreController { loreService.deleteLore(id); return ResponseEntity.noContent().build(); } + + /** + * Récapitulatif des entités qui seront supprimées / détachées en cascade. + * Utilisé par l'UI pour afficher "X dossiers, Y pages, Z templates, + * N campagne(s) détachée(s)" dans la confirmation. + */ + @GetMapping("/{id}/deletion-impact") + public ResponseEntity getDeletionImpact(@PathVariable String id) { + if (loreService.getLoreById(id).isEmpty()) { + return ResponseEntity.notFound().build(); + } + return ResponseEntity.ok(loreService.getDeletionImpact(id)); + } } diff --git a/core/src/main/java/com/loremind/infrastructure/web/controller/LoreNodeController.java b/core/src/main/java/com/loremind/infrastructure/web/controller/LoreNodeController.java index 4e59b11..4814d7f 100644 --- a/core/src/main/java/com/loremind/infrastructure/web/controller/LoreNodeController.java +++ b/core/src/main/java/com/loremind/infrastructure/web/controller/LoreNodeController.java @@ -97,4 +97,16 @@ public class LoreNodeController { loreNodeService.deleteLoreNode(id); return ResponseEntity.noContent().build(); } + + /** + * Récapitulatif des entités qui seront supprimées en cascade : utilisé par + * l'UI pour afficher "X sous-dossiers, Y pages..." dans la confirmation. + */ + @GetMapping("/{id}/deletion-impact") + public ResponseEntity getDeletionImpact(@PathVariable String id) { + if (loreNodeService.getLoreNodeById(id).isEmpty()) { + return ResponseEntity.notFound().build(); + } + return ResponseEntity.ok(loreNodeService.getDeletionImpact(id)); + } } diff --git a/core/src/test/java/com/loremind/application/campaigncontext/CampaignServiceTest.java b/core/src/test/java/com/loremind/application/campaigncontext/CampaignServiceTest.java index 677f5a5..1064f77 100644 --- a/core/src/test/java/com/loremind/application/campaigncontext/CampaignServiceTest.java +++ b/core/src/test/java/com/loremind/application/campaigncontext/CampaignServiceTest.java @@ -1,7 +1,15 @@ package com.loremind.application.campaigncontext; +import com.loremind.domain.campaigncontext.Arc; import com.loremind.domain.campaigncontext.Campaign; +import com.loremind.domain.campaigncontext.Chapter; +import com.loremind.domain.campaigncontext.Character; +import com.loremind.domain.campaigncontext.Scene; +import com.loremind.domain.campaigncontext.ports.ArcRepository; import com.loremind.domain.campaigncontext.ports.CampaignRepository; +import com.loremind.domain.campaigncontext.ports.ChapterRepository; +import com.loremind.domain.campaigncontext.ports.CharacterRepository; +import com.loremind.domain.campaigncontext.ports.SceneRepository; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -27,6 +35,14 @@ public class CampaignServiceTest { @Mock private CampaignRepository campaignRepository; + @Mock + private ArcRepository arcRepository; + @Mock + private ChapterRepository chapterRepository; + @Mock + private SceneRepository sceneRepository; + @Mock + private CharacterRepository characterRepository; @InjectMocks private CampaignService campaignService; @@ -196,15 +212,75 @@ public class CampaignServiceTest { } @Test - void testDeleteCampaign() { - // Arrange - doNothing().when(campaignRepository).deleteById("campaign-1"); - + void testDeleteCampaign_EmptyCampaign() { + // Arrange : aucune dépendance ; Mockito renvoie List.of() par défaut. // Act campaignService.deleteCampaign("campaign-1"); // Assert verify(campaignRepository, times(1)).deleteById("campaign-1"); + verify(arcRepository, never()).deleteById(anyString()); + verify(chapterRepository, never()).deleteById(anyString()); + verify(sceneRepository, never()).deleteById(anyString()); + verify(characterRepository, never()).deleteById(anyString()); + } + + @Test + void testDeleteCampaign_CascadesArcsChaptersScenes() { + // Arrange : campagne avec 1 arc → 1 chapitre → 2 scènes. + Arc arc = Arc.builder().id("arc-1").campaignId("campaign-1").name("Arc 1").build(); + Chapter chapter = Chapter.builder().id("chap-1").arcId("arc-1").name("Chap 1").build(); + Scene scene1 = Scene.builder().id("scene-1").chapterId("chap-1").name("Scene 1").build(); + Scene scene2 = Scene.builder().id("scene-2").chapterId("chap-1").name("Scene 2").build(); + + when(arcRepository.findByCampaignId("campaign-1")).thenReturn(List.of(arc)); + when(chapterRepository.findByArcId("arc-1")).thenReturn(List.of(chapter)); + when(sceneRepository.findByChapterId("chap-1")).thenReturn(List.of(scene1, scene2)); + + // Act + campaignService.deleteCampaign("campaign-1"); + + // Assert : tout disparaît, dans l'ordre feuilles → racine. + verify(sceneRepository).deleteById("scene-1"); + verify(sceneRepository).deleteById("scene-2"); + verify(chapterRepository).deleteById("chap-1"); + verify(arcRepository).deleteById("arc-1"); + verify(campaignRepository).deleteById("campaign-1"); + } + + @Test + void testDeleteCampaign_CascadesCharacters() { + Character pc = Character.builder().id("char-1").campaignId("campaign-1").name("Alric").build(); + when(characterRepository.findByCampaignId("campaign-1")).thenReturn(List.of(pc)); + + campaignService.deleteCampaign("campaign-1"); + + verify(characterRepository).deleteById("char-1"); + verify(campaignRepository).deleteById("campaign-1"); + } + + @Test + void testGetDeletionImpact() { + Arc arc = Arc.builder().id("arc-1").campaignId("campaign-1").name("Arc 1").build(); + Chapter c1 = Chapter.builder().id("chap-1").arcId("arc-1").name("C1").build(); + Chapter c2 = Chapter.builder().id("chap-2").arcId("arc-1").name("C2").build(); + Scene s1 = Scene.builder().id("s-1").chapterId("chap-1").name("S1").build(); + Scene s2 = Scene.builder().id("s-2").chapterId("chap-2").name("S2").build(); + Scene s3 = Scene.builder().id("s-3").chapterId("chap-2").name("S3").build(); + Character pc = Character.builder().id("char-1").campaignId("campaign-1").name("Alric").build(); + + when(arcRepository.findByCampaignId("campaign-1")).thenReturn(List.of(arc)); + when(chapterRepository.findByArcId("arc-1")).thenReturn(List.of(c1, c2)); + when(sceneRepository.findByChapterId("chap-1")).thenReturn(List.of(s1)); + when(sceneRepository.findByChapterId("chap-2")).thenReturn(List.of(s2, s3)); + when(characterRepository.findByCampaignId("campaign-1")).thenReturn(List.of(pc)); + + CampaignService.DeletionImpact impact = campaignService.getDeletionImpact("campaign-1"); + + assertEquals(1, impact.arcs()); + assertEquals(2, impact.chapters()); + assertEquals(3, impact.scenes()); + assertEquals(1, impact.characters()); } @Test diff --git a/core/src/test/java/com/loremind/application/lorecontext/LoreNodeServiceTest.java b/core/src/test/java/com/loremind/application/lorecontext/LoreNodeServiceTest.java index 138c147..9ce3535 100644 --- a/core/src/test/java/com/loremind/application/lorecontext/LoreNodeServiceTest.java +++ b/core/src/test/java/com/loremind/application/lorecontext/LoreNodeServiceTest.java @@ -1,7 +1,9 @@ package com.loremind.application.lorecontext; import com.loremind.domain.lorecontext.LoreNode; +import com.loremind.domain.lorecontext.Page; import com.loremind.domain.lorecontext.ports.LoreNodeRepository; +import com.loremind.domain.lorecontext.ports.PageRepository; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -15,6 +17,7 @@ import java.util.Optional; import static org.junit.jupiter.api.Assertions.*; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.*; /** @@ -26,6 +29,7 @@ import static org.mockito.Mockito.*; public class LoreNodeServiceTest { @Mock private LoreNodeRepository loreNodeRepository; + @Mock private PageRepository pageRepository; @InjectMocks private LoreNodeService loreNodeService; @@ -118,8 +122,66 @@ public class LoreNodeServiceTest { } @Test - void testDelete() { + void testDelete_LeafFolder() { + // Aucun descendant, aucune page : seul le dossier est supprimé. loreNodeService.deleteLoreNode("n-1"); verify(loreNodeRepository).deleteById("n-1"); + verify(pageRepository, never()).deleteById(anyString()); + } + + @Test + void testDelete_CascadesPagesOfRoot() { + Page p1 = Page.builder().id("p-1").nodeId("n-1").title("P1").build(); + Page p2 = Page.builder().id("p-2").nodeId("n-1").title("P2").build(); + when(pageRepository.findByNodeId("n-1")).thenReturn(List.of(p1, p2)); + + loreNodeService.deleteLoreNode("n-1"); + + verify(pageRepository).deleteById("p-1"); + verify(pageRepository).deleteById("p-2"); + verify(loreNodeRepository).deleteById("n-1"); + } + + @Test + void testDelete_CascadesSubfoldersRecursive() { + // n-1 → n-1a → n-1a1 ; chaque feuille a une page. + LoreNode mid = LoreNode.builder().id("n-1a").parentId("n-1").loreId("lore-1").name("mid").build(); + LoreNode leaf = LoreNode.builder().id("n-1a1").parentId("n-1a").loreId("lore-1").name("leaf").build(); + Page pageOnLeaf = Page.builder().id("p-leaf").nodeId("n-1a1").title("P").build(); + + when(loreNodeRepository.findByParentId("n-1")).thenReturn(List.of(mid)); + when(loreNodeRepository.findByParentId("n-1a")).thenReturn(List.of(leaf)); + when(pageRepository.findByNodeId("n-1a1")).thenReturn(List.of(pageOnLeaf)); + + loreNodeService.deleteLoreNode("n-1"); + + // Feuilles d'abord (pages puis dossier leaf), puis mid, puis la racine. + verify(pageRepository).deleteById("p-leaf"); + verify(loreNodeRepository).deleteById("n-1a1"); + verify(loreNodeRepository).deleteById("n-1a"); + verify(loreNodeRepository).deleteById("n-1"); + } + + @Test + void testGetDeletionImpact_CountsSubfoldersAndPages() { + LoreNode sub1 = LoreNode.builder().id("s-1").parentId("n-1").loreId("lore-1").name("s1").build(); + LoreNode sub2 = LoreNode.builder().id("s-2").parentId("n-1").loreId("lore-1").name("s2").build(); + LoreNode subsub = LoreNode.builder().id("s-1a").parentId("s-1").loreId("lore-1").name("s1a").build(); + Page p1 = Page.builder().id("p-1").nodeId("n-1").title("P1").build(); + Page p2 = Page.builder().id("p-2").nodeId("s-1").title("P2").build(); + Page p3 = Page.builder().id("p-3").nodeId("s-1a").title("P3").build(); + + when(loreNodeRepository.findByParentId("n-1")).thenReturn(List.of(sub1, sub2)); + when(loreNodeRepository.findByParentId("s-1")).thenReturn(List.of(subsub)); + when(pageRepository.findByNodeId("n-1")).thenReturn(List.of(p1)); + when(pageRepository.findByNodeId("s-1")).thenReturn(List.of(p2)); + when(pageRepository.findByNodeId("s-2")).thenReturn(List.of()); + when(pageRepository.findByNodeId("s-1a")).thenReturn(List.of(p3)); + + LoreNodeService.DeletionImpact impact = loreNodeService.getDeletionImpact("n-1"); + + // 3 sous-dossiers (sub1, sub2, subsub) — on ne compte pas la racine n-1. + assertEquals(3, impact.folders()); + assertEquals(3, impact.pages()); } } diff --git a/core/src/test/java/com/loremind/application/lorecontext/LoreServiceTest.java b/core/src/test/java/com/loremind/application/lorecontext/LoreServiceTest.java index 54171c1..93ede76 100644 --- a/core/src/test/java/com/loremind/application/lorecontext/LoreServiceTest.java +++ b/core/src/test/java/com/loremind/application/lorecontext/LoreServiceTest.java @@ -1,9 +1,15 @@ package com.loremind.application.lorecontext; +import com.loremind.domain.campaigncontext.Campaign; +import com.loremind.domain.campaigncontext.ports.CampaignRepository; import com.loremind.domain.lorecontext.Lore; +import com.loremind.domain.lorecontext.LoreNode; +import com.loremind.domain.lorecontext.Page; +import com.loremind.domain.lorecontext.Template; import com.loremind.domain.lorecontext.ports.LoreNodeRepository; import com.loremind.domain.lorecontext.ports.LoreRepository; import com.loremind.domain.lorecontext.ports.PageRepository; +import com.loremind.domain.lorecontext.ports.TemplateRepository; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -17,6 +23,7 @@ import java.util.Optional; import static org.junit.jupiter.api.Assertions.*; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.*; /** @@ -30,6 +37,8 @@ public class LoreServiceTest { @Mock private LoreRepository loreRepository; @Mock private LoreNodeRepository loreNodeRepository; @Mock private PageRepository pageRepository; + @Mock private TemplateRepository templateRepository; + @Mock private CampaignRepository campaignRepository; @InjectMocks private LoreService loreService; @@ -134,8 +143,67 @@ public class LoreServiceTest { } @Test - void testDeleteLore_DelegatesToRepository() { + void testDeleteLore_EmptyLore() { + // Aucun dossier / page / template / campagne : seul le Lore est supprimé. loreService.deleteLore("lore-1"); verify(loreRepository).deleteById("lore-1"); + verify(loreNodeRepository, never()).deleteById(anyString()); + verify(pageRepository, never()).deleteById(anyString()); + verify(templateRepository, never()).deleteById(anyString()); + } + + @Test + void testDeleteLore_CascadesFoldersPagesTemplates() { + LoreNode node = LoreNode.builder().id("n-1").loreId("lore-1").name("F").build(); + Page page = Page.builder().id("p-1").loreId("lore-1").nodeId("n-1").title("P").build(); + Template template = Template.builder().id("t-1").loreId("lore-1").name("T").build(); + + when(pageRepository.findByLoreId("lore-1")).thenReturn(List.of(page)); + when(loreNodeRepository.findByLoreId("lore-1")).thenReturn(List.of(node)); + when(templateRepository.findByLoreId("lore-1")).thenReturn(List.of(template)); + + loreService.deleteLore("lore-1"); + + verify(pageRepository).deleteById("p-1"); + verify(loreNodeRepository).deleteById("n-1"); + verify(templateRepository).deleteById("t-1"); + verify(loreRepository).deleteById("lore-1"); + } + + @Test + void testDeleteLore_DetachesCampaignsInsteadOfDeleting() { + Campaign attached = Campaign.builder().id("c-1").loreId("lore-1").name("C1").build(); + Campaign other = Campaign.builder().id("c-2").loreId("lore-other").name("C2").build(); + Campaign orphan = Campaign.builder().id("c-3").loreId(null).name("C3").build(); + when(campaignRepository.findAll()).thenReturn(List.of(attached, other, orphan)); + when(campaignRepository.save(any(Campaign.class))).thenAnswer(inv -> inv.getArgument(0)); + + loreService.deleteLore("lore-1"); + + // Seule la campagne attachée est re-sauvegardée (avec loreId=null). + ArgumentCaptor captor = ArgumentCaptor.forClass(Campaign.class); + verify(campaignRepository, times(1)).save(captor.capture()); + assertEquals("c-1", captor.getValue().getId()); + assertNull(captor.getValue().getLoreId()); + // Aucune campagne n'est supprimée. + verify(campaignRepository, never()).deleteById(anyString()); + } + + @Test + void testGetDeletionImpact() { + Template t1 = Template.builder().id("t-1").loreId("lore-1").name("T").build(); + Campaign attached = Campaign.builder().id("c-1").loreId("lore-1").name("C").build(); + Campaign unrelated = Campaign.builder().id("c-2").loreId("lore-other").name("C2").build(); + when(loreNodeRepository.countByLoreId("lore-1")).thenReturn(4L); + when(pageRepository.countByLoreId("lore-1")).thenReturn(12L); + when(templateRepository.findByLoreId("lore-1")).thenReturn(List.of(t1)); + when(campaignRepository.findAll()).thenReturn(List.of(attached, unrelated)); + + LoreService.DeletionImpact impact = loreService.getDeletionImpact("lore-1"); + + assertEquals(4, impact.folders()); + assertEquals(12, impact.pages()); + assertEquals(1, impact.templates()); + assertEquals(1, impact.detachedCampaigns()); } } diff --git a/web/src/app/campaigns/campaign-detail/campaign-detail.component.ts b/web/src/app/campaigns/campaign-detail/campaign-detail.component.ts index 52e2012..c094b33 100644 --- a/web/src/app/campaigns/campaign-detail/campaign-detail.component.ts +++ b/web/src/app/campaigns/campaign-detail/campaign-detail.component.ts @@ -257,22 +257,37 @@ export class CampaignDetailComponent implements OnInit, OnDestroy { } /** - * Suppression protégée : refus si la campagne contient des arcs. - * Les arcs contiennent potentiellement des chapitres/scènes construits longuement. + * Suppression en cascade : récupère d'abord le détail de ce qui sera effacé + * (arcs / chapitres / scènes / personnages), affiche le récapitulatif dans + * la confirmation, puis supprime. Le cascade est orchestré côté backend dans + * une seule transaction. */ deleteCampaign(): void { if (!this.campaign) return; - if (this.arcs.length > 0) { - alert( - `Impossible de supprimer "${this.campaign.name}" : elle contient encore ${this.arcs.length} arc(s).\n` + - `Videz la campagne (arcs et chapitres) avant de la supprimer.` - ); - return; - } - if (!confirm(`Supprimer définitivement la campagne "${this.campaign.name}" ?`)) return; - this.campaignService.deleteCampaign(this.campaign.id!).subscribe({ - next: () => this.router.navigate(['/campaigns']), - error: () => console.error('Erreur lors de la suppression de la campagne') + const campaign = this.campaign; + this.campaignService.getCampaignDeletionImpact(campaign.id!).subscribe({ + next: impact => { + const parts: string[] = []; + if (impact.arcs > 0) parts.push(`${impact.arcs} arc${impact.arcs > 1 ? 's' : ''}`); + if (impact.chapters > 0) parts.push(`${impact.chapters} chapitre${impact.chapters > 1 ? 's' : ''}`); + if (impact.scenes > 0) parts.push(`${impact.scenes} scène${impact.scenes > 1 ? 's' : ''}`); + if (impact.characters > 0) parts.push(`${impact.characters} personnage${impact.characters > 1 ? 's' : ''}`); + + const lines = [`Supprimer définitivement la campagne "${campaign.name}" ?`]; + if (parts.length) { + lines.push(''); + lines.push(`Cette action supprimera aussi : ${parts.join(', ')}.`); + } + lines.push(''); + lines.push('Cette action est irréversible.'); + + if (!confirm(lines.join('\n'))) return; + this.campaignService.deleteCampaign(campaign.id!).subscribe({ + next: () => this.router.navigate(['/campaigns']), + error: () => console.error('Erreur lors de la suppression de la campagne') + }); + }, + error: () => console.error('Impossible de récupérer les dépendances de la campagne') }); } diff --git a/web/src/app/lore/lore-detail/lore-detail.component.ts b/web/src/app/lore/lore-detail/lore-detail.component.ts index 3d686cc..83b6e27 100644 --- a/web/src/app/lore/lore-detail/lore-detail.component.ts +++ b/web/src/app/lore/lore-detail/lore-detail.component.ts @@ -110,24 +110,43 @@ export class LoreDetailComponent implements OnInit, OnDestroy { } /** - * Suppression protégée : refus si le Lore contient encore des dossiers - * ou des pages. Protège contre un clic accidentel sur des données - * construites longuement. Logique côté frontend (pas d'appel HTTP - * supplémentaire) car les données sont déjà chargées. + * Suppression en cascade : récupère le détail de ce qui tombera (dossiers, + * pages, templates) et de ce qui sera détaché (campagnes conservées mais + * sans lien vers ce Lore), affiche le récapitulatif dans la confirmation, + * puis délègue au backend (transaction atomique). */ deleteLore(): void { if (!this.lore) return; - if (this.allNodes.length > 0) { - alert( - `Impossible de supprimer "${this.lore.name}" : il contient encore ${this.allNodes.length} dossier(s).\n` + - `Videz le Lore (dossiers et pages) avant de le supprimer.` - ); - return; - } - if (!confirm(`Supprimer définitivement le Lore "${this.lore.name}" ?`)) return; - this.loreService.deleteLore(this.lore.id!).subscribe({ - next: () => this.router.navigate(['/lore']), - error: () => console.error('Erreur lors de la suppression du Lore') + const lore = this.lore; + this.loreService.getLoreDeletionImpact(lore.id!).subscribe({ + next: impact => { + const deleted: string[] = []; + if (impact.folders > 0) deleted.push(`${impact.folders} dossier${impact.folders > 1 ? 's' : ''}`); + if (impact.pages > 0) deleted.push(`${impact.pages} page${impact.pages > 1 ? 's' : ''}`); + if (impact.templates > 0) deleted.push(`${impact.templates} template${impact.templates > 1 ? 's' : ''}`); + + const lines = [`Supprimer définitivement le Lore "${lore.name}" ?`]; + if (deleted.length) { + lines.push(''); + lines.push(`Cette action supprimera aussi : ${deleted.join(', ')}.`); + } + if (impact.detachedCampaigns > 0) { + lines.push(''); + lines.push( + `${impact.detachedCampaigns} campagne${impact.detachedCampaigns > 1 ? 's' : ''} ${impact.detachedCampaigns > 1 ? 'seront conservées' : 'sera conservée'} ` + + `mais ${impact.detachedCampaigns > 1 ? 'perdront' : 'perdra'} leur lien vers cet univers.` + ); + } + lines.push(''); + lines.push('Cette action est irréversible.'); + + if (!confirm(lines.join('\n'))) return; + this.loreService.deleteLore(lore.id!).subscribe({ + next: () => this.router.navigate(['/lore']), + error: () => console.error('Erreur lors de la suppression du Lore') + }); + }, + error: () => console.error('Impossible de récupérer les dépendances du Lore') }); } diff --git a/web/src/app/lore/lore-node-edit/lore-node-edit.component.html b/web/src/app/lore/lore-node-edit/lore-node-edit.component.html index 7b5f27a..7ee8740 100644 --- a/web/src/app/lore/lore-node-edit/lore-node-edit.component.html +++ b/web/src/app/lore/lore-node-edit/lore-node-edit.component.html @@ -12,8 +12,7 @@ @@ -57,11 +56,6 @@ -
- ⚠️ Pour supprimer ce dossier, videz-le d'abord : déplacez ou supprimez ses - {{ childFolderCount }} sous-dossier(s) et ses {{ pageCount }} page(s). -
- diff --git a/web/src/app/lore/lore-node-edit/lore-node-edit.component.ts b/web/src/app/lore/lore-node-edit/lore-node-edit.component.ts index 31bc70d..0ed7b0b 100644 --- a/web/src/app/lore/lore-node-edit/lore-node-edit.component.ts +++ b/web/src/app/lore/lore-node-edit/lore-node-edit.component.ts @@ -134,16 +134,35 @@ export class LoreNodeEditComponent implements OnInit, OnDestroy { }); } - get canDelete(): boolean { - return this.childFolderCount === 0 && this.pageCount === 0; - } - + /** + * Suppression en cascade : on va chercher le compte exact de sous-dossiers et + * de pages (qui tombent avec le dossier), on l'annonce dans la confirmation, + * puis on délègue au backend — l'atomicité est garantie côté transaction. + */ delete(): void { - if (!this.canDelete || !this.node) return; - if (!confirm(`Supprimer le dossier "${this.node.name}" ?`)) return; - this.loreService.deleteLoreNode(this.folderId).subscribe({ - next: () => this.router.navigate(['/lore', this.loreId]), - error: () => console.error('Erreur lors de la suppression du dossier') + if (!this.node) return; + const node = this.node; + this.loreService.getLoreNodeDeletionImpact(this.folderId).subscribe({ + next: impact => { + const parts: string[] = []; + if (impact.folders > 0) parts.push(`${impact.folders} sous-dossier${impact.folders > 1 ? 's' : ''}`); + if (impact.pages > 0) parts.push(`${impact.pages} page${impact.pages > 1 ? 's' : ''}`); + + const lines = [`Supprimer le dossier "${node.name}" ?`]; + if (parts.length) { + lines.push(''); + lines.push(`Cette action supprimera aussi : ${parts.join(', ')}.`); + } + lines.push(''); + lines.push('Cette action est irréversible.'); + + if (!confirm(lines.join('\n'))) return; + this.loreService.deleteLoreNode(this.folderId).subscribe({ + next: () => this.router.navigate(['/lore', this.loreId]), + error: () => console.error('Erreur lors de la suppression du dossier') + }); + }, + error: () => console.error('Impossible de récupérer les dépendances du dossier') }); } diff --git a/web/src/app/services/campaign.service.ts b/web/src/app/services/campaign.service.ts index 35a828b..cb5b30b 100644 --- a/web/src/app/services/campaign.service.ts +++ b/web/src/app/services/campaign.service.ts @@ -3,6 +3,14 @@ import { HttpClient, HttpParams } from '@angular/common/http'; import { Observable } from 'rxjs'; import { Campaign, CampaignCreate, Arc, ArcCreate, Chapter, ChapterCreate, Scene, SceneCreate } from './campaign.model'; +/** Compte des entités qui seront supprimées en cascade avec la campagne. */ +export interface CampaignDeletionImpact { + arcs: number; + chapters: number; + scenes: number; + characters: number; +} + /** * Service HTTP pour la gestion des Campagnes. * Port de sortie vers le Backend Java (Architecture Hexagonale). @@ -35,6 +43,10 @@ export class CampaignService { return this.http.delete(`${this.apiUrl}/${id}`); } + getCampaignDeletionImpact(id: string): Observable { + return this.http.get(`${this.apiUrl}/${id}/deletion-impact`); + } + // ========== ARC ========== getArcs(campaignId: string): Observable { return this.http.get(`http://localhost:8080/api/arcs/campaign/${campaignId}`); diff --git a/web/src/app/services/lore.service.ts b/web/src/app/services/lore.service.ts index 6ef42c9..e9d85ad 100644 --- a/web/src/app/services/lore.service.ts +++ b/web/src/app/services/lore.service.ts @@ -3,6 +3,23 @@ import { HttpClient, HttpParams } from '@angular/common/http'; import { Observable } from 'rxjs'; import { Lore, LoreCreate, LoreNode, LoreNodeCreate } from './lore.model'; +/** Compte des entités qui seront supprimées en cascade avec un dossier. */ +export interface LoreNodeDeletionImpact { + /** Sous-dossiers (récursif, sans compter le dossier racine lui-même). */ + folders: number; + /** Pages dans l'ensemble du sous-arbre. */ + pages: number; +} + +/** Compte des entités qui seront supprimées / détachées en cascade avec un Lore. */ +export interface LoreDeletionImpact { + folders: number; + pages: number; + templates: number; + /** Campagnes qui perdront leur référence au Lore (mais resteront présentes). */ + detachedCampaigns: number; +} + /** * Service HTTP pour la gestion des Lores. * Port de sortie vers le Backend Java (Architecture Hexagonale). @@ -36,6 +53,10 @@ export class LoreService { return this.http.delete(`${this.apiUrl}/${id}`); } + getLoreDeletionImpact(id: string): Observable { + return this.http.get(`${this.apiUrl}/${id}/deletion-impact`); + } + getLoreNodes(loreId: string): Observable { return this.http.get(`${this.nodesUrl}?loreId=${loreId}`); } @@ -57,6 +78,10 @@ export class LoreService { return this.http.delete(`${this.nodesUrl}/${id}`); } + getLoreNodeDeletionImpact(id: string): Observable { + return this.http.get(`${this.nodesUrl}/${id}/deletion-impact`); + } + searchLores(q: string): Observable { const params = new HttpParams().set('q', q); return this.http.get(`${this.apiUrl}/search`, { params });