diff --git a/core/pom.xml b/core/pom.xml index ec34442..45cfda9 100644 --- a/core/pom.xml +++ b/core/pom.xml @@ -99,6 +99,29 @@ + + + + org.jacoco + jacoco-maven-plugin + 0.8.12 + + + prepare-agent + + prepare-agent + + + + report + test + + report + + + + diff --git a/core/src/test/java/com/loremind/application/generationcontext/CampaignStructuralContextBuilderTest.java b/core/src/test/java/com/loremind/application/generationcontext/CampaignStructuralContextBuilderTest.java new file mode 100644 index 0000000..ade4664 --- /dev/null +++ b/core/src/test/java/com/loremind/application/generationcontext/CampaignStructuralContextBuilderTest.java @@ -0,0 +1,175 @@ +package com.loremind.application.generationcontext; + +import com.loremind.domain.campaigncontext.Arc; +import com.loremind.domain.campaigncontext.Campaign; +import com.loremind.domain.campaigncontext.Chapter; +import com.loremind.domain.campaigncontext.Scene; +import com.loremind.domain.campaigncontext.SceneBranch; +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.SceneRepository; +import com.loremind.domain.generationcontext.CampaignStructuralContext; +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 java.util.List; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +/** + * Test unitaire pour CampaignStructuralContextBuilder. + * Vérifie la projection Campaign Context → Generation Context (arcs → chapitres → scènes), + * le tri par `order`, la résolution des branches via la map id→nom, et le comptage + * null-safe des illustrations. + */ +@ExtendWith(MockitoExtension.class) +public class CampaignStructuralContextBuilderTest { + + @Mock + private CampaignRepository campaignRepository; + @Mock + private ArcRepository arcRepository; + @Mock + private ChapterRepository chapterRepository; + @Mock + private SceneRepository sceneRepository; + + @InjectMocks + private CampaignStructuralContextBuilder builder; + + private Campaign campaign; + + @BeforeEach + void setUp() { + campaign = Campaign.builder() + .id("camp-1") + .name("Les Terres Brisées") + .description("Campagne dark fantasy") + .build(); + } + + @Test + void testBuild_CampaignNotFound() { + when(campaignRepository.findById("missing")).thenReturn(Optional.empty()); + + IllegalArgumentException ex = assertThrows(IllegalArgumentException.class, + () -> builder.build("missing")); + assertTrue(ex.getMessage().contains("missing")); + } + + @Test + void testBuild_EmptyCampaign() { + when(campaignRepository.findById("camp-1")).thenReturn(Optional.of(campaign)); + when(arcRepository.findByCampaignId("camp-1")).thenReturn(List.of()); + + CampaignStructuralContext ctx = builder.build("camp-1"); + + assertEquals("Les Terres Brisées", ctx.getCampaignName()); + assertEquals("Campagne dark fantasy", ctx.getCampaignDescription()); + assertTrue(ctx.getArcs().isEmpty()); + } + + @Test + void testBuild_SortsArcsChaptersScenesByOrder() { + Arc arc1 = Arc.builder().id("arc-1").name("Arc A").description("first").order(1).build(); + Arc arc2 = Arc.builder().id("arc-2").name("Arc B").description("second").order(2).build(); + + Chapter ch1 = Chapter.builder().id("ch-1").arcId("arc-1").name("Ch A").description("d").order(2).build(); + Chapter ch2 = Chapter.builder().id("ch-2").arcId("arc-1").name("Ch B").description("d").order(1).build(); + + Scene s1 = Scene.builder().id("s-1").chapterId("ch-1").name("Scene A").description("d").order(2).build(); + Scene s2 = Scene.builder().id("s-2").chapterId("ch-1").name("Scene B").description("d").order(1).build(); + + when(campaignRepository.findById("camp-1")).thenReturn(Optional.of(campaign)); + // Volontairement inverse pour verifier le tri. + when(arcRepository.findByCampaignId("camp-1")).thenReturn(List.of(arc2, arc1)); + when(chapterRepository.findByArcId("arc-1")).thenReturn(List.of(ch1, ch2)); + when(chapterRepository.findByArcId("arc-2")).thenReturn(List.of()); + when(sceneRepository.findByChapterId("ch-1")).thenReturn(List.of(s1, s2)); + when(sceneRepository.findByChapterId("ch-2")).thenReturn(List.of()); + + CampaignStructuralContext ctx = builder.build("camp-1"); + + assertEquals(2, ctx.getArcs().size()); + assertEquals("Arc A", ctx.getArcs().get(0).getName()); + assertEquals("Arc B", ctx.getArcs().get(1).getName()); + + // Chapitres tries : ch2 (order 1) avant ch1 (order 2) + assertEquals(2, ctx.getArcs().get(0).getChapters().size()); + assertEquals("Ch B", ctx.getArcs().get(0).getChapters().get(0).getName()); + assertEquals("Ch A", ctx.getArcs().get(0).getChapters().get(1).getName()); + + // Scenes dans ch-1 : s2 (order 1) avant s1 (order 2) + var chADto = ctx.getArcs().get(0).getChapters().get(1); + assertEquals("Scene B", chADto.getScenes().get(0).getName()); + assertEquals("Scene A", chADto.getScenes().get(1).getName()); + } + + @Test + void testBuild_ResolvesBranchTargetSceneName() { + Arc arc = Arc.builder().id("arc-1").name("Arc").description("").order(1).build(); + Chapter ch = Chapter.builder().id("ch-1").arcId("arc-1").name("Ch").description("").order(1).build(); + + SceneBranch validBranch = SceneBranch.builder() + .label("Si les joueurs fuient") + .targetSceneId("s-2") + .condition("en cas de combat perdu") + .build(); + SceneBranch danglingBranch = SceneBranch.builder() + .label("Vers l'inconnu") + .targetSceneId("s-inconnu") + .build(); + + Scene s1 = Scene.builder().id("s-1").chapterId("ch-1").name("Entrée").description("") + .order(1) + .branches(List.of(validBranch, danglingBranch)) + .build(); + Scene s2 = Scene.builder().id("s-2").chapterId("ch-1").name("Fuite").description("").order(2).build(); + + when(campaignRepository.findById("camp-1")).thenReturn(Optional.of(campaign)); + when(arcRepository.findByCampaignId("camp-1")).thenReturn(List.of(arc)); + when(chapterRepository.findByArcId("arc-1")).thenReturn(List.of(ch)); + when(sceneRepository.findByChapterId("ch-1")).thenReturn(List.of(s1, s2)); + + CampaignStructuralContext ctx = builder.build("camp-1"); + + var scene1Summary = ctx.getArcs().get(0).getChapters().get(0).getScenes().get(0); + assertEquals(2, scene1Summary.getBranches().size()); + assertEquals("Fuite", scene1Summary.getBranches().get(0).getTargetSceneName()); + assertEquals("en cas de combat perdu", scene1Summary.getBranches().get(0).getCondition()); + // ID inconnu → libellé de fallback + assertEquals("(scène inconnue)", scene1Summary.getBranches().get(1).getTargetSceneName()); + } + + @Test + void testBuild_CountsIllustrationsNullSafe() { + Arc arc = Arc.builder().id("arc-1").name("Arc").description("").order(1) + .illustrationImageIds(List.of("img-1", "img-2")).build(); + Chapter ch = Chapter.builder().id("ch-1").arcId("arc-1").name("Ch").description("").order(1) + .illustrationImageIds(null) // null-safe attendu + .build(); + Scene s = Scene.builder().id("s-1").chapterId("ch-1").name("S").description("").order(1) + .illustrationImageIds(List.of("img-3")) + .branches(null) + .build(); + + when(campaignRepository.findById("camp-1")).thenReturn(Optional.of(campaign)); + when(arcRepository.findByCampaignId("camp-1")).thenReturn(List.of(arc)); + when(chapterRepository.findByArcId("arc-1")).thenReturn(List.of(ch)); + when(sceneRepository.findByChapterId("ch-1")).thenReturn(List.of(s)); + + CampaignStructuralContext ctx = builder.build("camp-1"); + + assertEquals(2, ctx.getArcs().get(0).getIllustrationCount()); + assertEquals(0, ctx.getArcs().get(0).getChapters().get(0).getIllustrationCount()); + assertEquals(1, ctx.getArcs().get(0).getChapters().get(0).getScenes().get(0).getIllustrationCount()); + assertTrue(ctx.getArcs().get(0).getChapters().get(0).getScenes().get(0).getBranches().isEmpty()); + } +} diff --git a/core/src/test/java/com/loremind/application/generationcontext/GeneratePageValuesUseCaseTest.java b/core/src/test/java/com/loremind/application/generationcontext/GeneratePageValuesUseCaseTest.java new file mode 100644 index 0000000..73704e3 --- /dev/null +++ b/core/src/test/java/com/loremind/application/generationcontext/GeneratePageValuesUseCaseTest.java @@ -0,0 +1,164 @@ +package com.loremind.application.generationcontext; + +import com.loremind.domain.generationcontext.GenerationContext; +import com.loremind.domain.generationcontext.GenerationResult; +import com.loremind.domain.generationcontext.ports.AiProvider; +import com.loremind.domain.lorecontext.FieldType; +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.TemplateField; +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; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +/** + * Test unitaire pour GeneratePageValuesUseCase. + * Couvre : le happy path (contexte IA correctement assemblé avec uniquement + * les champs TEXT), les erreurs d'intégrité (Page/Template/Lore/Folder + * introuvables), et la validation métier (template sans champ texte). + */ +@ExtendWith(MockitoExtension.class) +public class GeneratePageValuesUseCaseTest { + + @Mock private PageRepository pageRepository; + @Mock private TemplateRepository templateRepository; + @Mock private LoreRepository loreRepository; + @Mock private LoreNodeRepository loreNodeRepository; + @Mock private AiProvider aiProvider; + + @InjectMocks private GeneratePageValuesUseCase useCase; + + private Page page; + private Template template; + private Lore lore; + private LoreNode folder; + + @BeforeEach + void setUp() { + page = Page.builder() + .id("p-1").loreId("lore-1").nodeId("node-1").templateId("tpl-1") + .title("Alice") + .build(); + + template = Template.builder() + .id("tpl-1").loreId("lore-1").name("Personnage") + .fields(List.of( + TemplateField.text("Histoire"), + TemplateField.text("Apparence"), + TemplateField.image("Portrait"))) + .build(); + + lore = Lore.builder().id("lore-1").name("Aetheria").description("monde aérien").build(); + folder = LoreNode.builder().id("node-1").name("PNJ").loreId("lore-1").build(); + } + + @Test + void testExecute_HappyPath_OnlyTextFieldsSentToAi() { + when(pageRepository.findById("p-1")).thenReturn(Optional.of(page)); + when(templateRepository.findById("tpl-1")).thenReturn(Optional.of(template)); + when(loreRepository.findById("lore-1")).thenReturn(Optional.of(lore)); + when(loreNodeRepository.findById("node-1")).thenReturn(Optional.of(folder)); + + Map generated = Map.of( + "Histoire", "Alice est une...", + "Apparence", "Cheveux roux"); + when(aiProvider.generatePage(any())).thenReturn(new GenerationResult(generated)); + + Map result = useCase.execute("p-1"); + + assertEquals(generated, result); + + ArgumentCaptor captor = ArgumentCaptor.forClass(GenerationContext.class); + verify(aiProvider).generatePage(captor.capture()); + GenerationContext ctx = captor.getValue(); + + assertEquals("Aetheria", ctx.getLoreName()); + assertEquals("monde aérien", ctx.getLoreDescription()); + assertEquals("PNJ", ctx.getFolderName()); + assertEquals("Personnage", ctx.getTemplateName()); + assertEquals("Alice", ctx.getPageTitle()); + // Seuls les champs TEXT doivent etre envoyes (pas "Portrait" IMAGE). + assertEquals(List.of("Histoire", "Apparence"), ctx.getTemplateFields()); + } + + @Test + void testExecute_PageNotFound() { + when(pageRepository.findById("missing")).thenReturn(Optional.empty()); + + assertThrows(IllegalArgumentException.class, () -> useCase.execute("missing")); + verifyNoInteractions(aiProvider); + } + + @Test + void testExecute_PageWithoutTemplateId() { + Page orphan = Page.builder().id("p-1").loreId("lore-1").nodeId("node-1") + .templateId(null).title("Orphan").build(); + when(pageRepository.findById("p-1")).thenReturn(Optional.of(orphan)); + + IllegalStateException ex = assertThrows(IllegalStateException.class, + () -> useCase.execute("p-1")); + assertTrue(ex.getMessage().contains("template")); + } + + @Test + void testExecute_TemplateNotFound() { + when(pageRepository.findById("p-1")).thenReturn(Optional.of(page)); + when(templateRepository.findById("tpl-1")).thenReturn(Optional.empty()); + + assertThrows(IllegalStateException.class, () -> useCase.execute("p-1")); + } + + @Test + void testExecute_LoreNotFound() { + when(pageRepository.findById("p-1")).thenReturn(Optional.of(page)); + when(templateRepository.findById("tpl-1")).thenReturn(Optional.of(template)); + when(loreRepository.findById("lore-1")).thenReturn(Optional.empty()); + + assertThrows(IllegalStateException.class, () -> useCase.execute("p-1")); + } + + @Test + void testExecute_FolderNotFound() { + when(pageRepository.findById("p-1")).thenReturn(Optional.of(page)); + when(templateRepository.findById("tpl-1")).thenReturn(Optional.of(template)); + when(loreRepository.findById("lore-1")).thenReturn(Optional.of(lore)); + when(loreNodeRepository.findById("node-1")).thenReturn(Optional.empty()); + + assertThrows(IllegalStateException.class, () -> useCase.execute("p-1")); + } + + @Test + void testExecute_TemplateWithoutTextFields() { + Template imageOnly = Template.builder() + .id("tpl-1").loreId("lore-1").name("Galerie") + .fields(List.of(new TemplateField("Portrait", FieldType.IMAGE))) + .build(); + + when(pageRepository.findById("p-1")).thenReturn(Optional.of(page)); + when(templateRepository.findById("tpl-1")).thenReturn(Optional.of(imageOnly)); + when(loreRepository.findById("lore-1")).thenReturn(Optional.of(lore)); + when(loreNodeRepository.findById("node-1")).thenReturn(Optional.of(folder)); + + IllegalStateException ex = assertThrows(IllegalStateException.class, + () -> useCase.execute("p-1")); + assertTrue(ex.getMessage().contains("Galerie")); + verifyNoInteractions(aiProvider); + } +} diff --git a/core/src/test/java/com/loremind/application/generationcontext/LoreStructuralContextBuilderTest.java b/core/src/test/java/com/loremind/application/generationcontext/LoreStructuralContextBuilderTest.java new file mode 100644 index 0000000..3891464 --- /dev/null +++ b/core/src/test/java/com/loremind/application/generationcontext/LoreStructuralContextBuilderTest.java @@ -0,0 +1,193 @@ +package com.loremind.application.generationcontext; + +import com.loremind.domain.generationcontext.LoreStructuralContext; +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; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +/** + * Test unitaire pour LoreStructuralContextBuilder. + * Couvre la projection LoreContext → GenerationContext : construction du + * dossier→pages, résolution template/relatedPages, troncature des valeurs, + * filtrage des valeurs vides, et extraction unique des tags. + */ +@ExtendWith(MockitoExtension.class) +public class LoreStructuralContextBuilderTest { + + @Mock private LoreRepository loreRepository; + @Mock private LoreNodeRepository loreNodeRepository; + @Mock private PageRepository pageRepository; + @Mock private TemplateRepository templateRepository; + + @InjectMocks private LoreStructuralContextBuilder builder; + + private Lore lore; + + @BeforeEach + void setUp() { + lore = Lore.builder().id("lore-1").name("Aetheria").description("Monde aérien").build(); + } + + @Test + void testBuild_LoreNotFound_ThrowsOnStrict() { + when(loreRepository.findById("missing")).thenReturn(Optional.empty()); + + assertThrows(IllegalArgumentException.class, () -> builder.build("missing")); + } + + @Test + void testBuildOptional_LoreNotFound_ReturnsEmpty() { + when(loreRepository.findById("missing")).thenReturn(Optional.empty()); + + assertTrue(builder.buildOptional("missing").isEmpty()); + } + + @Test + void testBuild_EmptyLore() { + when(loreRepository.findById("lore-1")).thenReturn(Optional.of(lore)); + when(loreNodeRepository.findByLoreId("lore-1")).thenReturn(List.of()); + when(pageRepository.findByLoreId("lore-1")).thenReturn(List.of()); + when(templateRepository.findByLoreId("lore-1")).thenReturn(List.of()); + + LoreStructuralContext ctx = builder.build("lore-1"); + + assertEquals("Aetheria", ctx.getLoreName()); + assertEquals("Monde aérien", ctx.getLoreDescription()); + assertTrue(ctx.getFolders().isEmpty()); + assertTrue(ctx.getTags().isEmpty()); + } + + @Test + void testBuild_FoldersAndPagesMapping() { + LoreNode nodePnj = LoreNode.builder().id("n-1").name("PNJ").loreId("lore-1").build(); + LoreNode nodeLieux = LoreNode.builder().id("n-2").name("Lieux").loreId("lore-1").build(); + + Template tpl = Template.builder().id("tpl-1").name("Personnage").build(); + + Map values = new LinkedHashMap<>(); + values.put("Histoire", "Il était une fois..."); + values.put("VideField", " "); // blank → filtré + values.put("NullField", null); // null → filtré + + Page p1 = Page.builder() + .id("p-1").loreId("lore-1").nodeId("n-1") + .templateId("tpl-1").title("Alice") + .values(values) + .tags(List.of("hero", "magic")) + .relatedPageIds(List.of("p-2", "p-ghost")) + .build(); + Page p2 = Page.builder() + .id("p-2").loreId("lore-1").nodeId("n-2") + .templateId("tpl-missing").title("La Forêt") + .values(Map.of()) + .tags(List.of("magic")) + .build(); + + when(loreRepository.findById("lore-1")).thenReturn(Optional.of(lore)); + when(loreNodeRepository.findByLoreId("lore-1")).thenReturn(List.of(nodePnj, nodeLieux)); + when(pageRepository.findByLoreId("lore-1")).thenReturn(List.of(p1, p2)); + when(templateRepository.findByLoreId("lore-1")).thenReturn(List.of(tpl)); + + LoreStructuralContext ctx = builder.build("lore-1"); + + assertEquals(2, ctx.getFolders().size()); + assertTrue(ctx.getFolders().containsKey("PNJ")); + assertTrue(ctx.getFolders().containsKey("Lieux")); + + var pnjPages = ctx.getFolders().get("PNJ"); + assertEquals(1, pnjPages.size()); + var aliceSummary = pnjPages.get(0); + assertEquals("Alice", aliceSummary.getTitle()); + assertEquals("Personnage", aliceSummary.getTemplateName()); + // Blank/null filtrés + assertEquals(1, aliceSummary.getValues().size()); + assertEquals("Il était une fois...", aliceSummary.getValues().get("Histoire")); + assertEquals(List.of("hero", "magic"), aliceSummary.getTags()); + // p-2 resolved into title, p-ghost dropped silently + assertEquals(List.of("La Forêt"), aliceSummary.getRelatedPageTitles()); + + var forestSummary = ctx.getFolders().get("Lieux").get(0); + // Template introuvable → "?" + assertEquals("?", forestSummary.getTemplateName()); + assertTrue(forestSummary.getValues().isEmpty()); + assertTrue(forestSummary.getRelatedPageTitles().isEmpty()); + + // Tags uniques entre les 2 pages + assertEquals(2, ctx.getTags().size()); + assertTrue(ctx.getTags().contains("hero")); + assertTrue(ctx.getTags().contains("magic")); + } + + @Test + void testBuild_TruncatesLongValues() { + LoreNode node = LoreNode.builder().id("n-1").name("PNJ").build(); + Template tpl = Template.builder().id("tpl-1").name("Personnage").build(); + + String longText = "a".repeat(600); // au-dessus du plafond 500 + Map values = new HashMap<>(); + values.put("Histoire", longText); + + Page p = Page.builder() + .id("p-1").loreId("lore-1").nodeId("n-1") + .templateId("tpl-1").title("Alice") + .values(values) + .build(); + + when(loreRepository.findById("lore-1")).thenReturn(Optional.of(lore)); + when(loreNodeRepository.findByLoreId("lore-1")).thenReturn(List.of(node)); + when(pageRepository.findByLoreId("lore-1")).thenReturn(List.of(p)); + when(templateRepository.findByLoreId("lore-1")).thenReturn(List.of(tpl)); + + LoreStructuralContext ctx = builder.build("lore-1"); + + String truncated = ctx.getFolders().get("PNJ").get(0).getValues().get("Histoire"); + assertNotNull(truncated); + assertEquals(500 + 1, truncated.length()); // 500 + ellipse + assertTrue(truncated.endsWith("…")); + } + + @Test + void testBuild_HandlesNullValuesAndTags() { + LoreNode node = LoreNode.builder().id("n-1").name("PNJ").build(); + + Page p = Page.builder() + .id("p-1").loreId("lore-1").nodeId("n-1") + .templateId(null).title("Alice") + .values(null) + .tags(null) + .relatedPageIds(null) + .build(); + + when(loreRepository.findById("lore-1")).thenReturn(Optional.of(lore)); + when(loreNodeRepository.findByLoreId("lore-1")).thenReturn(List.of(node)); + when(pageRepository.findByLoreId("lore-1")).thenReturn(List.of(p)); + when(templateRepository.findByLoreId("lore-1")).thenReturn(List.of()); + + LoreStructuralContext ctx = builder.build("lore-1"); + + var summary = ctx.getFolders().get("PNJ").get(0); + assertTrue(summary.getValues().isEmpty()); + assertTrue(summary.getTags().isEmpty()); + assertTrue(summary.getRelatedPageTitles().isEmpty()); + } +} diff --git a/core/src/test/java/com/loremind/application/generationcontext/NarrativeEntityContextBuilderTest.java b/core/src/test/java/com/loremind/application/generationcontext/NarrativeEntityContextBuilderTest.java new file mode 100644 index 0000000..eecf691 --- /dev/null +++ b/core/src/test/java/com/loremind/application/generationcontext/NarrativeEntityContextBuilderTest.java @@ -0,0 +1,130 @@ +package com.loremind.application.generationcontext; + +import com.loremind.domain.campaigncontext.Arc; +import com.loremind.domain.campaigncontext.Chapter; +import com.loremind.domain.campaigncontext.Scene; +import com.loremind.domain.campaigncontext.ports.ArcRepository; +import com.loremind.domain.campaigncontext.ports.ChapterRepository; +import com.loremind.domain.campaigncontext.ports.SceneRepository; +import com.loremind.domain.generationcontext.NarrativeEntityContext; +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.Optional; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +/** + * Test unitaire pour NarrativeEntityContextBuilder. + * Vérifie la projection Arc/Chapter/Scene → NarrativeEntityContext pour + * les 3 types, la normalisation du type (casse/whitespace), la gestion des + * champs null (remplacés par ""), et les erreurs (type inconnu, entité absente). + */ +@ExtendWith(MockitoExtension.class) +public class NarrativeEntityContextBuilderTest { + + @Mock private ArcRepository arcRepository; + @Mock private ChapterRepository chapterRepository; + @Mock private SceneRepository sceneRepository; + + @InjectMocks private NarrativeEntityContextBuilder builder; + + @Test + void testBuild_Arc() { + Arc arc = Arc.builder() + .id("arc-1").name("L'arc sombre").description("synopsis") + .themes("trahison").stakes("vie ou mort").rewards("pouvoir") + .resolution("le roi meurt").gmNotes("secret") + .build(); + when(arcRepository.findById("arc-1")).thenReturn(Optional.of(arc)); + + NarrativeEntityContext ctx = builder.build("arc", "arc-1"); + + assertEquals("arc", ctx.getEntityType()); + assertEquals("L'arc sombre", ctx.getTitle()); + assertEquals("synopsis", ctx.getFields().get("description (synopsis)")); + assertEquals("trahison", ctx.getFields().get("themes")); + assertEquals("vie ou mort", ctx.getFields().get("stakes")); + assertEquals("pouvoir", ctx.getFields().get("rewards")); + assertEquals("le roi meurt", ctx.getFields().get("resolution")); + assertEquals("secret", ctx.getFields().get("gmNotes")); + } + + @Test + void testBuild_Chapter_WithNullFieldsReplacedByEmptyString() { + Chapter ch = Chapter.builder() + .id("ch-1").name("Chapitre 1").description(null) + .playerObjectives(null).narrativeStakes("haut").gmNotes(null) + .build(); + when(chapterRepository.findById("ch-1")).thenReturn(Optional.of(ch)); + + NarrativeEntityContext ctx = builder.build("chapter", "ch-1"); + + assertEquals("chapter", ctx.getEntityType()); + assertEquals("Chapitre 1", ctx.getTitle()); + assertEquals("", ctx.getFields().get("description (synopsis)")); + assertEquals("", ctx.getFields().get("playerObjectives")); + assertEquals("haut", ctx.getFields().get("narrativeStakes")); + assertEquals("", ctx.getFields().get("gmNotes")); + } + + @Test + void testBuild_Scene_AllFieldsMapped() { + Scene sc = Scene.builder() + .id("s-1").name("L'auberge").description("lieu calme") + .location("Taverne").timing("Soir").atmosphere("tendue") + .playerNarration("Vous entrez...").choicesConsequences("option A...") + .combatDifficulty("moyen").enemies("3 bandits") + .gmSecretNotes("trésor caché") + .build(); + when(sceneRepository.findById("s-1")).thenReturn(Optional.of(sc)); + + NarrativeEntityContext ctx = builder.build("scene", "s-1"); + + assertEquals("scene", ctx.getEntityType()); + assertEquals("L'auberge", ctx.getTitle()); + assertEquals("lieu calme", ctx.getFields().get("description")); + assertEquals("Taverne", ctx.getFields().get("location")); + assertEquals("Soir", ctx.getFields().get("timing")); + assertEquals("tendue", ctx.getFields().get("atmosphere")); + assertEquals("Vous entrez...", ctx.getFields().get("playerNarration")); + assertEquals("option A...", ctx.getFields().get("choicesConsequences")); + assertEquals("moyen", ctx.getFields().get("combatDifficulty")); + assertEquals("3 bandits", ctx.getFields().get("enemies")); + assertEquals("trésor caché", ctx.getFields().get("gmSecretNotes")); + } + + @Test + void testBuild_NormalizesTypeCaseAndWhitespace() { + Arc arc = Arc.builder().id("arc-1").name("A").build(); + when(arcRepository.findById("arc-1")).thenReturn(Optional.of(arc)); + + NarrativeEntityContext ctx = builder.build(" ARC ", "arc-1"); + assertEquals("arc", ctx.getEntityType()); + } + + @Test + void testBuild_UnknownTypeThrows() { + IllegalArgumentException ex = assertThrows(IllegalArgumentException.class, + () -> builder.build("npc", "id")); + assertTrue(ex.getMessage().contains("npc")); + } + + @Test + void testBuild_NullTypeThrows() { + assertThrows(IllegalArgumentException.class, () -> builder.build(null, "id")); + } + + @Test + void testBuild_EntityNotFound() { + when(sceneRepository.findById("missing")).thenReturn(Optional.empty()); + + IllegalArgumentException ex = assertThrows(IllegalArgumentException.class, + () -> builder.build("scene", "missing")); + assertTrue(ex.getMessage().contains("missing")); + } +} diff --git a/core/src/test/java/com/loremind/application/generationcontext/StreamChatForCampaignUseCaseTest.java b/core/src/test/java/com/loremind/application/generationcontext/StreamChatForCampaignUseCaseTest.java new file mode 100644 index 0000000..c3fc097 --- /dev/null +++ b/core/src/test/java/com/loremind/application/generationcontext/StreamChatForCampaignUseCaseTest.java @@ -0,0 +1,156 @@ +package com.loremind.application.generationcontext; + +import com.loremind.domain.campaigncontext.Campaign; +import com.loremind.domain.campaigncontext.ports.CampaignRepository; +import com.loremind.domain.generationcontext.CampaignStructuralContext; +import com.loremind.domain.generationcontext.ChatMessage; +import com.loremind.domain.generationcontext.ChatRequest; +import com.loremind.domain.generationcontext.LoreStructuralContext; +import com.loremind.domain.generationcontext.NarrativeEntityContext; +import com.loremind.domain.generationcontext.ports.AiChatProvider; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.function.Consumer; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +/** + * Test unitaire pour StreamChatForCampaignUseCase. + * Vérifie l'orchestration : chargement Campaign, chargement optionnel du + * Lore lié (avec tolérance d'un Lore supprimé), chargement optionnel de + * l'entité narrative focus, et délégation au port AiChatProvider avec la + * ChatRequest correcte. + */ +@ExtendWith(MockitoExtension.class) +public class StreamChatForCampaignUseCaseTest { + + @Mock private CampaignRepository campaignRepository; + @Mock private CampaignStructuralContextBuilder campaignContextBuilder; + @Mock private LoreStructuralContextBuilder loreContextBuilder; + @Mock private NarrativeEntityContextBuilder narrativeEntityContextBuilder; + @Mock private AiChatProvider aiChatProvider; + + @InjectMocks private StreamChatForCampaignUseCase useCase; + + private CampaignStructuralContext campaignCtx; + private List messages; + private Consumer onToken; + private Runnable onComplete; + private Consumer onError; + + @SuppressWarnings("unchecked") + @BeforeEach + void setUp() { + campaignCtx = CampaignStructuralContext.builder() + .campaignName("X").campaignDescription("d") + .build(); + messages = List.of(); + onToken = mock(Consumer.class); + onComplete = mock(Runnable.class); + onError = mock(Consumer.class); + } + + @Test + void testExecute_CampaignNotFound_Throws() { + when(campaignRepository.findById("missing")).thenReturn(Optional.empty()); + + assertThrows(IllegalArgumentException.class, + () -> useCase.execute("missing", null, null, messages, onToken, onComplete, onError)); + verifyNoInteractions(aiChatProvider); + } + + @Test + void testExecute_StandaloneCampaign_NoLoreNoEntity() { + Campaign standalone = Campaign.builder().id("c-1").name("C").loreId(null).build(); + when(campaignRepository.findById("c-1")).thenReturn(Optional.of(standalone)); + when(campaignContextBuilder.build("c-1")).thenReturn(campaignCtx); + + useCase.execute("c-1", null, null, messages, onToken, onComplete, onError); + + ArgumentCaptor captor = ArgumentCaptor.forClass(ChatRequest.class); + verify(aiChatProvider).streamChat(captor.capture(), eq(onToken), eq(onComplete), eq(onError)); + ChatRequest req = captor.getValue(); + assertSame(campaignCtx, req.getCampaignContext()); + assertNull(req.getLoreContext()); + assertNull(req.getNarrativeEntity()); + assertNull(req.getPageContext()); + verifyNoInteractions(loreContextBuilder); + verifyNoInteractions(narrativeEntityContextBuilder); + } + + @Test + void testExecute_LinkedCampaign_LoadsLoreContext() { + Campaign linked = Campaign.builder().id("c-1").name("C").loreId("lore-1").build(); + LoreStructuralContext loreCtx = LoreStructuralContext.builder() + .loreName("L").loreDescription("d").folders(Collections.emptyMap()).build(); + + when(campaignRepository.findById("c-1")).thenReturn(Optional.of(linked)); + when(campaignContextBuilder.build("c-1")).thenReturn(campaignCtx); + when(loreContextBuilder.buildOptional("lore-1")).thenReturn(Optional.of(loreCtx)); + + useCase.execute("c-1", null, null, messages, onToken, onComplete, onError); + + ArgumentCaptor captor = ArgumentCaptor.forClass(ChatRequest.class); + verify(aiChatProvider).streamChat(captor.capture(), any(), any(), any()); + assertSame(loreCtx, captor.getValue().getLoreContext()); + } + + @Test + void testExecute_LinkedCampaignButLoreDeleted_ContinuesWithNullLore() { + Campaign linked = Campaign.builder().id("c-1").name("C").loreId("lore-ghost").build(); + + when(campaignRepository.findById("c-1")).thenReturn(Optional.of(linked)); + when(campaignContextBuilder.build("c-1")).thenReturn(campaignCtx); + when(loreContextBuilder.buildOptional("lore-ghost")).thenReturn(Optional.empty()); + + useCase.execute("c-1", null, null, messages, onToken, onComplete, onError); + + ArgumentCaptor captor = ArgumentCaptor.forClass(ChatRequest.class); + verify(aiChatProvider).streamChat(captor.capture(), any(), any(), any()); + assertNull(captor.getValue().getLoreContext()); + // La requete doit tout de meme partir (pas d'exception). + } + + @Test + void testExecute_WithEntityFocus_BuildsNarrativeEntity() { + Campaign standalone = Campaign.builder().id("c-1").name("C").loreId(null).build(); + NarrativeEntityContext entity = NarrativeEntityContext.builder() + .entityType("scene").title("L'auberge").fields(Map.of()).build(); + + when(campaignRepository.findById("c-1")).thenReturn(Optional.of(standalone)); + when(campaignContextBuilder.build("c-1")).thenReturn(campaignCtx); + when(narrativeEntityContextBuilder.build("scene", "s-1")).thenReturn(entity); + + useCase.execute("c-1", "scene", "s-1", messages, onToken, onComplete, onError); + + ArgumentCaptor captor = ArgumentCaptor.forClass(ChatRequest.class); + verify(aiChatProvider).streamChat(captor.capture(), any(), any(), any()); + assertSame(entity, captor.getValue().getNarrativeEntity()); + } + + @Test + void testExecute_BlankEntityTypeOrId_NoNarrativeEntityLoaded() { + Campaign standalone = Campaign.builder().id("c-1").name("C").loreId(null).build(); + when(campaignRepository.findById("c-1")).thenReturn(Optional.of(standalone)); + when(campaignContextBuilder.build("c-1")).thenReturn(campaignCtx); + + useCase.execute("c-1", "scene", " ", messages, onToken, onComplete, onError); + + ArgumentCaptor captor = ArgumentCaptor.forClass(ChatRequest.class); + verify(aiChatProvider).streamChat(captor.capture(), any(), any(), any()); + assertNull(captor.getValue().getNarrativeEntity()); + verifyNoInteractions(narrativeEntityContextBuilder); + } +} diff --git a/core/src/test/java/com/loremind/application/generationcontext/StreamChatForLoreUseCaseTest.java b/core/src/test/java/com/loremind/application/generationcontext/StreamChatForLoreUseCaseTest.java new file mode 100644 index 0000000..dfc97cb --- /dev/null +++ b/core/src/test/java/com/loremind/application/generationcontext/StreamChatForLoreUseCaseTest.java @@ -0,0 +1,174 @@ +package com.loremind.application.generationcontext; + +import com.loremind.domain.generationcontext.ChatMessage; +import com.loremind.domain.generationcontext.ChatRequest; +import com.loremind.domain.generationcontext.LoreStructuralContext; +import com.loremind.domain.generationcontext.ports.AiChatProvider; +import com.loremind.domain.lorecontext.FieldType; +import com.loremind.domain.lorecontext.Page; +import com.loremind.domain.lorecontext.Template; +import com.loremind.domain.lorecontext.TemplateField; +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; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.function.Consumer; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +/** + * Test unitaire pour StreamChatForLoreUseCase. + * Vérifie l'orchestration : chargement du LoreStructuralContext obligatoire, + * construction conditionnelle du PageContext (sans / avec page / page sans + * template), et délégation au port AiChatProvider avec la bonne ChatRequest. + */ +@ExtendWith(MockitoExtension.class) +public class StreamChatForLoreUseCaseTest { + + @Mock private LoreStructuralContextBuilder loreContextBuilder; + @Mock private PageRepository pageRepository; + @Mock private TemplateRepository templateRepository; + @Mock private AiChatProvider aiChatProvider; + + @InjectMocks private StreamChatForLoreUseCase useCase; + + private LoreStructuralContext loreCtx; + private List messages; + private Consumer onToken; + private Runnable onComplete; + private Consumer onError; + + @SuppressWarnings("unchecked") + @BeforeEach + void setUp() { + loreCtx = LoreStructuralContext.builder() + .loreName("Aetheria").loreDescription("d") + .folders(Collections.emptyMap()) + .build(); + messages = List.of(); + onToken = mock(Consumer.class); + onComplete = mock(Runnable.class); + onError = mock(Consumer.class); + } + + @Test + void testExecute_NoPageId_SendsRequestWithoutPageContext() { + when(loreContextBuilder.build("lore-1")).thenReturn(loreCtx); + + useCase.execute("lore-1", null, messages, onToken, onComplete, onError); + + ArgumentCaptor captor = ArgumentCaptor.forClass(ChatRequest.class); + verify(aiChatProvider).streamChat(captor.capture(), eq(onToken), eq(onComplete), eq(onError)); + ChatRequest req = captor.getValue(); + assertSame(loreCtx, req.getLoreContext()); + assertNull(req.getPageContext()); + assertNull(req.getCampaignContext()); + } + + @Test + void testExecute_BlankPageId_TreatedAsNoPage() { + when(loreContextBuilder.build("lore-1")).thenReturn(loreCtx); + + useCase.execute("lore-1", " ", messages, onToken, onComplete, onError); + + ArgumentCaptor captor = ArgumentCaptor.forClass(ChatRequest.class); + verify(aiChatProvider).streamChat(captor.capture(), any(), any(), any()); + assertNull(captor.getValue().getPageContext()); + verifyNoInteractions(pageRepository); + } + + @Test + void testExecute_WithPageAndTemplate_BuildsPageContext() { + Template tpl = Template.builder() + .id("tpl-1").name("Personnage") + .fields(List.of( + TemplateField.text("Histoire"), + new TemplateField("Portrait", FieldType.IMAGE))) + .build(); + Map values = Map.of("Histoire", "..."); + Page page = Page.builder() + .id("p-1").loreId("lore-1").nodeId("n-1") + .templateId("tpl-1").title("Alice") + .values(values) + .build(); + + when(loreContextBuilder.build("lore-1")).thenReturn(loreCtx); + when(pageRepository.findById("p-1")).thenReturn(Optional.of(page)); + when(templateRepository.findById("tpl-1")).thenReturn(Optional.of(tpl)); + + useCase.execute("lore-1", "p-1", messages, onToken, onComplete, onError); + + ArgumentCaptor captor = ArgumentCaptor.forClass(ChatRequest.class); + verify(aiChatProvider).streamChat(captor.capture(), any(), any(), any()); + ChatRequest req = captor.getValue(); + assertNotNull(req.getPageContext()); + assertEquals("Alice", req.getPageContext().getTitle()); + assertEquals("Personnage", req.getPageContext().getTemplateName()); + // Seuls les champs TEXT exposes + assertEquals(List.of("Histoire"), req.getPageContext().getTemplateFields()); + assertEquals(values, req.getPageContext().getValues()); + } + + @Test + void testExecute_PageWithoutTemplate_FallbackContext() { + Page page = Page.builder() + .id("p-1").loreId("lore-1").nodeId("n-1") + .templateId(null).title("Orphan").values(null) + .build(); + when(loreContextBuilder.build("lore-1")).thenReturn(loreCtx); + when(pageRepository.findById("p-1")).thenReturn(Optional.of(page)); + + useCase.execute("lore-1", "p-1", messages, onToken, onComplete, onError); + + ArgumentCaptor captor = ArgumentCaptor.forClass(ChatRequest.class); + verify(aiChatProvider).streamChat(captor.capture(), any(), any(), any()); + var pageCtx = captor.getValue().getPageContext(); + assertNotNull(pageCtx); + assertEquals("Orphan", pageCtx.getTitle()); + assertEquals("?", pageCtx.getTemplateName()); + assertTrue(pageCtx.getTemplateFields().isEmpty()); + assertTrue(pageCtx.getValues().isEmpty()); + verifyNoInteractions(templateRepository); + } + + @Test + void testExecute_PageWithTemplateIdButTemplateMissing_FallbackToQuestionMark() { + Page page = Page.builder() + .id("p-1").loreId("lore-1").nodeId("n-1") + .templateId("tpl-ghost").title("Alice").values(Map.of("k", "v")) + .build(); + when(loreContextBuilder.build("lore-1")).thenReturn(loreCtx); + when(pageRepository.findById("p-1")).thenReturn(Optional.of(page)); + when(templateRepository.findById("tpl-ghost")).thenReturn(Optional.empty()); + + useCase.execute("lore-1", "p-1", messages, onToken, onComplete, onError); + + ArgumentCaptor captor = ArgumentCaptor.forClass(ChatRequest.class); + verify(aiChatProvider).streamChat(captor.capture(), any(), any(), any()); + var pageCtx = captor.getValue().getPageContext(); + assertEquals("?", pageCtx.getTemplateName()); + assertTrue(pageCtx.getTemplateFields().isEmpty()); + } + + @Test + void testExecute_PageNotFound_Throws() { + when(loreContextBuilder.build("lore-1")).thenReturn(loreCtx); + when(pageRepository.findById("missing")).thenReturn(Optional.empty()); + + assertThrows(IllegalArgumentException.class, + () -> useCase.execute("lore-1", "missing", messages, onToken, onComplete, onError)); + verifyNoInteractions(aiChatProvider); + } +} diff --git a/core/src/test/java/com/loremind/application/images/ImageServiceTest.java b/core/src/test/java/com/loremind/application/images/ImageServiceTest.java new file mode 100644 index 0000000..6c3ca74 --- /dev/null +++ b/core/src/test/java/com/loremind/application/images/ImageServiceTest.java @@ -0,0 +1,198 @@ +package com.loremind.application.images; + +import com.loremind.domain.images.Image; +import com.loremind.domain.images.ports.ImageRepository; +import com.loremind.domain.images.ports.ImageStorage; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.io.ByteArrayInputStream; +import java.io.InputStream; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; + +/** + * Test unitaire pour ImageService. + * Couvre : validation upload (filename/MIME/size), happy path upload, compensation + * en cas d'échec DB après upload MinIO réussi, download et delete. + */ +@ExtendWith(MockitoExtension.class) +public class ImageServiceTest { + + @Mock private ImageRepository imageRepository; + @Mock private ImageStorage imageStorage; + + @InjectMocks private ImageService imageService; + + private InputStream data; + + @BeforeEach + void setUp() { + data = new ByteArrayInputStream(new byte[]{1, 2, 3}); + } + + @Test + void testUpload_HappyPath_PersistsMetadata() { + when(imageStorage.upload(eq("portrait.jpg"), eq("image/jpeg"), any(), eq(1024L))) + .thenReturn("images/abc.jpg"); + when(imageRepository.save(any(Image.class))).thenAnswer(inv -> { + Image i = inv.getArgument(0); + i.setId("img-1"); + return i; + }); + + Image result = imageService.upload("portrait.jpg", "image/jpeg", data, 1024L); + + assertEquals("img-1", result.getId()); + assertEquals("images/abc.jpg", result.getStorageKey()); + assertNotNull(result.getUploadedAt()); + + ArgumentCaptor captor = ArgumentCaptor.forClass(Image.class); + verify(imageRepository).save(captor.capture()); + Image saved = captor.getValue(); + assertEquals("portrait.jpg", saved.getFilename()); + assertEquals("image/jpeg", saved.getContentType()); + assertEquals(1024L, saved.getSizeBytes()); + } + + @Test + void testUpload_NormalizesContentTypeCase() { + when(imageStorage.upload(anyString(), anyString(), any(), anyLong())).thenReturn("k"); + when(imageRepository.save(any(Image.class))).thenAnswer(inv -> inv.getArgument(0)); + + // MIME en majuscules doit etre accepte (normalisation en lowercase lors de la validation) + assertDoesNotThrow(() -> imageService.upload("a.png", "IMAGE/PNG", data, 100L)); + } + + @Test + void testUpload_DbFailure_CompensatesByDeletingBinary() { + when(imageStorage.upload(anyString(), anyString(), any(), anyLong())) + .thenReturn("images/orphan.jpg"); + when(imageRepository.save(any(Image.class))).thenThrow(new RuntimeException("DB down")); + + RuntimeException ex = assertThrows(RuntimeException.class, + () -> imageService.upload("a.jpg", "image/jpeg", data, 500L)); + assertEquals("DB down", ex.getMessage()); + // Compensation : suppression du binaire orphelin + verify(imageStorage).delete("images/orphan.jpg"); + } + + @Test + void testUpload_BlankFilename_Throws() { + assertThrows(IllegalArgumentException.class, + () -> imageService.upload(" ", "image/jpeg", data, 100L)); + verifyNoInteractions(imageStorage); + verifyNoInteractions(imageRepository); + } + + @Test + void testUpload_NullFilename_Throws() { + assertThrows(IllegalArgumentException.class, + () -> imageService.upload(null, "image/jpeg", data, 100L)); + } + + @Test + void testUpload_UnsupportedMime_Throws() { + assertThrows(IllegalArgumentException.class, + () -> imageService.upload("a.pdf", "application/pdf", data, 100L)); + verifyNoInteractions(imageStorage); + } + + @Test + void testUpload_NullMime_Throws() { + assertThrows(IllegalArgumentException.class, + () -> imageService.upload("a.jpg", null, data, 100L)); + } + + @Test + void testUpload_ZeroSize_Throws() { + assertThrows(IllegalArgumentException.class, + () -> imageService.upload("a.jpg", "image/jpeg", data, 0L)); + } + + @Test + void testUpload_NegativeSize_Throws() { + assertThrows(IllegalArgumentException.class, + () -> imageService.upload("a.jpg", "image/jpeg", data, -1L)); + } + + @Test + void testUpload_TooLarge_Throws() { + long tooBig = 10L * 1024 * 1024 + 1; + assertThrows(IllegalArgumentException.class, + () -> imageService.upload("a.jpg", "image/jpeg", data, tooBig)); + verifyNoInteractions(imageStorage); + } + + @Test + void testUpload_ExactMaxSize_Accepted() { + long max = 10L * 1024 * 1024; + when(imageStorage.upload(anyString(), anyString(), any(), eq(max))).thenReturn("k"); + when(imageRepository.save(any(Image.class))).thenAnswer(inv -> inv.getArgument(0)); + + assertDoesNotThrow(() -> imageService.upload("a.jpg", "image/jpeg", data, max)); + } + + @Test + void testGetById_DelegatesToRepository() { + Image img = Image.builder().id("img-1").build(); + when(imageRepository.findById("img-1")).thenReturn(Optional.of(img)); + + assertEquals(Optional.of(img), imageService.getById("img-1")); + } + + @Test + void testDownloadById_FoundReturnsStream() { + Image img = Image.builder().id("img-1").storageKey("images/k.jpg").build(); + InputStream stream = new ByteArrayInputStream(new byte[]{9}); + when(imageRepository.findById("img-1")).thenReturn(Optional.of(img)); + when(imageStorage.download("images/k.jpg")).thenReturn(stream); + + Optional result = imageService.downloadById("img-1"); + + assertTrue(result.isPresent()); + assertSame(stream, result.get()); + } + + @Test + void testDownloadById_NotFoundReturnsEmpty() { + when(imageRepository.findById("missing")).thenReturn(Optional.empty()); + + assertTrue(imageService.downloadById("missing").isEmpty()); + verifyNoInteractions(imageStorage); + } + + @Test + void testDeleteById_RemovesBinaryThenMetadata() { + Image img = Image.builder().id("img-1").storageKey("images/k.jpg").build(); + when(imageRepository.findById("img-1")).thenReturn(Optional.of(img)); + + imageService.deleteById("img-1"); + + // Ordre important : binaire d'abord, metadata ensuite. + var order = inOrder(imageStorage, imageRepository); + order.verify(imageStorage).delete("images/k.jpg"); + order.verify(imageRepository).deleteById("img-1"); + } + + @Test + void testDeleteById_NotFound_NoOp() { + when(imageRepository.findById("missing")).thenReturn(Optional.empty()); + + imageService.deleteById("missing"); + + verifyNoInteractions(imageStorage); + verify(imageRepository, never()).deleteById(anyString()); + } +} diff --git a/core/src/test/java/com/loremind/application/lorecontext/LoreNodeServiceTest.java b/core/src/test/java/com/loremind/application/lorecontext/LoreNodeServiceTest.java new file mode 100644 index 0000000..138c147 --- /dev/null +++ b/core/src/test/java/com/loremind/application/lorecontext/LoreNodeServiceTest.java @@ -0,0 +1,125 @@ +package com.loremind.application.lorecontext; + +import com.loremind.domain.lorecontext.LoreNode; +import com.loremind.domain.lorecontext.ports.LoreNodeRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.List; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +/** + * Test unitaire pour LoreNodeService. + * Vérifie le CRUD, le pattern Parameter Object pour update/create, et + * l'immuabilité de loreId en update. + */ +@ExtendWith(MockitoExtension.class) +public class LoreNodeServiceTest { + + @Mock private LoreNodeRepository loreNodeRepository; + + @InjectMocks private LoreNodeService loreNodeService; + + private LoreNode existing; + + @BeforeEach + void setUp() { + existing = LoreNode.builder() + .id("n-1").name("PNJ").icon("users") + .parentId(null).loreId("lore-1") + .build(); + } + + @Test + void testCreateLoreNode_CopiesChanges() { + LoreNode changes = LoreNode.builder() + .name("Lieux").icon("map-pin").parentId("n-parent").loreId("lore-1") + .build(); + when(loreNodeRepository.save(any(LoreNode.class))).thenAnswer(inv -> inv.getArgument(0)); + + loreNodeService.createLoreNode(changes); + + ArgumentCaptor captor = ArgumentCaptor.forClass(LoreNode.class); + verify(loreNodeRepository).save(captor.capture()); + LoreNode saved = captor.getValue(); + assertEquals("Lieux", saved.getName()); + assertEquals("map-pin", saved.getIcon()); + assertEquals("n-parent", saved.getParentId()); + assertEquals("lore-1", saved.getLoreId()); + } + + @Test + void testGetLoreNodeById() { + when(loreNodeRepository.findById("n-1")).thenReturn(Optional.of(existing)); + + assertTrue(loreNodeService.getLoreNodeById("n-1").isPresent()); + } + + @Test + void testGetAll_AndByLoreId_AndByParentId() { + when(loreNodeRepository.findAll()).thenReturn(List.of(existing)); + when(loreNodeRepository.findByLoreId("lore-1")).thenReturn(List.of(existing)); + when(loreNodeRepository.findByParentId("n-parent")).thenReturn(List.of(existing)); + + assertEquals(1, loreNodeService.getAllLoreNodes().size()); + assertEquals(1, loreNodeService.getLoreNodesByLoreId("lore-1").size()); + assertEquals(1, loreNodeService.getLoreNodesByParentId("n-parent").size()); + } + + @Test + void testSearch_NullOrBlankReturnsEmpty() { + assertTrue(loreNodeService.searchLoreNodes(null).isEmpty()); + assertTrue(loreNodeService.searchLoreNodes(" ").isEmpty()); + verifyNoInteractions(loreNodeRepository); + } + + @Test + void testSearch_TrimsQuery() { + when(loreNodeRepository.searchByName("pnj")).thenReturn(List.of(existing)); + + loreNodeService.searchLoreNodes(" pnj "); + + verify(loreNodeRepository).searchByName("pnj"); + } + + @Test + void testUpdateLoreNode_AppliesChangesButKeepsLoreId() { + LoreNode changes = LoreNode.builder() + .name("Villes").icon("castle").parentId("n-parent") + .loreId("lore-2") // tentative de migration - doit etre ignoree + .build(); + when(loreNodeRepository.findById("n-1")).thenReturn(Optional.of(existing)); + when(loreNodeRepository.save(any(LoreNode.class))).thenAnswer(inv -> inv.getArgument(0)); + + LoreNode result = loreNodeService.updateLoreNode("n-1", changes); + + assertEquals("Villes", result.getName()); + assertEquals("castle", result.getIcon()); + assertEquals("n-parent", result.getParentId()); + // loreId doit rester inchange (immutable) + assertEquals("lore-1", result.getLoreId()); + } + + @Test + void testUpdateLoreNode_NotFoundThrows() { + when(loreNodeRepository.findById("missing")).thenReturn(Optional.empty()); + + assertThrows(IllegalArgumentException.class, + () -> loreNodeService.updateLoreNode("missing", existing)); + } + + @Test + void testDelete() { + loreNodeService.deleteLoreNode("n-1"); + verify(loreNodeRepository).deleteById("n-1"); + } +} diff --git a/core/src/test/java/com/loremind/application/lorecontext/LoreServiceTest.java b/core/src/test/java/com/loremind/application/lorecontext/LoreServiceTest.java new file mode 100644 index 0000000..54171c1 --- /dev/null +++ b/core/src/test/java/com/loremind/application/lorecontext/LoreServiceTest.java @@ -0,0 +1,141 @@ +package com.loremind.application.lorecontext; + +import com.loremind.domain.lorecontext.Lore; +import com.loremind.domain.lorecontext.ports.LoreNodeRepository; +import com.loremind.domain.lorecontext.ports.LoreRepository; +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; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.List; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +/** + * Test unitaire pour LoreService. + * Vérifie le CRUD, l'enrichissement à la volée des compteurs nodeCount/pageCount, + * et le comportement défensif de la recherche. + */ +@ExtendWith(MockitoExtension.class) +public class LoreServiceTest { + + @Mock private LoreRepository loreRepository; + @Mock private LoreNodeRepository loreNodeRepository; + @Mock private PageRepository pageRepository; + + @InjectMocks private LoreService loreService; + + private Lore testLore; + + @BeforeEach + void setUp() { + testLore = Lore.builder().id("lore-1").name("Aetheria").description("d").build(); + } + + @Test + void testCreateLore_InitialCountsZero() { + when(loreRepository.save(any(Lore.class))).thenReturn(testLore); + + loreService.createLore("Aetheria", "desc"); + + ArgumentCaptor captor = ArgumentCaptor.forClass(Lore.class); + verify(loreRepository).save(captor.capture()); + Lore saved = captor.getValue(); + assertEquals("Aetheria", saved.getName()); + assertEquals("desc", saved.getDescription()); + assertEquals(0, saved.getNodeCount()); + assertEquals(0, saved.getPageCount()); + } + + @Test + void testGetLoreById_EnrichesWithCounts() { + when(loreRepository.findById("lore-1")).thenReturn(Optional.of(testLore)); + when(loreNodeRepository.countByLoreId("lore-1")).thenReturn(5L); + when(pageRepository.countByLoreId("lore-1")).thenReturn(42L); + + Optional result = loreService.getLoreById("lore-1"); + + assertTrue(result.isPresent()); + assertEquals(5, result.get().getNodeCount()); + assertEquals(42, result.get().getPageCount()); + } + + @Test + void testGetLoreById_NotFound() { + when(loreRepository.findById("missing")).thenReturn(Optional.empty()); + + assertTrue(loreService.getLoreById("missing").isEmpty()); + verifyNoInteractions(loreNodeRepository); + verifyNoInteractions(pageRepository); + } + + @Test + void testGetAllLores_EnrichesEach() { + Lore lore2 = Lore.builder().id("lore-2").name("B").build(); + when(loreRepository.findAll()).thenReturn(List.of(testLore, lore2)); + when(loreNodeRepository.countByLoreId("lore-1")).thenReturn(3L); + when(loreNodeRepository.countByLoreId("lore-2")).thenReturn(7L); + when(pageRepository.countByLoreId("lore-1")).thenReturn(10L); + when(pageRepository.countByLoreId("lore-2")).thenReturn(20L); + + List result = loreService.getAllLores(); + + assertEquals(2, result.size()); + assertEquals(3, result.get(0).getNodeCount()); + assertEquals(10, result.get(0).getPageCount()); + assertEquals(7, result.get(1).getNodeCount()); + assertEquals(20, result.get(1).getPageCount()); + } + + @Test + void testSearchLores_NullOrBlankReturnsEmpty() { + assertTrue(loreService.searchLores(null).isEmpty()); + assertTrue(loreService.searchLores(" ").isEmpty()); + verifyNoInteractions(loreRepository); + } + + @Test + void testSearchLores_TrimsQuery() { + when(loreRepository.searchByName("aet")).thenReturn(List.of(testLore)); + + List result = loreService.searchLores(" aet "); + + assertEquals(1, result.size()); + verify(loreRepository).searchByName("aet"); + } + + @Test + void testUpdateLore_Success() { + when(loreRepository.findById("lore-1")).thenReturn(Optional.of(testLore)); + when(loreRepository.save(any(Lore.class))).thenAnswer(inv -> inv.getArgument(0)); + + Lore updated = loreService.updateLore("lore-1", "New Name", "New Desc"); + + assertEquals("New Name", updated.getName()); + assertEquals("New Desc", updated.getDescription()); + verify(loreRepository).save(testLore); + } + + @Test + void testUpdateLore_NotFoundThrows() { + when(loreRepository.findById("missing")).thenReturn(Optional.empty()); + + assertThrows(IllegalArgumentException.class, + () -> loreService.updateLore("missing", "n", "d")); + verify(loreRepository, never()).save(any()); + } + + @Test + void testDeleteLore_DelegatesToRepository() { + loreService.deleteLore("lore-1"); + verify(loreRepository).deleteById("lore-1"); + } +} diff --git a/core/src/test/java/com/loremind/application/lorecontext/PageServiceTest.java b/core/src/test/java/com/loremind/application/lorecontext/PageServiceTest.java new file mode 100644 index 0000000..f928966 --- /dev/null +++ b/core/src/test/java/com/loremind/application/lorecontext/PageServiceTest.java @@ -0,0 +1,170 @@ +package com.loremind.application.lorecontext; + +import com.loremind.domain.lorecontext.Page; +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; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +/** + * Test unitaire pour PageService. + * Vérifie la création MVP (collections initialisées vides), le CRUD, les + * copies défensives sur update, l'immuabilité de loreId/templateId, et + * la gestion null-safe des collections. + */ +@ExtendWith(MockitoExtension.class) +public class PageServiceTest { + + @Mock private PageRepository pageRepository; + + @InjectMocks private PageService pageService; + + private Page existing; + + @BeforeEach + void setUp() { + existing = Page.builder() + .id("p-1").loreId("lore-1").nodeId("n-1").templateId("tpl-1") + .title("Alice") + .values(new HashMap<>()) + .build(); + } + + @Test + void testCreatePage_InitializesEmptyCollections() { + when(pageRepository.save(any(Page.class))).thenAnswer(inv -> inv.getArgument(0)); + + pageService.createPage("lore-1", "n-1", "tpl-1", "Alice"); + + ArgumentCaptor captor = ArgumentCaptor.forClass(Page.class); + verify(pageRepository).save(captor.capture()); + Page saved = captor.getValue(); + assertEquals("Alice", saved.getTitle()); + assertEquals("lore-1", saved.getLoreId()); + assertEquals("n-1", saved.getNodeId()); + assertEquals("tpl-1", saved.getTemplateId()); + assertNotNull(saved.getValues()); + assertTrue(saved.getValues().isEmpty()); + assertNotNull(saved.getTags()); + assertTrue(saved.getTags().isEmpty()); + assertNotNull(saved.getRelatedPageIds()); + assertTrue(saved.getRelatedPageIds().isEmpty()); + } + + @Test + void testGetById_And_All_And_ByLore_And_ByNode() { + when(pageRepository.findById("p-1")).thenReturn(Optional.of(existing)); + when(pageRepository.findAll()).thenReturn(List.of(existing)); + when(pageRepository.findByLoreId("lore-1")).thenReturn(List.of(existing)); + when(pageRepository.findByNodeId("n-1")).thenReturn(List.of(existing)); + + assertTrue(pageService.getPageById("p-1").isPresent()); + assertEquals(1, pageService.getAllPages().size()); + assertEquals(1, pageService.getPagesByLoreId("lore-1").size()); + assertEquals(1, pageService.getPagesByNodeId("n-1").size()); + } + + @Test + void testSearch_NullOrBlankReturnsEmpty() { + assertTrue(pageService.searchPages(null).isEmpty()); + assertTrue(pageService.searchPages(" ").isEmpty()); + verifyNoInteractions(pageRepository); + } + + @Test + void testSearch_TrimsQuery() { + when(pageRepository.searchByTitle("alice")).thenReturn(List.of(existing)); + + pageService.searchPages(" alice "); + + verify(pageRepository).searchByTitle("alice"); + } + + @Test + void testUpdatePage_AppliesChangesAndKeepsImmutables() { + Map newValues = Map.of("Histoire", "Il..."); + Map> newImages = Map.of("Portrait", List.of("img-1")); + List newTags = List.of("hero"); + List newRelated = List.of("p-2"); + + Page changes = Page.builder() + .loreId("lore-OTHER") // doit etre ignore + .templateId("tpl-OTHER") // doit etre ignore + .nodeId("n-2") // mutable : deplacement + .title("Alice v2") + .values(newValues) + .imageValues(newImages) + .notes("notes MJ") + .tags(newTags) + .relatedPageIds(newRelated) + .build(); + + when(pageRepository.findById("p-1")).thenReturn(Optional.of(existing)); + when(pageRepository.save(any(Page.class))).thenAnswer(inv -> inv.getArgument(0)); + + Page result = pageService.updatePage("p-1", changes); + + assertEquals("Alice v2", result.getTitle()); + assertEquals("n-2", result.getNodeId()); + // loreId et templateId immuables + assertEquals("lore-1", result.getLoreId()); + assertEquals("tpl-1", result.getTemplateId()); + assertEquals(newValues, result.getValues()); + assertNotSame(newValues, result.getValues()); // copie defensive + assertEquals(newImages, result.getImageValues()); + assertNotSame(newImages, result.getImageValues()); + assertEquals("notes MJ", result.getNotes()); + assertEquals(newTags, result.getTags()); + assertNotSame(newTags, result.getTags()); + assertEquals(newRelated, result.getRelatedPageIds()); + assertNotSame(newRelated, result.getRelatedPageIds()); + } + + @Test + void testUpdatePage_NullCollectionsBecomeEmpty() { + Page changes = Page.builder() + .nodeId("n-1").title("t") + .values(null).imageValues(null).tags(null).relatedPageIds(null) + .build(); + when(pageRepository.findById("p-1")).thenReturn(Optional.of(existing)); + when(pageRepository.save(any(Page.class))).thenAnswer(inv -> inv.getArgument(0)); + + Page result = pageService.updatePage("p-1", changes); + + assertNotNull(result.getValues()); + assertTrue(result.getValues().isEmpty()); + assertNotNull(result.getImageValues()); + assertTrue(result.getImageValues().isEmpty()); + assertNotNull(result.getTags()); + assertTrue(result.getTags().isEmpty()); + assertNotNull(result.getRelatedPageIds()); + assertTrue(result.getRelatedPageIds().isEmpty()); + } + + @Test + void testUpdatePage_NotFoundThrows() { + when(pageRepository.findById("missing")).thenReturn(Optional.empty()); + + assertThrows(IllegalArgumentException.class, + () -> pageService.updatePage("missing", existing)); + } + + @Test + void testDelete() { + pageService.deletePage("p-1"); + verify(pageRepository).deleteById("p-1"); + } +} diff --git a/core/src/test/java/com/loremind/application/lorecontext/TemplateServiceTest.java b/core/src/test/java/com/loremind/application/lorecontext/TemplateServiceTest.java new file mode 100644 index 0000000..a363004 --- /dev/null +++ b/core/src/test/java/com/loremind/application/lorecontext/TemplateServiceTest.java @@ -0,0 +1,146 @@ +package com.loremind.application.lorecontext; + +import com.loremind.domain.lorecontext.Template; +import com.loremind.domain.lorecontext.TemplateField; +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; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.List; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +/** + * Test unitaire pour TemplateService. + * Vérifie le CRUD, la copie défensive des champs en create/update, le + * comportement null-safe de fields, et l'immuabilité de loreId. + */ +@ExtendWith(MockitoExtension.class) +public class TemplateServiceTest { + + @Mock private TemplateRepository templateRepository; + + @InjectMocks private TemplateService templateService; + + private Template existing; + private List originalFields; + + @BeforeEach + void setUp() { + originalFields = List.of(TemplateField.text("Histoire"), TemplateField.image("Portrait")); + existing = Template.builder() + .id("tpl-1").loreId("lore-1").name("Personnage") + .description("desc").defaultNodeId("n-1") + .fields(List.of(TemplateField.text("Old"))) + .build(); + } + + @Test + void testCreateTemplate_CopiesFieldsDefensively() { + when(templateRepository.save(any(Template.class))).thenAnswer(inv -> inv.getArgument(0)); + + templateService.createTemplate("lore-1", "T", "d", "n-1", originalFields); + + ArgumentCaptor