diff --git a/core/src/main/java/com/loremind/infrastructure/web/GlobalExceptionHandler.java b/core/src/main/java/com/loremind/infrastructure/web/GlobalExceptionHandler.java index 3ae3676..c24e932 100644 --- a/core/src/main/java/com/loremind/infrastructure/web/GlobalExceptionHandler.java +++ b/core/src/main/java/com/loremind/infrastructure/web/GlobalExceptionHandler.java @@ -11,6 +11,7 @@ import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; import org.springframework.web.context.request.async.AsyncRequestNotUsableException; +import org.springframework.web.server.ResponseStatusException; import java.util.LinkedHashMap; import java.util.Map; @@ -71,6 +72,21 @@ public class GlobalExceptionHandler { )); } + /** + * Statut HTTP explicitement choisi par un controller via {@link ResponseStatusException} + * (ex: {@code NotebookController} -> 404 si notebook introuvable, 502 si Brain injoignable). + *

+ * SANS ce handler, le fallback {@code @ExceptionHandler(Throwable.class)} ci-dessous + * interceptait ces exceptions et renvoyait 500 — ecrasant le statut voulu (le + * resolver natif de Spring est court-circuite des qu'un advice gere Throwable). + */ + @ExceptionHandler(ResponseStatusException.class) + public ResponseEntity> handleResponseStatus(ResponseStatusException ex) { + String reason = ex.getReason(); + return ResponseEntity.status(ex.getStatusCode()) + .body(Map.of("error", reason != null ? reason : ex.getStatusCode().toString())); + } + /** * Client HTTP parti pendant une reponse asynchrone (SSE) : le navigateur a ferme * la connexion (onglet ferme, proxy coupe...), la reponse n'est plus utilisable. diff --git a/core/src/test/java/com/loremind/infrastructure/web/controller/AiChatControllerTest.java b/core/src/test/java/com/loremind/infrastructure/web/controller/AiChatControllerTest.java new file mode 100644 index 0000000..6be7d72 --- /dev/null +++ b/core/src/test/java/com/loremind/infrastructure/web/controller/AiChatControllerTest.java @@ -0,0 +1,241 @@ +package com.loremind.infrastructure.web.controller; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.loremind.application.generationcontext.StreamChatForCampaignUseCase; +import com.loremind.application.generationcontext.StreamChatForLoreUseCase; +import com.loremind.application.generationcontext.StreamChatForSessionUseCase; +import com.loremind.domain.generationcontext.ChatMessage; +import com.loremind.domain.generationcontext.ChatUsage; +import com.loremind.infrastructure.web.dto.generationcontext.ChatMessageDTO; +import com.loremind.infrastructure.web.dto.generationcontext.ChatStreamCampaignRequestDTO; +import com.loremind.infrastructure.web.dto.generationcontext.ChatStreamRequestDTO; +import com.loremind.infrastructure.web.dto.generationcontext.ChatStreamSessionRequestDTO; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.core.task.TaskExecutor; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; + +import java.util.List; +import java.util.function.Consumer; + +import static org.hamcrest.Matchers.containsString; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.verify; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.asyncDispatch; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.request; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * Tests d'integration pour {@link AiChatController} (chat IA streame en SSE). + *

+ * Les trois use cases ({@link StreamChatForLoreUseCase}, + * {@link StreamChatForCampaignUseCase}, {@link StreamChatForSessionUseCase}) sont + * mockes : sinon chaque test ferait un vrai appel au Brain (indisponible en test). + *

+ * Le {@code TaskExecutor} ("applicationTaskExecutor") est mocke pour executer la + * tache de streaming EN LIGNE (synchrone) : tous les events SSE sont ecrits avant + * le retour du controleur, ce qui rend les assertions sur le flux deterministes. + */ +@SpringBootTest +@AutoConfigureMockMvc +class AiChatControllerTest { + + @Autowired private MockMvc mockMvc; + @Autowired private ObjectMapper objectMapper; + + @MockBean private StreamChatForLoreUseCase loreUseCase; + @MockBean private StreamChatForCampaignUseCase campaignUseCase; + @MockBean private StreamChatForSessionUseCase sessionUseCase; + @MockBean(name = "applicationTaskExecutor") private TaskExecutor taskExecutor; + + @BeforeEach + void setUp() { + // Tache de streaming executee en ligne -> events SSE deterministes. + doAnswer(inv -> { ((Runnable) inv.getArgument(0)).run(); return null; }) + .when(taskExecutor).execute(any(Runnable.class)); + } + + private MvcResult perform(String url, Object body) throws Exception { + return mockMvc.perform(post(url) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(body))) + .andExpect(request().asyncStarted()) + .andReturn(); + } + + // --- /chat/stream (Lore) ------------------------------------------------ + + @Test + void chatStream_streamsUsageTokenDone() throws Exception { + // Le use case mocke joue : usage -> 1 token -> fin. + doAnswer(inv -> { + Consumer onUsage = inv.getArgument(3); + Consumer onToken = inv.getArgument(4); + Runnable onComplete = inv.getArgument(5); + onUsage.accept(new ChatUsage(10, 20, 30, 8000)); + onToken.accept("Bonjour"); + onComplete.run(); + return null; + }).when(loreUseCase).execute(any(), any(), any(), any(), any(), any(), any()); + + ChatStreamRequestDTO body = new ChatStreamRequestDTO(); + body.setLoreId("lore-1"); + body.setMessages(List.of(new ChatMessageDTO("user", "Salut ?"))); + + MvcResult result = perform("/api/ai/chat/stream", body); + + mockMvc.perform(asyncDispatch(result)) + .andExpect(status().isOk()) + .andExpect(content().string(containsString("\"system\":10"))) + .andExpect(content().string(containsString("\"max\":8000"))) + .andExpect(content().string(containsString("Bonjour"))) + .andExpect(content().string(containsString("done"))); + } + + @Test + void chatStream_passesDomainMessagesToUseCase() throws Exception { + doAnswer(inv -> { ((Runnable) inv.getArgument(5)).run(); return null; }) + .when(loreUseCase).execute(any(), any(), any(), any(), any(), any(), any()); + + ChatStreamRequestDTO body = new ChatStreamRequestDTO(); + body.setLoreId("lore-1"); + body.setPageId("page-9"); + body.setMessages(List.of( + new ChatMessageDTO("user", "Q1"), + new ChatMessageDTO("assistant", "R1"))); + + MvcResult result = perform("/api/ai/chat/stream", body); + mockMvc.perform(asyncDispatch(result)).andExpect(status().isOk()); + + @SuppressWarnings("unchecked") + org.mockito.ArgumentCaptor> captor = + org.mockito.ArgumentCaptor.forClass(List.class); + verify(loreUseCase).execute( + org.mockito.ArgumentMatchers.eq("lore-1"), + org.mockito.ArgumentMatchers.eq("page-9"), + captor.capture(), any(), any(), any(), any()); + List passed = captor.getValue(); + org.junit.jupiter.api.Assertions.assertEquals(2, passed.size()); + org.junit.jupiter.api.Assertions.assertEquals("user", passed.get(0).role()); + org.junit.jupiter.api.Assertions.assertEquals("Q1", passed.get(0).content()); + } + + @Test + void chatStream_useCaseInvokesError_emitsError() throws Exception { + doAnswer(inv -> { + Consumer onError = inv.getArgument(6); + onError.accept(new RuntimeException("brain HS")); + return null; + }).when(loreUseCase).execute(any(), any(), any(), any(), any(), any(), any()); + + ChatStreamRequestDTO body = new ChatStreamRequestDTO(); + body.setLoreId("lore-1"); + + MvcResult result = perform("/api/ai/chat/stream", body); + mockMvc.perform(asyncDispatch(result)) + .andExpect(status().isOk()) + .andExpect(content().string(containsString("error"))) + .andExpect(content().string(containsString("brain HS"))); + } + + @Test + void chatStream_useCaseThrows_emitsError() throws Exception { + // Lore introuvable -> le use case leve, le controller catch et fail(emitter). + doThrow(new IllegalArgumentException("Lore introuvable")) + .when(loreUseCase).execute(any(), any(), any(), any(), any(), any(), any()); + + ChatStreamRequestDTO body = new ChatStreamRequestDTO(); + body.setLoreId("missing"); + + MvcResult result = perform("/api/ai/chat/stream", body); + mockMvc.perform(asyncDispatch(result)) + .andExpect(status().isOk()) + .andExpect(content().string(containsString("Lore introuvable"))); + } + + // --- /chat/stream-campaign ---------------------------------------------- + + @Test + void chatStreamCampaign_streamsTokenThenDone() throws Exception { + doAnswer(inv -> { + Consumer onToken = inv.getArgument(5); + Runnable onComplete = inv.getArgument(6); + onToken.accept("Campagne"); + onComplete.run(); + return null; + }).when(campaignUseCase).execute(any(), any(), any(), any(), any(), any(), any(), any()); + + ChatStreamCampaignRequestDTO body = new ChatStreamCampaignRequestDTO(); + body.setCampaignId("camp-1"); + body.setEntityType("scene"); + body.setEntityId("scene-3"); + body.setMessages(List.of(new ChatMessageDTO("user", "Aide"))); + + MvcResult result = perform("/api/ai/chat/stream-campaign", body); + mockMvc.perform(asyncDispatch(result)) + .andExpect(status().isOk()) + .andExpect(content().string(containsString("Campagne"))) + .andExpect(content().string(containsString("done"))); + } + + @Test + void chatStreamCampaign_useCaseThrows_emitsError() throws Exception { + doThrow(new IllegalArgumentException("Campagne introuvable")) + .when(campaignUseCase).execute(any(), any(), any(), any(), any(), any(), any(), any()); + + ChatStreamCampaignRequestDTO body = new ChatStreamCampaignRequestDTO(); + body.setCampaignId("missing"); + + MvcResult result = perform("/api/ai/chat/stream-campaign", body); + mockMvc.perform(asyncDispatch(result)) + .andExpect(status().isOk()) + .andExpect(content().string(containsString("Campagne introuvable"))); + } + + // --- /chat/stream-session ----------------------------------------------- + + @Test + void chatStreamSession_streamsTokenThenDone() throws Exception { + doAnswer(inv -> { + Consumer onToken = inv.getArgument(3); + Runnable onComplete = inv.getArgument(4); + onToken.accept("Session"); + onComplete.run(); + return null; + }).when(sessionUseCase).execute(any(), any(), any(), any(), any(), any()); + + ChatStreamSessionRequestDTO body = new ChatStreamSessionRequestDTO(); + body.setSessionId("sess-1"); + body.setMessages(List.of(new ChatMessageDTO("user", "Resume"))); + + MvcResult result = perform("/api/ai/chat/stream-session", body); + mockMvc.perform(asyncDispatch(result)) + .andExpect(status().isOk()) + .andExpect(content().string(containsString("Session"))) + .andExpect(content().string(containsString("done"))); + } + + @Test + void chatStreamSession_useCaseThrows_emitsError() throws Exception { + doThrow(new IllegalArgumentException("Session introuvable")) + .when(sessionUseCase).execute(any(), any(), any(), any(), any(), any()); + + ChatStreamSessionRequestDTO body = new ChatStreamSessionRequestDTO(); + body.setSessionId("missing"); + + MvcResult result = perform("/api/ai/chat/stream-session", body); + mockMvc.perform(asyncDispatch(result)) + .andExpect(status().isOk()) + .andExpect(content().string(containsString("Session introuvable"))); + } +} diff --git a/core/src/test/java/com/loremind/infrastructure/web/controller/CampaignAdaptControllerTest.java b/core/src/test/java/com/loremind/infrastructure/web/controller/CampaignAdaptControllerTest.java new file mode 100644 index 0000000..8295038 --- /dev/null +++ b/core/src/test/java/com/loremind/infrastructure/web/controller/CampaignAdaptControllerTest.java @@ -0,0 +1,130 @@ +package com.loremind.infrastructure.web.controller; + +import com.loremind.application.campaigncontext.CampaignAdaptService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.core.task.TaskExecutor; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; + +import java.nio.charset.StandardCharsets; +import java.util.function.Consumer; + +import static org.hamcrest.Matchers.containsString; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.doThrow; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.asyncDispatch; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.request; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * Tests d'integration pour {@link CampaignAdaptController} (conseil PDF -> campagne, SSE). + *

+ * Le {@link CampaignAdaptService} est mocke : il appelle le Brain (indisponible en + * test). Le {@code TaskExecutor} ("applicationTaskExecutor") est mocke pour executer + * la tache de streaming EN LIGNE -> events SSE deterministes. + *

+ * Signature de {@code adviseStreaming(campaignId, pdfBytes, filename, messagesJson, + * onToken, onComplete, onError)} : indices des callbacks -> onToken=4, onComplete=5, + * onError=6. + */ +@SpringBootTest +@AutoConfigureMockMvc +class CampaignAdaptControllerTest { + + @Autowired private MockMvc mockMvc; + + @MockBean private CampaignAdaptService campaignAdaptService; + @MockBean(name = "applicationTaskExecutor") private TaskExecutor taskExecutor; + + @BeforeEach + void setUp() { + // Tache de streaming executee en ligne -> events SSE deterministes. + doAnswer(inv -> { ((Runnable) inv.getArgument(0)).run(); return null; }) + .when(taskExecutor).execute(any(Runnable.class)); + } + + private MockMultipartFile pdf(byte[] content) { + return new MockMultipartFile("file", "aventure.pdf", "application/pdf", content); + } + + private MvcResult perform(MockMultipartFile file) throws Exception { + return mockMvc.perform(multipart("/api/campaigns/{id}/adapt-pdf/stream", "camp-1") + .file(file) + .param("messages", "[]")) + .andExpect(request().asyncStarted()) + .andReturn(); + } + + @Test + void adaptStream_streamsTokenThenDone() throws Exception { + // Le service mocke joue : 1 token -> fin. + doAnswer(inv -> { + Consumer onToken = inv.getArgument(4); + Runnable onComplete = inv.getArgument(5); + onToken.accept("Conseil markdown"); + onComplete.run(); + return null; + }).when(campaignAdaptService).adviseStreaming(any(), any(), any(), any(), any(), any(), any()); + + MvcResult result = perform(pdf("%PDF-1.4 fake".getBytes(StandardCharsets.UTF_8))); + + mockMvc.perform(asyncDispatch(result)) + .andExpect(status().isOk()) + .andExpect(content().string(containsString("Conseil markdown"))) + .andExpect(content().string(containsString("done"))); + } + + @Test + void adaptStream_serviceInvokesError_emitsError() throws Exception { + doAnswer(inv -> { + Consumer onError = inv.getArgument(6); + onError.accept(new RuntimeException("brain HS")); + return null; + }).when(campaignAdaptService).adviseStreaming(any(), any(), any(), any(), any(), any(), any()); + + MvcResult result = perform(pdf("%PDF-1.4 fake".getBytes(StandardCharsets.UTF_8))); + + mockMvc.perform(asyncDispatch(result)) + .andExpect(status().isOk()) + .andExpect(content().string(containsString("error"))) + .andExpect(content().string(containsString("brain HS"))); + } + + @Test + void adaptStream_campaignMissing_emitsError() throws Exception { + // Le service leve IllegalArgumentException -> le controller catch et envoie "Campagne introuvable.". + doThrow(new IllegalArgumentException("Campagne introuvable : camp-1")) + .when(campaignAdaptService).adviseStreaming(any(), any(), any(), any(), any(), any(), any()); + + MvcResult result = perform(pdf("%PDF-1.4 fake".getBytes(StandardCharsets.UTF_8))); + + mockMvc.perform(asyncDispatch(result)) + .andExpect(status().isOk()) + .andExpect(content().string(containsString("error"))) + .andExpect(content().string(containsString("Campagne introuvable"))); + } + + @Test + void adaptStream_emptyFile_emitsError_withoutCallingService() throws Exception { + // Fichier vide : branche court-circuit, le service n'est jamais appele. + MvcResult result = mockMvc.perform( + multipart("/api/campaigns/{id}/adapt-pdf/stream", "camp-1") + .file(new MockMultipartFile("file", "vide.pdf", "application/pdf", new byte[0]))) + .andExpect(request().asyncStarted()) + .andReturn(); + + mockMvc.perform(asyncDispatch(result)) + .andExpect(status().isOk()) + .andExpect(content().string(containsString("error"))) + .andExpect(content().string(containsString("Fichier PDF vide"))); + } +} diff --git a/core/src/test/java/com/loremind/infrastructure/web/controller/CampaignFlagControllerTest.java b/core/src/test/java/com/loremind/infrastructure/web/controller/CampaignFlagControllerTest.java new file mode 100644 index 0000000..e18284c --- /dev/null +++ b/core/src/test/java/com/loremind/infrastructure/web/controller/CampaignFlagControllerTest.java @@ -0,0 +1,91 @@ +package com.loremind.infrastructure.web.controller; + +import com.loremind.domain.campaigncontext.Arc; +import com.loremind.domain.campaigncontext.Campaign; +import com.loremind.domain.campaigncontext.Chapter; +import com.loremind.domain.campaigncontext.Prerequisite; +import com.loremind.domain.campaigncontext.ports.ArcRepository; +import com.loremind.domain.campaigncontext.ports.CampaignRepository; +import com.loremind.domain.campaigncontext.ports.ChapterRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * Tests d'intégration de {@link CampaignFlagController}. + * Unique endpoint : list (GET) qui déduplique les noms de faits (Prerequisite.FlagSet) + * référencés dans les prérequis des chapitres de la campagne. + * Fixtures : campaign -> arc -> chapters avec prérequis FlagSet. + */ +@SpringBootTest +@AutoConfigureMockMvc +@Transactional +class CampaignFlagControllerTest { + + @Autowired private MockMvc mockMvc; + @Autowired private CampaignRepository campaignRepository; + @Autowired private ArcRepository arcRepository; + @Autowired private ChapterRepository chapterRepository; + + private String campaignId; + private String arcId; + + @BeforeEach + void setUp() { + // Chaîne de fixtures : campaign -> arc + campaignId = campaignRepository.save(Campaign.builder().name("C").description("").build()).getId(); + arcId = arcRepository.save(Arc.builder().campaignId(campaignId).name("A").order(0).build()).getId(); + } + + @Test + void list_returnsEmptyArray_whenNoFlagPrerequisites() throws Exception { + // Chapitre sans prérequis FlagSet => aucun fait référencé + chapterRepository.save(Chapter.builder().arcId(arcId).name("Ch").order(0).build()); + mockMvc.perform(get("/api/campaigns/{cid}/flags", campaignId)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$").isArray()) + .andExpect(jsonPath("$.length()").value(0)); + } + + @Test + void list_returnsDeduplicatedSortedFlagNames() throws Exception { + // Deux chapitres référençant des FlagSet, avec un doublon "alpha" + chapterRepository.save(Chapter.builder().arcId(arcId).name("Ch1").order(0) + .prerequisites(List.of( + new Prerequisite.FlagSet("zeta"), + new Prerequisite.FlagSet("alpha"))) + .build()); + chapterRepository.save(Chapter.builder().arcId(arcId).name("Ch2").order(1) + .prerequisites(List.of( + new Prerequisite.FlagSet("alpha"), // doublon + new Prerequisite.QuestCompleted("q-1"))) // ignoré (pas un FlagSet) + .build()); + + mockMvc.perform(get("/api/campaigns/{cid}/flags", campaignId)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$").isArray()) + .andExpect(jsonPath("$.length()").value(2)) + // TreeSet => tri alphabétique : alpha avant zeta + .andExpect(jsonPath("$[0]").value("alpha")) + .andExpect(jsonPath("$[1]").value("zeta")); + } + + @Test + void list_returnsEmptyArray_forUnknownCampaign() throws Exception { + // Campagne sans arcs => liste vide (pas d'erreur) + mockMvc.perform(get("/api/campaigns/{cid}/flags", "999999999")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$").isArray()) + .andExpect(jsonPath("$.length()").value(0)); + } +} diff --git a/core/src/test/java/com/loremind/infrastructure/web/controller/CampaignImportControllerTest.java b/core/src/test/java/com/loremind/infrastructure/web/controller/CampaignImportControllerTest.java new file mode 100644 index 0000000..b4bb1a7 --- /dev/null +++ b/core/src/test/java/com/loremind/infrastructure/web/controller/CampaignImportControllerTest.java @@ -0,0 +1,281 @@ +package com.loremind.infrastructure.web.controller; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.loremind.application.campaigncontext.CampaignImportService; +import com.loremind.application.campaigncontext.CampaignImportService.ApplyResult; +import com.loremind.domain.campaigncontext.CampaignImportProgress; +import com.loremind.domain.campaigncontext.CampaignImportProposal; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.core.task.TaskExecutor; +import org.springframework.http.MediaType; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; + +import java.util.List; +import java.util.function.Consumer; + +import static org.hamcrest.Matchers.containsString; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.asyncDispatch; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.request; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * Tests d'integration pour {@link CampaignImportController} (import PDF -> arbre). + *

+ * Le {@link CampaignImportService} est mocke : son {@code importStructureStreaming} + * delegue sinon au Brain Python (indisponible en test) et {@code applyStructure} + * persiste en base. On controle ici les callbacks (progress / done / error) du + * streaming et le mapping HTTP du apply. + *

+ * Le {@code TaskExecutor} ("applicationTaskExecutor") est mocke pour executer la + * tache d'import EN LIGNE (synchrone) : tous les events SSE sont ecrits avant le + * retour du controleur, rendant les assertions sur le flux deterministes. + *

+ * Indices des callbacks de + * {@link CampaignImportService#importStructureStreaming} : + * (0) pdfBytes, (1) filename, (2) onProgress, (3) onHeartbeat, (4) onStatus, + * (5) onDone, (6) onError. + */ +@SpringBootTest +@AutoConfigureMockMvc +class CampaignImportControllerTest { + + @Autowired private MockMvc mockMvc; + @Autowired private ObjectMapper objectMapper; + + @MockBean private CampaignImportService campaignImportService; + @MockBean(name = "applicationTaskExecutor") private TaskExecutor taskExecutor; + + private static final String CAMPAIGN_ID = "camp-1"; + + @BeforeEach + void setUp() { + // Tache d'import executee en ligne -> events SSE deterministes. + doAnswer(inv -> { ((Runnable) inv.getArgument(0)).run(); return null; }) + .when(taskExecutor).execute(any(Runnable.class)); + } + + private MockMultipartFile pdf(byte[] bytes) { + return new MockMultipartFile("file", "campagne.pdf", "application/pdf", bytes); + } + + // --- POST /stream (SSE) ------------------------------------------------ + + @Test + void importStream_emptyFile_emitsError() throws Exception { + MockMultipartFile empty = pdf(new byte[0]); + MvcResult result = mockMvc.perform( + multipart("/api/campaigns/{id}/import-structure/stream", CAMPAIGN_ID).file(empty)) + .andExpect(request().asyncStarted()) + .andReturn(); + + mockMvc.perform(asyncDispatch(result)) + .andExpect(status().isOk()) + .andExpect(content().string(containsString("error"))) + .andExpect(content().string(containsString("vide"))); + } + + @Test + void importStream_happyPath_streamsDone() throws Exception { + // Le service mocke joue : onDone avec une proposition vide. + doAnswer(inv -> { + Consumer onDone = inv.getArgument(5); + onDone.accept(new CampaignImportProposal(List.of(), List.of())); + return null; + }).when(campaignImportService).importStructureStreaming( + any(), any(), any(), any(), any(), any(), any()); + + MvcResult result = mockMvc.perform( + multipart("/api/campaigns/{id}/import-structure/stream", CAMPAIGN_ID) + .file(pdf(new byte[]{1, 2, 3}))) + .andExpect(request().asyncStarted()) + .andReturn(); + + mockMvc.perform(asyncDispatch(result)) + .andExpect(status().isOk()) + .andExpect(content().string(containsString("done"))); + } + + @Test + void importStream_serviceError_emitsError() throws Exception { + // Le service mocke joue : onError avec un message. + doAnswer(inv -> { + Consumer onError = inv.getArgument(6); + onError.accept(new RuntimeException("Brain injoignable")); + return null; + }).when(campaignImportService).importStructureStreaming( + any(), any(), any(), any(), any(), any(), any()); + + MvcResult result = mockMvc.perform( + multipart("/api/campaigns/{id}/import-structure/stream", CAMPAIGN_ID) + .file(pdf(new byte[]{1, 2, 3}))) + .andExpect(request().asyncStarted()) + .andReturn(); + + mockMvc.perform(asyncDispatch(result)) + .andExpect(status().isOk()) + .andExpect(content().string(containsString("error"))) + .andExpect(content().string(containsString("Brain injoignable"))); + } + + @Test + void importStream_thrownException_emitsError() throws Exception { + // Le service leve directement (catch general du controleur). + doAnswer(inv -> { throw new RuntimeException("boom extraction"); }) + .when(campaignImportService).importStructureStreaming( + any(), any(), any(), any(), any(), any(), any()); + + MvcResult result = mockMvc.perform( + multipart("/api/campaigns/{id}/import-structure/stream", CAMPAIGN_ID) + .file(pdf(new byte[]{1, 2, 3}))) + .andExpect(request().asyncStarted()) + .andReturn(); + + mockMvc.perform(asyncDispatch(result)) + .andExpect(status().isOk()) + .andExpect(content().string(containsString("boom extraction"))); + } + + @Test + void importStream_progressAndStatus_streamedBeforeDone() throws Exception { + // Joue la sequence complete des callbacks intermediaires (progress / status / + // heartbeat) puis onDone -> couvre sendEvent("progress"), sendEvent("status") + // et sendHeartbeat (helpers SSE non touches par les tests precedents). + doAnswer(inv -> { + Consumer onProgress = inv.getArgument(2); + Runnable onHeartbeat = inv.getArgument(3); + Consumer onStatus = inv.getArgument(4); + Consumer onDone = inv.getArgument(5); + onProgress.accept(new CampaignImportProgress(2, 10, 5, 1, 1, 2, 3, 4)); + onStatus.accept("Analyse en cours"); + onHeartbeat.run(); + onDone.accept(new CampaignImportProposal(List.of(), List.of())); + return null; + }).when(campaignImportService).importStructureStreaming( + any(), any(), any(), any(), any(), any(), any()); + + MvcResult result = mockMvc.perform( + multipart("/api/campaigns/{id}/import-structure/stream", CAMPAIGN_ID) + .file(pdf(new byte[]{1, 2, 3}))) + .andExpect(request().asyncStarted()) + .andReturn(); + + mockMvc.perform(asyncDispatch(result)) + .andExpect(status().isOk()) + .andExpect(content().string(containsString("progress"))) + .andExpect(content().string(containsString("status"))) + .andExpect(content().string(containsString("Analyse en cours"))) + .andExpect(content().string(containsString("keepalive"))) + .andExpect(content().string(containsString("done"))); + } + + @Test + void importStream_nullStatus_emitsEmptyMessage() throws Exception { + // onStatus(null) -> branche "status != null ? status : \"\"" du controleur. + doAnswer(inv -> { + Consumer onStatus = inv.getArgument(4); + Consumer onDone = inv.getArgument(5); + onStatus.accept(null); + onDone.accept(new CampaignImportProposal(List.of(), List.of())); + return null; + }).when(campaignImportService).importStructureStreaming( + any(), any(), any(), any(), any(), any(), any()); + + MvcResult result = mockMvc.perform( + multipart("/api/campaigns/{id}/import-structure/stream", CAMPAIGN_ID) + .file(pdf(new byte[]{1, 2, 3}))) + .andExpect(request().asyncStarted()) + .andReturn(); + + mockMvc.perform(asyncDispatch(result)) + .andExpect(status().isOk()) + .andExpect(content().string(containsString("status"))) + .andExpect(content().string(containsString("done"))); + } + + @Test + void importStream_nullErrorMessage_emitsFallbackMessage() throws Exception { + // onError avec un message null -> sendError utilise "Erreur inconnue.". + doAnswer(inv -> { + Consumer onError = inv.getArgument(6); + onError.accept(new RuntimeException()); // getMessage() == null + return null; + }).when(campaignImportService).importStructureStreaming( + any(), any(), any(), any(), any(), any(), any()); + + MvcResult result = mockMvc.perform( + multipart("/api/campaigns/{id}/import-structure/stream", CAMPAIGN_ID) + .file(pdf(new byte[]{1, 2, 3}))) + .andExpect(request().asyncStarted()) + .andReturn(); + + mockMvc.perform(asyncDispatch(result)) + .andExpect(status().isOk()) + .andExpect(content().string(containsString("Erreur inconnue"))); + } + + @Test + void importStream_serviceThrows_emitsError() throws Exception { + // Le service LEVE (au lieu d'invoquer onError) : le catch(Exception) du + // controleur relaie le message via sendError. + doThrow(new RuntimeException("panne interne")) + .when(campaignImportService).importStructureStreaming( + any(), any(), any(), any(), any(), any(), any()); + + MvcResult result = mockMvc.perform( + multipart("/api/campaigns/{id}/import-structure/stream", CAMPAIGN_ID) + .file(pdf(new byte[]{1, 2, 3}))) + .andExpect(request().asyncStarted()) + .andReturn(); + + mockMvc.perform(asyncDispatch(result)) + .andExpect(status().isOk()) + .andExpect(content().string(containsString("error"))) + .andExpect(content().string(containsString("panne interne"))); + } + + // --- POST /apply ------------------------------------------------------- + + @Test + void apply_returns200_withSummary() throws Exception { + when(campaignImportService.applyStructure(eq(CAMPAIGN_ID), any())) + .thenReturn(new ApplyResult(1, 2, 3, 4)); + CampaignImportProposal proposal = new CampaignImportProposal(List.of(), List.of()); + + mockMvc.perform(post("/api/campaigns/{id}/import-structure/apply", CAMPAIGN_ID) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(proposal))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.arcsCreated").value(1)) + .andExpect(jsonPath("$.chaptersCreated").value(2)) + .andExpect(jsonPath("$.scenesCreated").value(3)) + .andExpect(jsonPath("$.npcsCreated").value(4)); + } + + @Test + void apply_returns404_whenCampaignMissing() throws Exception { + when(campaignImportService.applyStructure(any(), any())) + .thenThrow(new IllegalArgumentException("Campagne introuvable")); + CampaignImportProposal proposal = new CampaignImportProposal(List.of(), List.of()); + + mockMvc.perform(post("/api/campaigns/{id}/import-structure/apply", CAMPAIGN_ID) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(proposal))) + .andExpect(status().isNotFound()); + } +} diff --git a/core/src/test/java/com/loremind/infrastructure/web/controller/CampaignImportControllerUnitTest.java b/core/src/test/java/com/loremind/infrastructure/web/controller/CampaignImportControllerUnitTest.java new file mode 100644 index 0000000..3e96a6f --- /dev/null +++ b/core/src/test/java/com/loremind/infrastructure/web/controller/CampaignImportControllerUnitTest.java @@ -0,0 +1,79 @@ +package com.loremind.infrastructure.web.controller; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.loremind.application.campaigncontext.CampaignImportService; +import com.loremind.domain.campaigncontext.CampaignImportProgress; +import com.loremind.domain.campaigncontext.CampaignImportProposal; +import org.junit.jupiter.api.Test; +import org.springframework.core.task.TaskExecutor; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; + +import java.util.List; +import java.util.function.Consumer; + +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; + +/** + * Test UNITAIRE pur (sans Spring) de {@link CampaignImportController} dédié au code + * défensif de déconnexion du navigateur, INTESTABLE via MockMvc : là-bas, un envoi + * SSE après {@code complete()} met l'emitter dans l'état « already completed » qui + * remonte en {@code ServletException} (échec de test) au lieu d'exécuter la branche. + *

+ * En instanciant le controller à la main (exécuteur en ligne, vrai ObjectMapper, + * service mocké), on pilote directement la séquence : envoi initial → {@code complete()} + * → un envoi ultérieur échoue et bascule {@code clientGone=true} → les callbacks + * suivants empruntent alors les branches {@code ClientGoneException} / early-return. + */ +class CampaignImportControllerUnitTest { + + private final CampaignImportService service = mock(CampaignImportService.class); + /** Exécuteur synchrone : la tâche d'import tourne dans le thread du test. */ + private final TaskExecutor inlineExecutor = Runnable::run; + private final CampaignImportController controller = + new CampaignImportController(service, inlineExecutor, new ObjectMapper()); + + private static CampaignImportProgress progress() { + return new CampaignImportProgress(1, 1, 0, 0, 0, 0, 0, 0); + } + + @Test + void importStream_clientDisconnect_exercisesDefensiveBranches() throws Exception { + // Scénario de déconnexion : on termine le flux puis on continue d'émettre. + // Le 1er envoi post-complete échoue -> clientGone=true ; les callbacks suivants + // court-circuitent (ClientGoneException sur sendEvent/sendHeartbeat, early-return + // sur le callback d'erreur). Chaque étape est isolée car ClientGoneException + // (privée) se propage hors des callbacks — comme en prod où elle remonte au + // pipeline amont pour stopper le Brain. + doAnswer(inv -> { + Consumer onProgress = inv.getArgument(2); + Runnable onHeartbeat = inv.getArgument(3); + Consumer onDone = inv.getArgument(5); + Consumer onError = inv.getArgument(6); + + // 1) Termine proprement le flux (event "done" + complete()). + onDone.accept(new CampaignImportProposal(List.of(), List.of())); + // 2) Envoi post-complete : send échoue -> catch -> clientGone=true. + try { onProgress.accept(progress()); } catch (RuntimeException ignored) { } + // 3) clientGone=true -> sendHeartbeat lève ClientGoneException (branche garde). + try { onHeartbeat.run(); } catch (RuntimeException ignored) { } + // 4) clientGone=true -> sendEvent lève ClientGoneException (branche garde). + try { onProgress.accept(progress()); } catch (RuntimeException ignored) { } + // 5) clientGone=true -> le callback d'erreur prend l'early-return (pas d'envoi). + onError.accept(new RuntimeException("tardif")); + return null; + }).when(service).importStructureStreaming(any(), any(), any(), any(), any(), any(), any()); + + MockMultipartFile file = new MockMultipartFile( + "file", "campagne.pdf", "application/pdf", new byte[]{1, 2, 3}); + + SseEmitter emitter = controller.importStream("camp-1", file); + + assertNotNull(emitter); + verify(service).importStructureStreaming(any(), any(), any(), any(), any(), any(), any()); + } +} diff --git a/core/src/test/java/com/loremind/infrastructure/web/controller/ChapterControllerTest.java b/core/src/test/java/com/loremind/infrastructure/web/controller/ChapterControllerTest.java index 734f52f..3fe743e 100644 --- a/core/src/test/java/com/loremind/infrastructure/web/controller/ChapterControllerTest.java +++ b/core/src/test/java/com/loremind/infrastructure/web/controller/ChapterControllerTest.java @@ -7,6 +7,8 @@ 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.playcontext.Playthrough; +import com.loremind.domain.playcontext.ports.PlaythroughRepository; import com.loremind.infrastructure.web.dto.campaigncontext.ChapterDTO; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -34,13 +36,18 @@ class ChapterControllerTest { @Autowired private CampaignRepository campaignRepository; @Autowired private ArcRepository arcRepository; @Autowired private ChapterRepository chapterRepository; + @Autowired private PlaythroughRepository playthroughRepository; private String arcId; + private String playthroughId; @BeforeEach void setUp() { String campaignId = campaignRepository.save(Campaign.builder().name("C").description("").build()).getId(); arcId = arcRepository.save(Arc.builder().campaignId(campaignId).name("A").order(0).build()).getId(); + // playthroughId REEL (id numerique) : l'enrichissement du statut le parse. + playthroughId = playthroughRepository.save( + Playthrough.builder().campaignId(campaignId).name("Table").description("").build()).getId(); } @Test @@ -106,4 +113,52 @@ class ChapterControllerTest { mockMvc.perform(delete("/api/chapters/{id}", saved.getId())) .andExpect(status().isNoContent()); } + + // getById avec ?playthroughId= : branche d'enrichissement du statut. + // Le playthrough est inexistant -> snapshot vide -> statut NOT_STARTED, mais la branche est couverte. + @Test + void getById_withPlaythroughId_enrichesStatus() throws Exception { + Chapter saved = chapterRepository.save(Chapter.builder().arcId(arcId).name("Ch").order(0).build()); + mockMvc.perform(get("/api/chapters/{id}", saved.getId()).param("playthroughId", playthroughId)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.progressionStatus").value("NOT_STARTED")); + } + + // getAll avec ?playthroughId= : branche d'enrichissement sur la liste. + @Test + void getAll_withPlaythroughId_enrichesStatus() throws Exception { + chapterRepository.save(Chapter.builder().arcId(arcId).name("A").order(0).build()); + mockMvc.perform(get("/api/chapters").param("playthroughId", playthroughId)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].progressionStatus").value("NOT_STARTED")); + } + + // deletion-impact : chapitre existant -> 200 avec le compte de scènes (0 ici). + @Test + void deletionImpact_returns200_whenExists() throws Exception { + Chapter saved = chapterRepository.save(Chapter.builder().arcId(arcId).name("Ch").order(0).build()); + mockMvc.perform(get("/api/chapters/{id}/deletion-impact", saved.getId())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.scenes").value(0)); + } + + // deletion-impact : chapitre inexistant -> 404. + @Test + void deletionImpact_returns404_whenMissing() throws Exception { + mockMvc.perform(get("/api/chapters/{id}/deletion-impact", "999999999")) + .andExpect(status().isNotFound()); + } + + // update sur id inexistant : le service lève IllegalArgumentException -> 400. + @Test + void update_returns400_whenMissing() throws Exception { + ChapterDTO dto = new ChapterDTO(); + dto.setName("new"); + dto.setArcId(arcId); + dto.setOrder(0); + mockMvc.perform(put("/api/chapters/{id}", "999999999") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(dto))) + .andExpect(status().isBadRequest()); + } } diff --git a/core/src/test/java/com/loremind/infrastructure/web/controller/CharacterControllerTest.java b/core/src/test/java/com/loremind/infrastructure/web/controller/CharacterControllerTest.java new file mode 100644 index 0000000..137b2b1 --- /dev/null +++ b/core/src/test/java/com/loremind/infrastructure/web/controller/CharacterControllerTest.java @@ -0,0 +1,128 @@ +package com.loremind.infrastructure.web.controller; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.loremind.domain.campaigncontext.Campaign; +import com.loremind.domain.campaigncontext.Character; +import com.loremind.domain.campaigncontext.ports.CampaignRepository; +import com.loremind.domain.campaigncontext.ports.CharacterRepository; +import com.loremind.domain.playcontext.Playthrough; +import com.loremind.domain.playcontext.ports.PlaythroughRepository; +import com.loremind.infrastructure.web.dto.campaigncontext.CharacterDTO; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.transaction.annotation.Transactional; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** Tests d'intégration CRUD du CharacterController (PJ liés à un Playthrough). */ +@SpringBootTest +@AutoConfigureMockMvc +@Transactional +class CharacterControllerTest { + + @Autowired private MockMvc mockMvc; + @Autowired private ObjectMapper objectMapper; + @Autowired private CampaignRepository campaignRepository; + @Autowired private PlaythroughRepository playthroughRepository; + @Autowired private CharacterRepository characterRepository; + + private String playthroughId; + + @BeforeEach + void setUp() { + String campId = campaignRepository.save(Campaign.builder().name("C").description("").build()).getId(); + playthroughId = playthroughRepository.save( + Playthrough.builder().campaignId(campId).name("Table").build()).getId(); + } + + @Test + void create_returns200() throws Exception { + CharacterDTO dto = new CharacterDTO(); + dto.setName("Aragorn"); + dto.setPlaythroughId(playthroughId); + mockMvc.perform(post("/api/characters") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(dto))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.name").value("Aragorn")); + } + + @Test + void getById_returns200() throws Exception { + Character saved = characterRepository.save( + Character.builder().playthroughId(playthroughId).name("PJ").order(0).build()); + mockMvc.perform(get("/api/characters/{id}", saved.getId())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.name").value("PJ")); + } + + @Test + void getById_returns404_whenMissing() throws Exception { + mockMvc.perform(get("/api/characters/{id}", "999999999")) + .andExpect(status().isNotFound()); + } + + @Test + void getByPlaythrough_returnsArray() throws Exception { + characterRepository.save(Character.builder().playthroughId(playthroughId).name("A").order(0).build()); + mockMvc.perform(get("/api/characters/playthrough/{playthroughId}", playthroughId)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$").isArray()); + } + + /** Recherche enrichie : le campaignId est résolu via le Playthrough. */ + @Test + void search_returnsEnrichedResult() throws Exception { + characterRepository.save(Character.builder().playthroughId(playthroughId).name("Legolas").order(0).build()); + mockMvc.perform(get("/api/characters/search").param("q", "Legolas")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$").isArray()) + .andExpect(jsonPath("$[0].name").value("Legolas")) + .andExpect(jsonPath("$[0].campaignId").exists()); + } + + @Test + void update_returns200() throws Exception { + Character saved = characterRepository.save( + Character.builder().playthroughId(playthroughId).name("old").order(0).build()); + CharacterDTO dto = new CharacterDTO(); + dto.setName("new"); + dto.setPlaythroughId(playthroughId); + dto.setOrder(0); + mockMvc.perform(put("/api/characters/{id}", saved.getId()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(dto))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.name").value("new")); + } + + @Test + void update_returns400_whenMissing() throws Exception { + CharacterDTO dto = new CharacterDTO(); + dto.setName("x"); + dto.setPlaythroughId(playthroughId); + dto.setOrder(0); + mockMvc.perform(put("/api/characters/{id}", "999999999") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(dto))) + .andExpect(status().isBadRequest()); + } + + @Test + void delete_returns204() throws Exception { + Character saved = characterRepository.save( + Character.builder().playthroughId(playthroughId).name("X").order(0).build()); + mockMvc.perform(delete("/api/characters/{id}", saved.getId())) + .andExpect(status().isNoContent()); + } +} diff --git a/core/src/test/java/com/loremind/infrastructure/web/controller/ConfigControllerTest.java b/core/src/test/java/com/loremind/infrastructure/web/controller/ConfigControllerTest.java new file mode 100644 index 0000000..6ae0c9a --- /dev/null +++ b/core/src/test/java/com/loremind/infrastructure/web/controller/ConfigControllerTest.java @@ -0,0 +1,46 @@ +package com.loremind.infrastructure.web.controller; + +import com.loremind.infrastructure.updates.UpdateCheckService; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.test.web.servlet.MockMvc; + +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * Tests d'integration pour {@link ConfigController}. + *

+ * GET /api/config renvoie {demoMode, updateCheckEnabled}. Le service de mise a + * jour est mocke pour piloter la valeur de updateCheckEnabled sans dependre de + * la configuration reelle (Watchtower/registry indisponibles en test). + */ +@SpringBootTest +@AutoConfigureMockMvc +class ConfigControllerTest { + + @Autowired private MockMvc mockMvc; + @MockBean private UpdateCheckService updates; + + @Test + void getPublicConfig_returns200_updateCheckEnabledTrue() throws Exception { + when(updates.isEnabled()).thenReturn(true); + mockMvc.perform(get("/api/config")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.updateCheckEnabled").value(true)) + .andExpect(jsonPath("$.demoMode").value(false)); + } + + @Test + void getPublicConfig_returns200_updateCheckEnabledFalse() throws Exception { + when(updates.isEnabled()).thenReturn(false); + mockMvc.perform(get("/api/config")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.updateCheckEnabled").value(false)); + } +} diff --git a/core/src/test/java/com/loremind/infrastructure/web/controller/EnemyControllerTest.java b/core/src/test/java/com/loremind/infrastructure/web/controller/EnemyControllerTest.java new file mode 100644 index 0000000..bc51b38 --- /dev/null +++ b/core/src/test/java/com/loremind/infrastructure/web/controller/EnemyControllerTest.java @@ -0,0 +1,117 @@ +package com.loremind.infrastructure.web.controller; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.loremind.domain.campaigncontext.Campaign; +import com.loremind.domain.campaigncontext.Enemy; +import com.loremind.domain.campaigncontext.ports.CampaignRepository; +import com.loremind.domain.campaigncontext.ports.EnemyRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Map; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** Tests d'intégration CRUD du EnemyController (bestiaire de campagne). */ +@SpringBootTest +@AutoConfigureMockMvc +@Transactional +class EnemyControllerTest { + + @Autowired private MockMvc mockMvc; + @Autowired private ObjectMapper objectMapper; + @Autowired private CampaignRepository campaignRepository; + @Autowired private EnemyRepository enemyRepository; + + private String campaignId; + + @BeforeEach + void setUp() { + campaignId = campaignRepository.save(Campaign.builder().name("C").description("").build()).getId(); + } + + /** Requête = record EnemyRequest(name, level, folder, ..., campaignId, order). */ + @Test + void create_returns200() throws Exception { + EnemyController.EnemyRequest req = new EnemyController.EnemyRequest( + "Gobelin", "FP 1", "Humanoïdes", null, null, + Map.of(), Map.of(), Map.of(), campaignId, null); + mockMvc.perform(post("/api/enemies") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(req))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.name").value("Gobelin")); + } + + @Test + void getById_returns200() throws Exception { + Enemy saved = enemyRepository.save(Enemy.builder().campaignId(campaignId).name("E").order(0).build()); + mockMvc.perform(get("/api/enemies/{id}", saved.getId())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.name").value("E")); + } + + @Test + void getById_returns404_whenMissing() throws Exception { + mockMvc.perform(get("/api/enemies/{id}", "999999999")) + .andExpect(status().isNotFound()); + } + + @Test + void getByCampaign_returnsArray() throws Exception { + enemyRepository.save(Enemy.builder().campaignId(campaignId).name("A").order(0).build()); + mockMvc.perform(get("/api/enemies/campaign/{campaignId}", campaignId)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$").isArray()); + } + + @Test + void search_returnsArray() throws Exception { + enemyRepository.save(Enemy.builder().campaignId(campaignId).name("Dragon").order(0).build()); + mockMvc.perform(get("/api/enemies/search").param("q", "Dragon")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$").isArray()); + } + + @Test + void update_returns200() throws Exception { + Enemy saved = enemyRepository.save(Enemy.builder().campaignId(campaignId).name("old").order(0).build()); + EnemyController.EnemyRequest req = new EnemyController.EnemyRequest( + "new", null, null, null, null, + Map.of(), Map.of(), Map.of(), campaignId, 0); + mockMvc.perform(put("/api/enemies/{id}", saved.getId()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(req))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.name").value("new")); + } + + @Test + void update_returns400_whenMissing() throws Exception { + EnemyController.EnemyRequest req = new EnemyController.EnemyRequest( + "x", null, null, null, null, + Map.of(), Map.of(), Map.of(), campaignId, 0); + mockMvc.perform(put("/api/enemies/{id}", "999999999") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(req))) + .andExpect(status().isBadRequest()); + } + + @Test + void delete_returns204() throws Exception { + Enemy saved = enemyRepository.save(Enemy.builder().campaignId(campaignId).name("X").order(0).build()); + mockMvc.perform(delete("/api/enemies/{id}", saved.getId())) + .andExpect(status().isNoContent()); + } +} diff --git a/core/src/test/java/com/loremind/infrastructure/web/controller/GameSystemControllerTest.java b/core/src/test/java/com/loremind/infrastructure/web/controller/GameSystemControllerTest.java index 1e1584c..fe99a3f 100644 --- a/core/src/test/java/com/loremind/infrastructure/web/controller/GameSystemControllerTest.java +++ b/core/src/test/java/com/loremind/infrastructure/web/controller/GameSystemControllerTest.java @@ -1,23 +1,43 @@ package com.loremind.infrastructure.web.controller; import com.fasterxml.jackson.databind.ObjectMapper; +import com.loremind.domain.gamesystemcontext.RulesImportProgress; +import com.loremind.domain.gamesystemcontext.RulesImportResult; +import com.loremind.domain.gamesystemcontext.ports.RulesImportException; +import com.loremind.domain.gamesystemcontext.ports.RulesPdfImporter; import com.loremind.infrastructure.web.dto.gamesystemcontext.GameSystemDTO; import com.loremind.infrastructure.web.dto.shared.TemplateFieldDTO; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.core.task.TaskExecutor; import org.springframework.http.MediaType; +import org.springframework.mock.web.MockMultipartFile; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.MvcResult; import org.springframework.transaction.annotation.Transactional; import java.util.List; +import java.util.Map; +import java.util.function.Consumer; +import static org.hamcrest.Matchers.containsString; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.asyncDispatch; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.request; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; /** @@ -32,6 +52,29 @@ class GameSystemControllerTest { @Autowired private MockMvc mockMvc; @Autowired private ObjectMapper objectMapper; + @MockBean private RulesPdfImporter rulesPdfImporter; + @MockBean(name = "applicationTaskExecutor") private TaskExecutor taskExecutor; + + @BeforeEach + void setUp() { + // Tache de l'import streame executee en ligne -> events SSE deterministes. + doAnswer(inv -> { ((Runnable) inv.getArgument(0)).run(); return null; }) + .when(taskExecutor).execute(any(Runnable.class)); + } + + /** Cree un GameSystem minimal via l'API et renvoie son id. */ + private String createGameSystem(String name) throws Exception { + GameSystemDTO dto = new GameSystemDTO(); + dto.setName(name); + MvcResult posted = mockMvc.perform(post("/api/game-systems") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(dto))) + .andExpect(status().isOk()) + .andReturn(); + return objectMapper.readValue( + posted.getResponse().getContentAsString(), GameSystemDTO.class).getId(); + } + @Test void create_persistsCharacterAndNpcTemplates() throws Exception { GameSystemDTO dto = new GameSystemDTO(); @@ -105,4 +148,228 @@ class GameSystemControllerTest { .content(objectMapper.writeValueAsString(dto))) .andExpect(status().is4xxClientError()); } + + @Test + void create_persistsEnemyTemplate() throws Exception { + GameSystemDTO dto = new GameSystemDTO(); + dto.setName("Bestiaire"); + dto.setEnemyTemplate(List.of( + new TemplateFieldDTO("Niveau", "NUMBER", null), + new TemplateFieldDTO("Tactique", "TEXT", null))); + + mockMvc.perform(post("/api/game-systems") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(dto))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.enemyTemplate.length()").value(2)) + .andExpect(jsonPath("$.enemyTemplate[0].name").value("Niveau")) + .andExpect(jsonPath("$.enemyTemplate[1].type").value("TEXT")); + } + + // --- CRUD : lecture / recherche / suppression --------------------------- + + @Test + void getById_returns200() throws Exception { + String id = createGameSystem("Lisible"); + mockMvc.perform(get("/api/game-systems/{id}", id)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id").value(id)) + .andExpect(jsonPath("$.name").value("Lisible")); + } + + @Test + void getById_returns404_whenMissing() throws Exception { + mockMvc.perform(get("/api/game-systems/{id}", "999999999")) + .andExpect(status().isNotFound()); + } + + @Test + void getAll_returnsArray() throws Exception { + createGameSystem("Systeme A"); + mockMvc.perform(get("/api/game-systems")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$").isArray()); + } + + @Test + void search_returnsMatching() throws Exception { + createGameSystem("Dragonbane Unique"); + mockMvc.perform(get("/api/game-systems/search").param("q", "Dragonbane")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$").isArray()) + .andExpect(jsonPath("$[?(@.name == 'Dragonbane Unique')]").exists()); + } + + @Test + void delete_returns204_thenGone() throws Exception { + String id = createGameSystem("A supprimer"); + mockMvc.perform(delete("/api/game-systems/{id}", id)) + .andExpect(status().isNoContent()); + mockMvc.perform(get("/api/game-systems/{id}", id)) + .andExpect(status().isNotFound()); + } + + // --- Import de regles (PDF) : multipart --------------------------------- + + @Test + void importRules_returns200_withSections() throws Exception { + when(rulesPdfImporter.importRules(any(), any())) + .thenReturn(new RulesImportResult(Map.of("Combat", "## Combat\n- d20"), 5, 1)); + MockMultipartFile file = new MockMultipartFile( + "file", "regles.pdf", "application/pdf", new byte[]{1, 2, 3}); + + mockMvc.perform(multipart("/api/game-systems/import-rules").file(file)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.pageCount").value(5)) + .andExpect(jsonPath("$.ocrPageCount").value(1)) + .andExpect(jsonPath("$.sections.Combat").value("## Combat\n- d20")); + } + + @Test + void importRules_returns400_whenFileEmpty() throws Exception { + MockMultipartFile empty = new MockMultipartFile( + "file", "regles.pdf", "application/pdf", new byte[0]); + mockMvc.perform(multipart("/api/game-systems/import-rules").file(empty)) + .andExpect(status().isBadRequest()); + } + + @Test + void importRules_returns502_whenBrainFails() throws Exception { + when(rulesPdfImporter.importRules(any(), any())) + .thenThrow(new RulesImportException("Brain injoignable")); + MockMultipartFile file = new MockMultipartFile( + "file", "regles.pdf", "application/pdf", new byte[]{1, 2, 3}); + + mockMvc.perform(multipart("/api/game-systems/import-rules").file(file)) + .andExpect(status().isBadGateway()); + } + + // --- Import de regles streame (SSE) ------------------------------------- + + @Test + void importRulesStream_emitsProgressThenDone() throws Exception { + doAnswer(inv -> { + Consumer onProgress = inv.getArgument(2); + Consumer onDone = inv.getArgument(5); + onProgress.accept(new RulesImportProgress(1, 2, 5, 0, List.of("Combat"))); + onDone.accept(new RulesImportResult(Map.of("Combat", "## Combat"), 5, 0)); + return null; + }).when(rulesPdfImporter).importRulesStreaming( + any(), any(), any(), any(), any(), any(), any()); + + MockMultipartFile file = new MockMultipartFile( + "file", "regles.pdf", "application/pdf", new byte[]{1, 2, 3}); + MvcResult result = mockMvc.perform( + multipart("/api/game-systems/import-rules/stream").file(file)) + .andExpect(request().asyncStarted()) + .andReturn(); + + mockMvc.perform(asyncDispatch(result)) + .andExpect(status().isOk()) + .andExpect(content().string(containsString("progress"))) + .andExpect(content().string(containsString("done"))) + .andExpect(content().string(containsString("Combat"))); + } + + @Test + void importRulesStream_emptyFile_emitsError() throws Exception { + MockMultipartFile empty = new MockMultipartFile( + "file", "regles.pdf", "application/pdf", new byte[0]); + MvcResult result = mockMvc.perform( + multipart("/api/game-systems/import-rules/stream").file(empty)) + .andExpect(request().asyncStarted()) + .andReturn(); + + mockMvc.perform(asyncDispatch(result)) + .andExpect(status().isOk()) + .andExpect(content().string(containsString("error"))) + .andExpect(content().string(containsString("vide"))); + } + + @Test + void importRulesStream_brainError_emitsError() throws Exception { + doAnswer(inv -> { + Consumer onError = inv.getArgument(6); + onError.accept(new RuntimeException("structuration KO")); + return null; + }).when(rulesPdfImporter).importRulesStreaming( + any(), any(), any(), any(), any(), any(), any()); + + MockMultipartFile file = new MockMultipartFile( + "file", "regles.pdf", "application/pdf", new byte[]{1, 2, 3}); + MvcResult result = mockMvc.perform( + multipart("/api/game-systems/import-rules/stream").file(file)) + .andExpect(request().asyncStarted()) + .andReturn(); + + mockMvc.perform(asyncDispatch(result)) + .andExpect(status().isOk()) + .andExpect(content().string(containsString("error"))) + .andExpect(content().string(containsString("structuration KO"))); + } + + /** + * Couvre {@code sendImportHeartbeat} (callback onHeartbeat, arg 3) ET la branche + * "status" de {@code sendImportEvent} (callback onStatus, arg 4) : un import qui + * envoie un keepalive et un message de statut avant de produire son resultat. + */ + @Test + void importRulesStream_emitsHeartbeatAndStatus_thenDone() throws Exception { + doAnswer(inv -> { + Runnable onHeartbeat = inv.getArgument(3); + Consumer onStatus = inv.getArgument(4); + Consumer onDone = inv.getArgument(5); + // Keepalive (commentaire SSE, ignore par le front) -> sendImportHeartbeat. + onHeartbeat.run(); + // Message de statut lisible -> sendImportEvent(..., "status", ...). + onStatus.accept("Fournisseur sature, nouvelle tentative..."); + onDone.accept(new RulesImportResult(Map.of("Combat", "## Combat"), 5, 0)); + return null; + }).when(rulesPdfImporter).importRulesStreaming( + any(), any(), any(), any(), any(), any(), any()); + + MockMultipartFile file = new MockMultipartFile( + "file", "regles.pdf", "application/pdf", new byte[]{1, 2, 3}); + MvcResult result = mockMvc.perform( + multipart("/api/game-systems/import-rules/stream").file(file)) + .andExpect(request().asyncStarted()) + .andReturn(); + + mockMvc.perform(asyncDispatch(result)) + .andExpect(status().isOk()) + // Le commentaire keepalive est present dans le flux brut. + .andExpect(content().string(containsString("keepalive"))) + // L'event "status" et son message sont serialises. + .andExpect(content().string(containsString("status"))) + .andExpect(content().string(containsString("Fournisseur sature"))) + .andExpect(content().string(containsString("done"))); + } + + /** + * Couvre la branche onStatus avec un message null : le controleur substitue une + * chaine vide ({@code status != null ? status : ""}) sans planter. + */ + @Test + void importRulesStream_nullStatus_emitsEmptyStatus() throws Exception { + doAnswer(inv -> { + Consumer onStatus = inv.getArgument(4); + Consumer onDone = inv.getArgument(5); + onStatus.accept(null); + onDone.accept(new RulesImportResult(Map.of("Combat", "## Combat"), 5, 0)); + return null; + }).when(rulesPdfImporter).importRulesStreaming( + any(), any(), any(), any(), any(), any(), any()); + + MockMultipartFile file = new MockMultipartFile( + "file", "regles.pdf", "application/pdf", new byte[]{1, 2, 3}); + MvcResult result = mockMvc.perform( + multipart("/api/game-systems/import-rules/stream").file(file)) + .andExpect(request().asyncStarted()) + .andReturn(); + + mockMvc.perform(asyncDispatch(result)) + .andExpect(status().isOk()) + .andExpect(content().string(containsString("status"))) + .andExpect(content().string(containsString("done"))); + } } diff --git a/core/src/test/java/com/loremind/infrastructure/web/controller/GameSystemControllerUnitTest.java b/core/src/test/java/com/loremind/infrastructure/web/controller/GameSystemControllerUnitTest.java new file mode 100644 index 0000000..4552ca0 --- /dev/null +++ b/core/src/test/java/com/loremind/infrastructure/web/controller/GameSystemControllerUnitTest.java @@ -0,0 +1,73 @@ +package com.loremind.infrastructure.web.controller; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.loremind.application.gamesystemcontext.GameSystemService; +import com.loremind.domain.gamesystemcontext.RulesImportProgress; +import com.loremind.domain.gamesystemcontext.RulesImportResult; +import com.loremind.infrastructure.web.mapper.GameSystemMapper; +import com.loremind.infrastructure.web.mapper.TemplateFieldMapper; +import org.junit.jupiter.api.Test; +import org.springframework.core.task.TaskExecutor; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; + +import java.util.List; +import java.util.Map; +import java.util.function.Consumer; + +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; + +/** + * Test UNITAIRE pur (sans Spring) de {@link GameSystemController} dédié au code + * défensif de déconnexion du navigateur de l'import streamé, INTESTABLE via MockMvc + * (un envoi SSE après {@code complete()} y remonte en ServletException au lieu + * d'exécuter la branche). Voir {@link CampaignImportControllerUnitTest} pour le détail + * de l'approche — même séquence : complete -> send échoue -> {@code clientGone=true} + * -> les callbacks suivants empruntent les branches {@code ClientGoneException}. + */ +class GameSystemControllerUnitTest { + + private final GameSystemService service = mock(GameSystemService.class); + private final TaskExecutor inlineExecutor = Runnable::run; + private final GameSystemController controller = new GameSystemController( + service, mock(GameSystemMapper.class), mock(TemplateFieldMapper.class), + inlineExecutor, new ObjectMapper()); + + private static RulesImportProgress progress() { + return new RulesImportProgress(1, 1, 0, 0, List.of()); + } + + @Test + void importRulesStream_clientDisconnect_exercisesDefensiveBranches() throws Exception { + doAnswer(inv -> { + Consumer onProgress = inv.getArgument(2); + Runnable onHeartbeat = inv.getArgument(3); + Consumer onDone = inv.getArgument(5); + Consumer onError = inv.getArgument(6); + + // 1) Termine le flux (event "done" + complete()). + onDone.accept(new RulesImportResult(Map.of(), 0, 0)); + // 2) Envoi post-complete : send échoue -> catch -> clientGone=true. + try { onProgress.accept(progress()); } catch (RuntimeException ignored) { } + // 3) clientGone=true -> sendImportHeartbeat lève ClientGoneException. + try { onHeartbeat.run(); } catch (RuntimeException ignored) { } + // 4) clientGone=true -> sendImportEvent lève ClientGoneException. + try { onProgress.accept(progress()); } catch (RuntimeException ignored) { } + // 5) clientGone=true -> callback d'erreur en early-return. + onError.accept(new RuntimeException("tardif")); + return null; + }).when(service).importRulesFromPdfStreaming(any(), any(), any(), any(), any(), any(), any()); + + MockMultipartFile file = new MockMultipartFile( + "file", "regles.pdf", "application/pdf", new byte[]{1, 2, 3}); + + SseEmitter emitter = controller.importRulesStream(file); + + assertNotNull(emitter); + verify(service).importRulesFromPdfStreaming(any(), any(), any(), any(), any(), any(), any()); + } +} diff --git a/core/src/test/java/com/loremind/infrastructure/web/controller/ImageControllerTest.java b/core/src/test/java/com/loremind/infrastructure/web/controller/ImageControllerTest.java new file mode 100644 index 0000000..2df5fb4 --- /dev/null +++ b/core/src/test/java/com/loremind/infrastructure/web/controller/ImageControllerTest.java @@ -0,0 +1,158 @@ +package com.loremind.infrastructure.web.controller; + +import com.loremind.application.images.ImageService; +import com.loremind.domain.images.Image; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.test.web.servlet.MockMvc; + +import java.io.ByteArrayInputStream; +import java.io.InputStream; +import java.time.LocalDateTime; +import java.util.Optional; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * Tests d'integration pour {@link ImageController} (Shared Kernel images). + *

+ * Le {@link ImageService} est mocke : il orchestre sinon MinIO (binaire) + + * Postgres (metadonnees) via ses deux ports. On evite ainsi tout acces a MinIO, + * indisponible en test, tout en couvrant le mapping HTTP du controleur + * (upload / metadata / content streaming / delete + cas 400 / 404). + */ +@SpringBootTest +@AutoConfigureMockMvc +class ImageControllerTest { + + @Autowired private MockMvc mockMvc; + + @MockBean private ImageService imageService; + + private Image sampleImage() { + return Image.builder() + .id("img-1") + .filename("portrait.png") + .contentType("image/png") + .sizeBytes(3) + .storageKey("images/img-1.png") + .uploadedAt(LocalDateTime.of(2026, 1, 1, 12, 0)) + .build(); + } + + private MockMultipartFile pngFile(byte[] bytes) { + return new MockMultipartFile("file", "portrait.png", "image/png", bytes); + } + + // --- POST /api/images -------------------------------------------------- + + @Test + void upload_returns200_withMetadata() throws Exception { + when(imageService.upload(eq("portrait.png"), eq("image/png"), any(InputStream.class), anyLong())) + .thenReturn(sampleImage()); + + mockMvc.perform(multipart("/api/images").file(pngFile(new byte[]{1, 2, 3}))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id").value("img-1")) + .andExpect(jsonPath("$.filename").value("portrait.png")) + .andExpect(jsonPath("$.contentType").value("image/png")) + .andExpect(jsonPath("$.sizeBytes").value(3)) + .andExpect(jsonPath("$.url").value("/api/images/img-1/content")); + } + + @Test + void upload_returns400_whenEmpty() throws Exception { + mockMvc.perform(multipart("/api/images").file(pngFile(new byte[0]))) + .andExpect(status().isBadRequest()); + } + + @Test + void upload_returns400_whenServiceRejects() throws Exception { + // Validation metier (MIME non autorise, taille...) -> IllegalArgumentException. + when(imageService.upload(any(), any(), any(InputStream.class), anyLong())) + .thenThrow(new IllegalArgumentException("Type de fichier non supporte.")); + + mockMvc.perform(multipart("/api/images") + .file(new MockMultipartFile("file", "evil.exe", "application/octet-stream", + new byte[]{1, 2, 3}))) + .andExpect(status().isBadRequest()); + } + + // --- GET /api/images/{id} ---------------------------------------------- + + @Test + void getMetadata_returns200() throws Exception { + when(imageService.getById("img-1")).thenReturn(Optional.of(sampleImage())); + + mockMvc.perform(get("/api/images/{id}", "img-1")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id").value("img-1")) + .andExpect(jsonPath("$.url").value("/api/images/img-1/content")); + } + + @Test + void getMetadata_returns404_whenMissing() throws Exception { + when(imageService.getById("nope")).thenReturn(Optional.empty()); + + mockMvc.perform(get("/api/images/{id}", "nope")) + .andExpect(status().isNotFound()); + } + + // --- GET /api/images/{id}/content -------------------------------------- + + @Test + void getContent_returns200_withBinary() throws Exception { + byte[] data = {10, 20, 30}; + when(imageService.getById("img-1")).thenReturn(Optional.of(sampleImage())); + when(imageService.downloadById("img-1")) + .thenReturn(Optional.of(new ByteArrayInputStream(data))); + + mockMvc.perform(get("/api/images/{id}/content", "img-1")) + .andExpect(status().isOk()) + .andExpect(header().string("Content-Type", "image/png")) + .andExpect(header().string("Cross-Origin-Resource-Policy", "cross-origin")) + .andExpect(content().bytes(data)); + } + + @Test + void getContent_returns404_whenMetadataMissing() throws Exception { + when(imageService.getById("nope")).thenReturn(Optional.empty()); + + mockMvc.perform(get("/api/images/{id}/content", "nope")) + .andExpect(status().isNotFound()); + } + + @Test + void getContent_returns404_whenBinaryLost() throws Exception { + // Metadonnees presentes mais binaire absent (incoherence) -> 404. + when(imageService.getById("img-1")).thenReturn(Optional.of(sampleImage())); + when(imageService.downloadById("img-1")).thenReturn(Optional.empty()); + + mockMvc.perform(get("/api/images/{id}/content", "img-1")) + .andExpect(status().isNotFound()); + } + + // --- DELETE /api/images/{id} ------------------------------------------- + + @Test + void delete_returns204() throws Exception { + mockMvc.perform(delete("/api/images/{id}", "img-1")) + .andExpect(status().isNoContent()); + verify(imageService).deleteById("img-1"); + } +} diff --git a/core/src/test/java/com/loremind/infrastructure/web/controller/ItemCatalogControllerTest.java b/core/src/test/java/com/loremind/infrastructure/web/controller/ItemCatalogControllerTest.java new file mode 100644 index 0000000..8ef3ef7 --- /dev/null +++ b/core/src/test/java/com/loremind/infrastructure/web/controller/ItemCatalogControllerTest.java @@ -0,0 +1,186 @@ +package com.loremind.infrastructure.web.controller; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.loremind.domain.campaigncontext.Campaign; +import com.loremind.domain.campaigncontext.CatalogItem; +import com.loremind.domain.campaigncontext.ItemCatalog; +import com.loremind.domain.campaigncontext.ports.CampaignRepository; +import com.loremind.domain.campaigncontext.ports.ItemCatalogGenerationException; +import com.loremind.domain.campaigncontext.ports.ItemCatalogGenerator; +import com.loremind.domain.campaigncontext.ports.ItemCatalogRepository; +import com.loremind.infrastructure.web.controller.ItemCatalogController.GenerateRequest; +import com.loremind.infrastructure.web.dto.campaigncontext.ItemCatalogDTO; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * Tests d'integration pour {@link ItemCatalogController} (CRUD + recherche + + * generation IA d'un catalogue d'objets). + *

+ * Le port {@link ItemCatalogGenerator} (client du Brain) est mocke : sinon + * l'endpoint /generate ferait un vrai appel reseau au service IA (indisponible + * en test). Les repos reels servent aux fixtures. + */ +@SpringBootTest +@AutoConfigureMockMvc +@Transactional +class ItemCatalogControllerTest { + + @Autowired private MockMvc mockMvc; + @Autowired private ObjectMapper objectMapper; + @Autowired private ItemCatalogRepository catalogRepository; + @Autowired private CampaignRepository campaignRepository; + + @MockBean private ItemCatalogGenerator generator; + + private String campaignId; + + @BeforeEach + void setUp() { + campaignId = campaignRepository.save( + Campaign.builder().name("Camp").description("desc").build()).getId(); + } + + private ItemCatalogDTO dto(String name) { + ItemCatalogDTO dto = new ItemCatalogDTO(); + dto.setName(name); + dto.setDescription("Une boutique"); + dto.setIcon("store"); + dto.setCampaignId(campaignId); + dto.setOrder(0); + return dto; + } + + // --- POST / ------------------------------------------------------------- + + @Test + void create_returns200() throws Exception { + mockMvc.perform(post("/api/item-catalogs") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(dto("Echoppe")))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.name").value("Echoppe")) + .andExpect(jsonPath("$.campaignId").value(campaignId)); + } + + // --- GET /{id} ---------------------------------------------------------- + + @Test + void getById_returns200() throws Exception { + ItemCatalog saved = catalogRepository.save(ItemCatalog.builder() + .name("Tresor").campaignId(campaignId).order(0).build()); + mockMvc.perform(get("/api/item-catalogs/{id}", saved.getId())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.name").value("Tresor")); + } + + @Test + void getById_returns404_whenMissing() throws Exception { + mockMvc.perform(get("/api/item-catalogs/{id}", "999999999")) + .andExpect(status().isNotFound()); + } + + // --- GET /campaign/{campaignId} ----------------------------------------- + + @Test + void getByCampaign_returnsArray() throws Exception { + catalogRepository.save(ItemCatalog.builder() + .name("A").campaignId(campaignId).order(0).build()); + mockMvc.perform(get("/api/item-catalogs/campaign/{campaignId}", campaignId)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$").isArray()) + .andExpect(jsonPath("$[0].name").value("A")); + } + + // --- PUT /{id} ---------------------------------------------------------- + + @Test + void update_returns200() throws Exception { + ItemCatalog saved = catalogRepository.save(ItemCatalog.builder() + .name("old").campaignId(campaignId).order(0).build()); + ItemCatalogDTO dto = dto("new"); + mockMvc.perform(put("/api/item-catalogs/{id}", saved.getId()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(dto))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.name").value("new")); + } + + @Test + void update_returns400_whenMissing() throws Exception { + // Le service leve IllegalArgumentException -> GlobalExceptionHandler -> 400. + mockMvc.perform(put("/api/item-catalogs/{id}", "999999999") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(dto("x")))) + .andExpect(status().isBadRequest()); + } + + // --- DELETE /{id} ------------------------------------------------------- + + @Test + void delete_returns204() throws Exception { + ItemCatalog saved = catalogRepository.save(ItemCatalog.builder() + .name("X").campaignId(campaignId).order(0).build()); + mockMvc.perform(delete("/api/item-catalogs/{id}", saved.getId())) + .andExpect(status().isNoContent()); + } + + // --- GET /search -------------------------------------------------------- + + @Test + void search_returnsArray() throws Exception { + catalogRepository.save(ItemCatalog.builder() + .name("Forge de Naheulbeuk").campaignId(campaignId).order(0).build()); + mockMvc.perform(get("/api/item-catalogs/search").param("q", "Forge")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$").isArray()); + } + + // --- POST /generate ----------------------------------------------------- + + @Test + void generate_returns200() throws Exception { + when(generator.generate(any(), any())).thenReturn( + new ItemCatalogGenerator.GeneratedCatalog( + "Boutique magique", + "Objets enchantes", + List.of(CatalogItem.builder().name("Potion").price("50 po").build()))); + + GenerateRequest req = new GenerateRequest(campaignId, "une boutique de magie"); + mockMvc.perform(post("/api/item-catalogs/generate") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(req))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.name").value("Boutique magique")); + } + + @Test + void generate_returns502_whenBrainUnreachable() throws Exception { + when(generator.generate(any(), any())) + .thenThrow(new ItemCatalogGenerationException("Brain injoignable")); + + GenerateRequest req = new GenerateRequest(campaignId, "boutique"); + mockMvc.perform(post("/api/item-catalogs/generate") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(req))) + .andExpect(status().isBadGateway()); + } +} diff --git a/core/src/test/java/com/loremind/infrastructure/web/controller/LicenseControllerTest.java b/core/src/test/java/com/loremind/infrastructure/web/controller/LicenseControllerTest.java new file mode 100644 index 0000000..7899e7c --- /dev/null +++ b/core/src/test/java/com/loremind/infrastructure/web/controller/LicenseControllerTest.java @@ -0,0 +1,261 @@ +package com.loremind.infrastructure.web.controller; + +import com.loremind.application.licensing.ChannelSwitcherService; +import com.loremind.application.licensing.LicenseService; +import com.loremind.application.licensing.LicenseService.InstallException; +import com.loremind.domain.licensing.LicenseSnapshot; +import com.loremind.domain.licensing.LicenseStatus; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.util.Base64; + +import static org.mockito.ArgumentMatchers.anyBoolean; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * Tests d'integration pour {@link LicenseController} (gestion licence Patreon). + *

+ * {@code /api/license/**} exige le role ADMIN (HTTP Basic) : chaque requete porte + * l'entete d'auth construite a partir des identifiants de test (cf. + * src/test/resources/application.properties). Sans cet entete, la securite + * renverrait 401. + *

+ * Les deux services applicatifs sont mockes : {@link LicenseService} (qui appelle + * sinon le relais OAuth distant + verification JWT) et {@link ChannelSwitcherService} + * (qui ecrit sinon dans un volume partage avec le sidecar). Aucun acces reseau ni + * fichier reel. + */ +@SpringBootTest +@AutoConfigureMockMvc +class LicenseControllerTest { + + /** Identifiants definis dans src/test/resources/application.properties. */ + private static final String ADMIN_AUTH = "Basic " + Base64.getEncoder() + .encodeToString("test-admin:test-admin-password".getBytes(StandardCharsets.UTF_8)); + + @Autowired private MockMvc mockMvc; + + @MockBean private LicenseService licenseService; + @MockBean private ChannelSwitcherService channelSwitcher; + + private LicenseSnapshot validSnapshot() { + return new LicenseSnapshot( + LicenseStatus.VALID, "user-42", "tier-1", "li-xyz", + Instant.now().plusSeconds(3600), Instant.now(), true, true); + } + + @BeforeEach + void setUp() { + // Valeurs par defaut neutres ; chaque test surcharge ce dont il a besoin. + when(licenseService.isLicensingEnabled()).thenReturn(true); + when(licenseService.getCurrentSnapshot()).thenReturn(validSnapshot()); + } + + // --- Securite ---------------------------------------------------------- + + @Test + void getStatus_returns401_withoutAuth() throws Exception { + mockMvc.perform(get("/api/license")) + .andExpect(status().isUnauthorized()); + } + + // --- GET /api/license -------------------------------------------------- + + @Test + void getStatus_returns200() throws Exception { + mockMvc.perform(get("/api/license").header(HttpHeaders.AUTHORIZATION, ADMIN_AUTH)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.enabled").value(true)) + .andExpect(jsonPath("$.status").value("VALID")) + .andExpect(jsonPath("$.patreonUserId").value("user-42")); + } + + // --- GET /api/license/connect-url -------------------------------------- + + @Test + void getConnectUrl_returns200() throws Exception { + when(licenseService.buildConnectUrl()).thenReturn("https://relay/oauth?x=1"); + mockMvc.perform(get("/api/license/connect-url").header(HttpHeaders.AUTHORIZATION, ADMIN_AUTH)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.url").value("https://relay/oauth?x=1")); + } + + // --- POST /api/license/install ----------------------------------------- + + @Test + void install_returns200_onValidJwt() throws Exception { + when(licenseService.installToken("good-jwt")).thenReturn(validSnapshot()); + mockMvc.perform(post("/api/license/install") + .header(HttpHeaders.AUTHORIZATION, ADMIN_AUTH) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"jwt\":\"good-jwt\"}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value("VALID")); + } + + @Test + void install_returns400_whenJwtMissing() throws Exception { + mockMvc.perform(post("/api/license/install") + .header(HttpHeaders.AUTHORIZATION, ADMIN_AUTH) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"jwt\":\"\"}")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error").value("missing jwt")); + } + + @Test + void install_returns400_whenInstallFails() throws Exception { + when(licenseService.installToken("bad-jwt")) + .thenThrow(new InstallException("Invalid JWT: signature")); + mockMvc.perform(post("/api/license/install") + .header(HttpHeaders.AUTHORIZATION, ADMIN_AUTH) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"jwt\":\"bad-jwt\"}")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error").value("Invalid JWT: signature")); + } + + // --- DELETE /api/license ----------------------------------------------- + + @Test + void disconnect_returns204() throws Exception { + mockMvc.perform(delete("/api/license").header(HttpHeaders.AUTHORIZATION, ADMIN_AUTH)) + .andExpect(status().isNoContent()); + } + + // --- POST /api/license/refresh ----------------------------------------- + + @Test + void refresh_returns200() throws Exception { + mockMvc.perform(post("/api/license/refresh").header(HttpHeaders.AUTHORIZATION, ADMIN_AUTH)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value("VALID")); + } + + // --- PUT /api/license/beta-channel ------------------------------------- + + @Test + void setBetaChannel_returns200() throws Exception { + when(licenseService.setBetaChannelEnabled(eq(false))).thenReturn(validSnapshot()); + mockMvc.perform(put("/api/license/beta-channel") + .header(HttpHeaders.AUTHORIZATION, ADMIN_AUTH) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"enabled\":false}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value("VALID")); + } + + @Test + void setBetaChannel_returns409_whenNoLicense() throws Exception { + when(licenseService.setBetaChannelEnabled(anyBoolean())) + .thenThrow(new IllegalStateException("No license installed")); + mockMvc.perform(put("/api/license/beta-channel") + .header(HttpHeaders.AUTHORIZATION, ADMIN_AUTH) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"enabled\":true}")) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.error").value("No license installed")); + } + + // --- GET /api/license/channel ------------------------------------------ + + @Test + void getChannel_returns200() throws Exception { + when(channelSwitcher.getCurrentChannel()).thenReturn(ChannelSwitcherService.Channel.STABLE); + when(channelSwitcher.isSwitcherAvailable()).thenReturn(true); + when(channelSwitcher.getLastResult()).thenReturn(null); + mockMvc.perform(get("/api/license/channel").header(HttpHeaders.AUTHORIZATION, ADMIN_AUTH)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.currentChannel").value("stable")) + .andExpect(jsonPath("$.switcherAvailable").value(true)); + } + + // --- POST /api/license/channel/switch ---------------------------------- + + @Test + void switchChannel_returns400_whenChannelMissing() throws Exception { + mockMvc.perform(post("/api/license/channel/switch") + .header(HttpHeaders.AUTHORIZATION, ADMIN_AUTH) + .contentType(MediaType.APPLICATION_JSON) + .content("{}")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error").value("missing channel")); + } + + @Test + void switchChannel_returns400_whenChannelInvalid() throws Exception { + mockMvc.perform(post("/api/license/channel/switch") + .header(HttpHeaders.AUTHORIZATION, ADMIN_AUTH) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"channel\":\"nightly\"}")) + .andExpect(status().isBadRequest()); + } + + @Test + void switchChannel_returns403_whenBetaWithoutLicense() throws Exception { + // Snapshot EXPIRED -> pas d'acces beta. + when(licenseService.getCurrentSnapshot()).thenReturn(new LicenseSnapshot( + LicenseStatus.EXPIRED, null, null, null, null, null, false, false)); + mockMvc.perform(post("/api/license/channel/switch") + .header(HttpHeaders.AUTHORIZATION, ADMIN_AUTH) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"channel\":\"beta\"}")) + .andExpect(status().isForbidden()); + } + + @Test + void switchChannel_returns503_whenSwitcherUnavailable() throws Exception { + // Licence VALID (autorise beta) mais sidecar absent. + when(channelSwitcher.isSwitcherAvailable()).thenReturn(false); + mockMvc.perform(post("/api/license/channel/switch") + .header(HttpHeaders.AUTHORIZATION, ADMIN_AUTH) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"channel\":\"beta\"}")) + .andExpect(status().isServiceUnavailable()); + } + + @Test + void switchChannel_returns202_onSuccess() throws Exception { + when(channelSwitcher.isSwitcherAvailable()).thenReturn(true); + when(channelSwitcher.requestSwitch(ChannelSwitcherService.Channel.STABLE)) + .thenReturn("cmd-1"); + mockMvc.perform(post("/api/license/channel/switch") + .header(HttpHeaders.AUTHORIZATION, ADMIN_AUTH) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"channel\":\"stable\"}")) + .andExpect(status().isAccepted()) + .andExpect(jsonPath("$.id").value("cmd-1")) + .andExpect(jsonPath("$.channel").value("stable")); + } + + @Test + void switchChannel_returns500_whenWriteFails() throws Exception { + when(channelSwitcher.isSwitcherAvailable()).thenReturn(true); + doThrow(new IOException("disk full")) + .when(channelSwitcher).requestSwitch(ChannelSwitcherService.Channel.STABLE); + mockMvc.perform(post("/api/license/channel/switch") + .header(HttpHeaders.AUTHORIZATION, ADMIN_AUTH) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"channel\":\"stable\"}")) + .andExpect(status().isInternalServerError()); + } +} diff --git a/core/src/test/java/com/loremind/infrastructure/web/controller/NotebookControllerTest.java b/core/src/test/java/com/loremind/infrastructure/web/controller/NotebookControllerTest.java new file mode 100644 index 0000000..fd4ad51 --- /dev/null +++ b/core/src/test/java/com/loremind/infrastructure/web/controller/NotebookControllerTest.java @@ -0,0 +1,328 @@ +package com.loremind.infrastructure.web.controller; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.loremind.domain.campaigncontext.Campaign; +import com.loremind.domain.campaigncontext.Notebook; +import com.loremind.domain.campaigncontext.NotebookSource; +import com.loremind.domain.campaigncontext.ports.CampaignRepository; +import com.loremind.domain.campaigncontext.ports.NotebookChatStreamer; +import com.loremind.domain.campaigncontext.ports.NotebookException; +import com.loremind.domain.campaigncontext.ports.NotebookIndexer; +import com.loremind.domain.campaigncontext.ports.NotebookRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.core.task.TaskExecutor; +import org.springframework.http.MediaType; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; +import org.springframework.transaction.annotation.Transactional; + +import static org.hamcrest.Matchers.containsString; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.asyncDispatch; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.request; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * Tests d'integration pour {@link NotebookController} (atelier RAG). + *

+ * Les ports vers le Brain sont mockes : {@link NotebookIndexer} (indexation des + * sources) et {@link NotebookChatStreamer} (chat ancre streame), sinon chaque test + * ferait un vrai appel HTTP au Brain (indisponible en test). + *

+ * Le {@code TaskExecutor} ("applicationTaskExecutor") est egalement mocke pour + * executer la tache du chat stream EN LIGNE (synchrone) : tous les events SSE sont + * ainsi ecrits avant le retour du controleur, ce qui rend les assertions sur le + * flux deterministes (pas de course entre threads). + */ +@SpringBootTest +@AutoConfigureMockMvc +@Transactional +class NotebookControllerTest { + + @Autowired private MockMvc mockMvc; + @Autowired private ObjectMapper objectMapper; + @Autowired private NotebookRepository notebookRepository; + @Autowired private CampaignRepository campaignRepository; + + @MockBean private NotebookIndexer indexer; + @MockBean private NotebookChatStreamer chatStreamer; + @MockBean(name = "applicationTaskExecutor") private TaskExecutor taskExecutor; + + private String campaignId; + + @BeforeEach + void setUp() { + campaignId = campaignRepository.save( + Campaign.builder().name("C").description("").build()).getId(); + // Tache du chat stream executee en ligne -> events SSE deterministes. + doAnswer(inv -> { ((Runnable) inv.getArgument(0)).run(); return null; }) + .when(taskExecutor).execute(any(Runnable.class)); + } + + private Notebook persistNotebook() { + return notebookRepository.save( + Notebook.builder().campaignId(campaignId).name("Atelier").build()); + } + + // --- Notebooks (CRUD) --- + + @Test + void create_returns200() throws Exception { + var req = new NotebookController.CreateRequest(campaignId, "Mon atelier"); + mockMvc.perform(post("/api/notebooks") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(req))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id").exists()) + .andExpect(jsonPath("$.name").value("Mon atelier")) + .andExpect(jsonPath("$.campaignId").value(campaignId)); + } + + @Test + void create_blankName_fallsBackToDefault() throws Exception { + var req = new NotebookController.CreateRequest(campaignId, " "); + mockMvc.perform(post("/api/notebooks") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(req))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.name").value("Nouvel atelier")); + } + + @Test + void listByCampaign_returnsArray() throws Exception { + persistNotebook(); + mockMvc.perform(get("/api/notebooks/campaign/{campaignId}", campaignId)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$").isArray()) + .andExpect(jsonPath("$[0].campaignId").value(campaignId)); + } + + @Test + void get_returns200_withSourcesAndMessages() throws Exception { + Notebook nb = persistNotebook(); + mockMvc.perform(get("/api/notebooks/{id}", nb.getId())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id").value(nb.getId())) + .andExpect(jsonPath("$.name").value("Atelier")) + .andExpect(jsonPath("$.sources").isArray()) + .andExpect(jsonPath("$.messages").isArray()); + } + + @Test + void get_returns404_whenMissing() throws Exception { + mockMvc.perform(get("/api/notebooks/{id}", "999999999")) + .andExpect(status().isNotFound()); + } + + @Test + void rename_returns200() throws Exception { + Notebook nb = persistNotebook(); + var req = new NotebookController.RenameRequest("Renomme"); + mockMvc.perform(put("/api/notebooks/{id}", nb.getId()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(req))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.name").value("Renomme")); + } + + @Test + void delete_returns204() throws Exception { + Notebook nb = persistNotebook(); + mockMvc.perform(delete("/api/notebooks/{id}", nb.getId())) + .andExpect(status().isNoContent()); + } + + // --- Sources --- + + @Test + void addSource_returns200_andIndexes() throws Exception { + Notebook nb = persistNotebook(); + when(indexer.index(any(), any(), any())) + .thenReturn(new NotebookIndexer.IndexResult(12, 3, 0)); + MockMultipartFile file = new MockMultipartFile( + "file", "livre.pdf", "application/pdf", new byte[]{1, 2, 3}); + + mockMvc.perform(multipart("/api/notebooks/{id}/sources", nb.getId()).file(file)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.filename").value("livre.pdf")) + .andExpect(jsonPath("$.status").value("READY")) + .andExpect(jsonPath("$.chunkCount").value(12)) + .andExpect(jsonPath("$.pageCount").value(3)); + } + + @Test + void addSource_returns502_whenBrainFails() throws Exception { + Notebook nb = persistNotebook(); + when(indexer.index(any(), any(), any())) + .thenThrow(new NotebookException("Brain injoignable")); + MockMultipartFile file = new MockMultipartFile( + "file", "livre.pdf", "application/pdf", new byte[]{1, 2, 3}); + + mockMvc.perform(multipart("/api/notebooks/{id}/sources", nb.getId()).file(file)) + .andExpect(status().isBadGateway()); + } + + @Test + void deleteSource_returns204() throws Exception { + Notebook nb = persistNotebook(); + NotebookSource src = notebookRepository.saveSource(NotebookSource.builder() + .notebookId(nb.getId()).filename("s.pdf").status("READY").build()); + mockMvc.perform(delete("/api/notebooks/sources/{sourceId}", src.getId())) + .andExpect(status().isNoContent()); + } + + // --- Conversation : vider (archiver) + archives --- + + @Test + void clearChat_returns204() throws Exception { + Notebook nb = persistNotebook(); + mockMvc.perform(post("/api/notebooks/{id}/chat/clear", nb.getId())) + .andExpect(status().isNoContent()); + } + + @Test + void clearChat_returns404_whenMissing() throws Exception { + mockMvc.perform(post("/api/notebooks/{id}/chat/clear", "999999999")) + .andExpect(status().isNotFound()); + } + + @Test + void listArchives_returnsArray() throws Exception { + Notebook nb = persistNotebook(); + mockMvc.perform(get("/api/notebooks/{id}/chat/archives", nb.getId())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$").isArray()); + } + + // --- Chat ancre streame (SSE) --- + + @Test + void chatStream_happyPath_streamsTokenThenDone() throws Exception { + Notebook nb = persistNotebook(); + // Le streamer mocke joue : 1 token puis fin. + doAnswer(inv -> { + java.util.function.Consumer onToken = inv.getArgument(5); + Runnable onDone = inv.getArgument(7); + onToken.accept("Bonjour"); + onDone.run(); + return null; + }).when(chatStreamer).stream(any(), any(), any(), org.mockito.ArgumentMatchers.anyBoolean(), + any(), any(), any(), any(), any()); + + var req = new NotebookController.ChatRequest("Salut ?", false, null, null); + MvcResult result = mockMvc.perform(post("/api/notebooks/{id}/chat/stream", nb.getId()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(req))) + .andExpect(request().asyncStarted()) + .andReturn(); + + mockMvc.perform(asyncDispatch(result)) + .andExpect(status().isOk()) + .andExpect(content().string(containsString("Bonjour"))) + .andExpect(content().string(containsString("done"))); + + // La reponse de l'assistant a ete persistee a la fin du stream. + boolean persisted = notebookRepository.findMessagesByNotebookId(nb.getId()).stream() + .anyMatch(m -> "assistant".equals(m.getRole()) && "Bonjour".equals(m.getContent())); + org.junit.jupiter.api.Assertions.assertTrue(persisted, "reponse assistant persistee"); + } + + @Test + void chatStream_emptyMessage_emitsError() throws Exception { + Notebook nb = persistNotebook(); + var req = new NotebookController.ChatRequest(" ", false, null, null); + MvcResult result = mockMvc.perform(post("/api/notebooks/{id}/chat/stream", nb.getId()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(req))) + .andExpect(request().asyncStarted()) + .andReturn(); + + mockMvc.perform(asyncDispatch(result)) + .andExpect(status().isOk()) + .andExpect(content().string(containsString("error"))); + } + + @Test + void chatStream_streamerError_emitsError() throws Exception { + Notebook nb = persistNotebook(); + doAnswer(inv -> { + java.util.function.Consumer onError = inv.getArgument(8); + onError.accept(new RuntimeException("boom")); + return null; + }).when(chatStreamer).stream(any(), any(), any(), org.mockito.ArgumentMatchers.anyBoolean(), + any(), any(), any(), any(), any()); + + var req = new NotebookController.ChatRequest("Salut ?", false, null, null); + MvcResult result = mockMvc.perform(post("/api/notebooks/{id}/chat/stream", nb.getId()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(req))) + .andExpect(request().asyncStarted()) + .andReturn(); + + mockMvc.perform(asyncDispatch(result)) + .andExpect(status().isOk()) + .andExpect(content().string(containsString("boom"))); + } + + @Test + void chatStream_returns404_whenMissing() throws Exception { + var req = new NotebookController.ChatRequest("Salut ?", false, null, null); + mockMvc.perform(post("/api/notebooks/{id}/chat/stream", "999999999") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(req))) + .andExpect(status().isNotFound()); + } + + @Test + void chatStream_deepMode_emitsSourcesAndProgress() throws Exception { + Notebook nb = persistNotebook(); + // Source PRETE -> remontee par readySourceIds, donc selectionnable via sourceIds. + NotebookSource src = notebookRepository.saveSource(NotebookSource.builder() + .notebookId(nb.getId()).filename("s.pdf").status("READY").build()); + + // Le streamer mocke joue, en mode approfondi : sources -> progress -> token -> fin. + doAnswer(inv -> { + java.util.function.Consumer onSourcesJson = inv.getArgument(4); + java.util.function.Consumer onToken = inv.getArgument(5); + java.util.function.Consumer onProgress = inv.getArgument(6); + Runnable onDone = inv.getArgument(7); + onSourcesJson.accept("{\"sources\":[{\"source_id\":\"" + src.getId() + "\",\"page\":1}]}"); + onProgress.accept(new NotebookChatStreamer.Progress(1, 3)); + onToken.accept("Reponse"); + onDone.run(); + return null; + }).when(chatStreamer).stream(any(), any(), any(), org.mockito.ArgumentMatchers.anyBoolean(), + any(), any(), any(), any(), any()); + + // deep=true + sourceIds (filtrage) + archiveIds non nuls (branche buildArchiveContext). + var req = new NotebookController.ChatRequest( + "Analyse complete ?", true, + java.util.List.of(src.getId()), java.util.List.of("2020-01-01T00:00")); + MvcResult result = mockMvc.perform(post("/api/notebooks/{id}/chat/stream", nb.getId()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(req))) + .andExpect(request().asyncStarted()) + .andReturn(); + + mockMvc.perform(asyncDispatch(result)) + .andExpect(status().isOk()) + .andExpect(content().string(containsString("sources"))) + .andExpect(content().string(containsString("progress"))) + .andExpect(content().string(containsString("Reponse"))); + } +} diff --git a/core/src/test/java/com/loremind/infrastructure/web/controller/NpcControllerTest.java b/core/src/test/java/com/loremind/infrastructure/web/controller/NpcControllerTest.java new file mode 100644 index 0000000..5731a18 --- /dev/null +++ b/core/src/test/java/com/loremind/infrastructure/web/controller/NpcControllerTest.java @@ -0,0 +1,124 @@ +package com.loremind.infrastructure.web.controller; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.loremind.domain.campaigncontext.Campaign; +import com.loremind.domain.campaigncontext.Npc; +import com.loremind.domain.campaigncontext.ports.CampaignRepository; +import com.loremind.domain.campaigncontext.ports.NpcRepository; +import com.loremind.infrastructure.web.dto.campaigncontext.NpcDTO; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.transaction.annotation.Transactional; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** Tests d'intégration CRUD du NpcController. */ +@SpringBootTest +@AutoConfigureMockMvc +@Transactional +class NpcControllerTest { + + @Autowired private MockMvc mockMvc; + @Autowired private ObjectMapper objectMapper; + @Autowired private CampaignRepository campaignRepository; + @Autowired private NpcRepository npcRepository; + + private String campaignId; + + @BeforeEach + void setUp() { + campaignId = campaignRepository.save(Campaign.builder().name("C").description("").build()).getId(); + } + + @Test + void create_returns200() throws Exception { + NpcDTO dto = new NpcDTO(); + dto.setName("Gandalf"); + dto.setCampaignId(campaignId); + mockMvc.perform(post("/api/npcs") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(dto))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.name").value("Gandalf")); + } + + @Test + void getById_returns200() throws Exception { + Npc saved = npcRepository.save(Npc.builder().campaignId(campaignId).name("N").order(0).build()); + mockMvc.perform(get("/api/npcs/{id}", saved.getId())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.name").value("N")); + } + + @Test + void getById_returns404_whenMissing() throws Exception { + mockMvc.perform(get("/api/npcs/{id}", "999999999")) + .andExpect(status().isNotFound()); + } + + @Test + void getByCampaign_returnsArray() throws Exception { + npcRepository.save(Npc.builder().campaignId(campaignId).name("A").order(0).build()); + mockMvc.perform(get("/api/npcs/campaign/{campaignId}", campaignId)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$").isArray()); + } + + @Test + void search_returnsArray() throws Exception { + npcRepository.save(Npc.builder().campaignId(campaignId).name("Frodon").order(0).build()); + mockMvc.perform(get("/api/npcs/search").param("q", "Frodon")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$").isArray()); + } + + @Test + void getByLore_returnsArray() throws Exception { + mockMvc.perform(get("/api/npcs/lore/{loreId}", "lore-1")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$").isArray()); + } + + @Test + void update_returns200() throws Exception { + Npc saved = npcRepository.save(Npc.builder().campaignId(campaignId).name("old").order(0).build()); + NpcDTO dto = new NpcDTO(); + dto.setName("new"); + dto.setCampaignId(campaignId); + dto.setOrder(0); + mockMvc.perform(put("/api/npcs/{id}", saved.getId()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(dto))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.name").value("new")); + } + + @Test + void update_returns400_whenMissing() throws Exception { + NpcDTO dto = new NpcDTO(); + dto.setName("x"); + dto.setCampaignId(campaignId); + dto.setOrder(0); + mockMvc.perform(put("/api/npcs/{id}", "999999999") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(dto))) + .andExpect(status().isBadRequest()); + } + + @Test + void delete_returns204() throws Exception { + Npc saved = npcRepository.save(Npc.builder().campaignId(campaignId).name("X").order(0).build()); + mockMvc.perform(delete("/api/npcs/{id}", saved.getId())) + .andExpect(status().isNoContent()); + } +} diff --git a/core/src/test/java/com/loremind/infrastructure/web/controller/PageGenerationControllerTest.java b/core/src/test/java/com/loremind/infrastructure/web/controller/PageGenerationControllerTest.java new file mode 100644 index 0000000..51d8e7c --- /dev/null +++ b/core/src/test/java/com/loremind/infrastructure/web/controller/PageGenerationControllerTest.java @@ -0,0 +1,87 @@ +package com.loremind.infrastructure.web.controller; + +import com.loremind.application.generationcontext.GeneratePageValuesUseCase; +import com.loremind.domain.generationcontext.ports.AiProviderException; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.test.web.servlet.MockMvc; + +import java.util.Map; + +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * Tests d'integration pour {@link PageGenerationController} (generation IA de Page). + *

+ * Le use case {@link GeneratePageValuesUseCase} est mocke : il orchestre l'appel + * au Brain (AiProvider), indisponible en test. On verifie le mapping des + * exceptions du use case vers les codes HTTP : + *

+ */ +@SpringBootTest +@AutoConfigureMockMvc +class PageGenerationControllerTest { + + @Autowired private MockMvc mockMvc; + @MockBean private GeneratePageValuesUseCase generatePageValuesUseCase; + + @Test + void generate_returns200_withSuggestions() throws Exception { + when(generatePageValuesUseCase.execute(eq("p1"))) + .thenReturn(Map.of("nom", "Aldric", "race", "Elfe")); + + mockMvc.perform(post("/api/pages/{id}/generate", "p1")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.values.nom").value("Aldric")) + .andExpect(jsonPath("$.values.race").value("Elfe")); + } + + @Test + void generate_returns404_whenPageMissing() throws Exception { + when(generatePageValuesUseCase.execute(eq("missing"))) + .thenThrow(new IllegalArgumentException("Page non trouvée")); + + mockMvc.perform(post("/api/pages/{id}/generate", "missing")) + .andExpect(status().isNotFound()); + } + + @Test + void generate_returns502_whenBrainDown() throws Exception { + when(generatePageValuesUseCase.execute(eq("p1"))) + .thenThrow(new AiProviderException("Brain unreachable")); + + mockMvc.perform(post("/api/pages/{id}/generate", "p1")) + .andExpect(status().isBadGateway()); + } + + @Test + void generate_returns422_whenTemplateHasNoFields() throws Exception { + when(generatePageValuesUseCase.execute(eq("p1"))) + .thenThrow(new IllegalStateException("Le template 'X' n'a aucun champ texte à générer.")); + + mockMvc.perform(post("/api/pages/{id}/generate", "p1")) + .andExpect(status().isUnprocessableEntity()); + } + + @Test + void generate_returns500_whenBddInconsistent() throws Exception { + when(generatePageValuesUseCase.execute(eq("p1"))) + .thenThrow(new IllegalStateException("Template introuvable (id=t1)")); + + mockMvc.perform(post("/api/pages/{id}/generate", "p1")) + .andExpect(status().isInternalServerError()); + } +} diff --git a/core/src/test/java/com/loremind/infrastructure/web/controller/PlaythroughControllerTest.java b/core/src/test/java/com/loremind/infrastructure/web/controller/PlaythroughControllerTest.java new file mode 100644 index 0000000..406d74c --- /dev/null +++ b/core/src/test/java/com/loremind/infrastructure/web/controller/PlaythroughControllerTest.java @@ -0,0 +1,160 @@ +package com.loremind.infrastructure.web.controller; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.loremind.domain.campaigncontext.Campaign; +import com.loremind.domain.campaigncontext.ports.CampaignRepository; +import com.loremind.domain.playcontext.Playthrough; +import com.loremind.domain.playcontext.Session; +import com.loremind.domain.playcontext.ports.PlaythroughRepository; +import com.loremind.domain.playcontext.ports.SessionRepository; +import com.loremind.infrastructure.web.dto.playcontext.PlaythroughDTO; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.transaction.annotation.Transactional; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * Tests d'intégration du PlaythroughController. + * Fixture parente : Campaign (un Playthrough référence une campagne). + */ +@SpringBootTest +@AutoConfigureMockMvc +@Transactional +class PlaythroughControllerTest { + + @Autowired private MockMvc mockMvc; + @Autowired private ObjectMapper objectMapper; + @Autowired private CampaignRepository campaignRepository; + @Autowired private PlaythroughRepository playthroughRepository; + @Autowired private SessionRepository sessionRepository; + + private String campaignId; + + @BeforeEach + void setUp() { + campaignId = campaignRepository.save(Campaign.builder().name("C").description("").build()).getId(); + } + + private Playthrough savePlaythrough() { + return playthroughRepository.save( + Playthrough.builder().campaignId(campaignId).name("Partie").description("d").build()); + } + + @Test + void create_returns200() throws Exception { + PlaythroughDTO dto = new PlaythroughDTO(); + dto.setCampaignId(campaignId); + dto.setName("Table du vendredi"); + dto.setDescription("desc"); + mockMvc.perform(post("/api/playthroughs") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(dto))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.name").value("Table du vendredi")); + } + + @Test + void create_returns400_whenCampaignMissing() throws Exception { + PlaythroughDTO dto = new PlaythroughDTO(); + dto.setCampaignId("999999999"); + dto.setName("X"); + mockMvc.perform(post("/api/playthroughs") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(dto))) + .andExpect(status().isBadRequest()); + } + + @Test + void getById_returns200() throws Exception { + Playthrough p = savePlaythrough(); + mockMvc.perform(get("/api/playthroughs/{id}", p.getId())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.name").value("Partie")); + } + + @Test + void getById_returns404_whenMissing() throws Exception { + mockMvc.perform(get("/api/playthroughs/{id}", "999999999")) + .andExpect(status().isNotFound()); + } + + @Test + void list_byCampaign_returnsArray() throws Exception { + savePlaythrough(); + mockMvc.perform(get("/api/playthroughs").param("campaignId", campaignId)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$").isArray()); + } + + @Test + void list_withoutCampaign_returnsEmptyArray() throws Exception { + savePlaythrough(); + mockMvc.perform(get("/api/playthroughs")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$").isArray()); + } + + @Test + void update_returns200() throws Exception { + Playthrough p = savePlaythrough(); + PlaythroughDTO dto = new PlaythroughDTO(); + dto.setName("Renommée"); + dto.setDescription("nouvelle desc"); + mockMvc.perform(put("/api/playthroughs/{id}", p.getId()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(dto))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.name").value("Renommée")); + } + + @Test + void update_returns400_whenMissing() throws Exception { + PlaythroughDTO dto = new PlaythroughDTO(); + dto.setName("X"); + mockMvc.perform(put("/api/playthroughs/{id}", "999999999") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(dto))) + .andExpect(status().isBadRequest()); + } + + @Test + void delete_returns204() throws Exception { + Playthrough p = savePlaythrough(); + mockMvc.perform(delete("/api/playthroughs/{id}", p.getId())) + .andExpect(status().isNoContent()); + } + + @Test + void delete_returns400_whenMissing() throws Exception { + mockMvc.perform(delete("/api/playthroughs/{id}", "999999999")) + .andExpect(status().isBadRequest()); + } + + @Test + void deletionImpact_returns200() throws Exception { + Playthrough p = savePlaythrough(); + sessionRepository.save(Session.builder() + .name("S").playthroughId(p.getId()) + .startedAt(java.time.LocalDateTime.now()).build()); + mockMvc.perform(get("/api/playthroughs/{id}/deletion-impact", p.getId())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.sessions").value(1)); + } + + @Test + void deletionImpact_returns404_whenMissing() throws Exception { + mockMvc.perform(get("/api/playthroughs/{id}/deletion-impact", "999999999")) + .andExpect(status().isNotFound()); + } +} diff --git a/core/src/test/java/com/loremind/infrastructure/web/controller/PlaythroughFlagControllerTest.java b/core/src/test/java/com/loremind/infrastructure/web/controller/PlaythroughFlagControllerTest.java new file mode 100644 index 0000000..4695fe9 --- /dev/null +++ b/core/src/test/java/com/loremind/infrastructure/web/controller/PlaythroughFlagControllerTest.java @@ -0,0 +1,103 @@ +package com.loremind.infrastructure.web.controller; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.loremind.domain.campaigncontext.Campaign; +import com.loremind.domain.campaigncontext.ports.CampaignRepository; +import com.loremind.domain.playcontext.Playthrough; +import com.loremind.domain.playcontext.ports.PlaythroughFlagRepository; +import com.loremind.domain.playcontext.ports.PlaythroughRepository; +import com.loremind.infrastructure.web.dto.playcontext.PlaythroughFlagDTO; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.transaction.annotation.Transactional; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * Tests d'intégration de {@link PlaythroughFlagController}. + * Couvre les 3 endpoints : list (GET), setFlag (PUT), deleteFlag (DELETE). + * Les flags sont indexés par playthroughId : on crée une campagne puis un playthrough en fixture. + */ +@SpringBootTest +@AutoConfigureMockMvc +@Transactional +class PlaythroughFlagControllerTest { + + @Autowired private MockMvc mockMvc; + @Autowired private ObjectMapper objectMapper; + @Autowired private CampaignRepository campaignRepository; + @Autowired private PlaythroughRepository playthroughRepository; + @Autowired private PlaythroughFlagRepository flagRepository; + + private String playthroughId; + + @BeforeEach + void setUp() { + // Chaîne de fixtures : campaign -> playthrough + String campId = campaignRepository.save(Campaign.builder().name("C").description("").build()).getId(); + playthroughId = playthroughRepository.save( + Playthrough.builder().campaignId(campId).name("Table").description("").build()).getId(); + } + + @Test + void list_returnsEmptyArray_whenNoFlags() throws Exception { + mockMvc.perform(get("/api/playthroughs/{pid}/flags", playthroughId)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$").isArray()); + } + + @Test + void list_returnsExistingFlags() throws Exception { + // Pré-positionne un flag via le repo réel + flagRepository.setFlag(playthroughId, "porte_ouverte", true); + mockMvc.perform(get("/api/playthroughs/{pid}/flags", playthroughId)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$").isArray()) + .andExpect(jsonPath("$[0].name").value("porte_ouverte")) + .andExpect(jsonPath("$[0].value").value(true)); + } + + @Test + void setFlag_returns200_andEcho() throws Exception { + PlaythroughFlagDTO body = new PlaythroughFlagDTO("dragon_vaincu", true); + mockMvc.perform(put("/api/playthroughs/{pid}/flags/{name}", playthroughId, "dragon_vaincu") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(body))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.name").value("dragon_vaincu")) + .andExpect(jsonPath("$.value").value(true)); + } + + @Test + void setFlag_false_returns200() throws Exception { + PlaythroughFlagDTO body = new PlaythroughFlagDTO("ignore", false); + mockMvc.perform(put("/api/playthroughs/{pid}/flags/{name}", playthroughId, "ignore") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(body))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.value").value(false)); + } + + @Test + void deleteFlag_returns204() throws Exception { + flagRepository.setFlag(playthroughId, "a_supprimer", true); + mockMvc.perform(delete("/api/playthroughs/{pid}/flags/{name}", playthroughId, "a_supprimer")) + .andExpect(status().isNoContent()); + } + + @Test + void deleteFlag_returns204_whenAbsent() throws Exception { + // deleteFlag est idempotent : pas d'erreur même si le flag n'existe pas + mockMvc.perform(delete("/api/playthroughs/{pid}/flags/{name}", playthroughId, "inexistant")) + .andExpect(status().isNoContent()); + } +} diff --git a/core/src/test/java/com/loremind/infrastructure/web/controller/QuestProgressionControllerTest.java b/core/src/test/java/com/loremind/infrastructure/web/controller/QuestProgressionControllerTest.java new file mode 100644 index 0000000..fe99e4a --- /dev/null +++ b/core/src/test/java/com/loremind/infrastructure/web/controller/QuestProgressionControllerTest.java @@ -0,0 +1,127 @@ +package com.loremind.infrastructure.web.controller; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.loremind.domain.campaigncontext.Arc; +import com.loremind.domain.campaigncontext.Campaign; +import com.loremind.domain.campaigncontext.Chapter; +import com.loremind.domain.campaigncontext.ProgressionStatus; +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.playcontext.Playthrough; +import com.loremind.domain.playcontext.ports.PlaythroughRepository; +import com.loremind.domain.playcontext.ports.QuestProgressionRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.transaction.annotation.Transactional; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * Tests d'intégration de {@link QuestProgressionController}. + * Couvre list (GET, map chapterId->status) et setStatus (PUT) avec : + * - statut valide IN_PROGRESS / COMPLETED + * - statut vide/null => NOT_STARTED (suppression de ligne) + * - statut invalide => 400 (badRequest renvoyé directement par le contrôleur) + *

+ * Le {@code chapterId} doit être un id de Chapter REEL (clé numérique) : on crée + * donc la chaîne campaign -> arc -> chapter en fixture. + */ +@SpringBootTest +@AutoConfigureMockMvc +@Transactional +class QuestProgressionControllerTest { + + @Autowired private MockMvc mockMvc; + @Autowired private ObjectMapper objectMapper; + @Autowired private CampaignRepository campaignRepository; + @Autowired private ArcRepository arcRepository; + @Autowired private ChapterRepository chapterRepository; + @Autowired private PlaythroughRepository playthroughRepository; + @Autowired private QuestProgressionRepository repo; + + private String playthroughId; + private String chapterId; + + @BeforeEach + void setUp() { + // Chaîne de fixtures : campaign -> playthrough (+ arc -> chapter pour un chapterId reel). + String campId = campaignRepository.save(Campaign.builder().name("C").description("").build()).getId(); + String arcId = arcRepository.save(Arc.builder().campaignId(campId).name("A").order(0).build()).getId(); + chapterId = chapterRepository.save(Chapter.builder().arcId(arcId).name("Ch").order(0).build()).getId(); + playthroughId = playthroughRepository.save( + Playthrough.builder().campaignId(campId).name("Table").description("").build()).getId(); + } + + @Test + void list_returnsEmptyMap_whenNoProgressions() throws Exception { + mockMvc.perform(get("/api/playthroughs/{pid}/quest-progressions", playthroughId)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$").isMap()); + } + + @Test + void list_returnsStatusMap() throws Exception { + // Pré-positionne une progression explicite via le repo réel. + repo.setStatus(playthroughId, chapterId, ProgressionStatus.IN_PROGRESS); + mockMvc.perform(get("/api/playthroughs/{pid}/quest-progressions", playthroughId)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$['" + chapterId + "']").value("IN_PROGRESS")); + } + + @Test + void setStatus_inProgress_returns204() throws Exception { + mockMvc.perform(put("/api/playthroughs/{pid}/quest-progressions/{cid}", playthroughId, chapterId) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString( + new QuestProgressionController.SetStatusRequest("IN_PROGRESS")))) + .andExpect(status().isNoContent()); + } + + @Test + void setStatus_completed_returns204() throws Exception { + mockMvc.perform(put("/api/playthroughs/{pid}/quest-progressions/{cid}", playthroughId, chapterId) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString( + new QuestProgressionController.SetStatusRequest("COMPLETED")))) + .andExpect(status().isNoContent()); + } + + @Test + void setStatus_nullStatus_returns204_asNotStarted() throws Exception { + // status null => NOT_STARTED (branche : pas de parsing) + mockMvc.perform(put("/api/playthroughs/{pid}/quest-progressions/{cid}", playthroughId, chapterId) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString( + new QuestProgressionController.SetStatusRequest(null)))) + .andExpect(status().isNoContent()); + } + + @Test + void setStatus_blankStatus_returns204_asNotStarted() throws Exception { + // status vide => NOT_STARTED (branche isBlank) + mockMvc.perform(put("/api/playthroughs/{pid}/quest-progressions/{cid}", playthroughId, chapterId) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString( + new QuestProgressionController.SetStatusRequest(" ")))) + .andExpect(status().isNoContent()); + } + + @Test + void setStatus_invalidStatus_returns400() throws Exception { + // valeur d'enum inconnue => IllegalArgumentException attrapée => badRequest direct + mockMvc.perform(put("/api/playthroughs/{pid}/quest-progressions/{cid}", playthroughId, chapterId) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString( + new QuestProgressionController.SetStatusRequest("NOPE")))) + .andExpect(status().isBadRequest()); + } +} diff --git a/core/src/test/java/com/loremind/infrastructure/web/controller/RandomTableControllerTest.java b/core/src/test/java/com/loremind/infrastructure/web/controller/RandomTableControllerTest.java new file mode 100644 index 0000000..122afe2 --- /dev/null +++ b/core/src/test/java/com/loremind/infrastructure/web/controller/RandomTableControllerTest.java @@ -0,0 +1,218 @@ +package com.loremind.infrastructure.web.controller; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.loremind.domain.campaigncontext.Campaign; +import com.loremind.domain.campaigncontext.RandomTable; +import com.loremind.domain.campaigncontext.RandomTableEntry; +import com.loremind.domain.campaigncontext.ports.CampaignRepository; +import com.loremind.domain.campaigncontext.ports.RandomTableGenerationException; +import com.loremind.domain.campaigncontext.ports.RandomTableGenerator; +import com.loremind.domain.campaigncontext.ports.RandomTableRepository; +import com.loremind.infrastructure.web.controller.RandomTableController.GenerateRequest; +import com.loremind.infrastructure.web.controller.RandomTableController.ImproviseRequest; +import com.loremind.infrastructure.web.dto.campaigncontext.RandomTableDTO; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * Tests d'integration pour {@link RandomTableController} (CRUD + recherche + + * generation IA d'une table + improvisation narrative). + *

+ * Le port {@link RandomTableGenerator} (client du Brain) est mocke : sinon les + * endpoints /generate et /improvise feraient un vrai appel reseau au service IA + * (indisponible en test). Les repos reels servent aux fixtures. + */ +@SpringBootTest +@AutoConfigureMockMvc +@Transactional +class RandomTableControllerTest { + + @Autowired private MockMvc mockMvc; + @Autowired private ObjectMapper objectMapper; + @Autowired private RandomTableRepository tableRepository; + @Autowired private CampaignRepository campaignRepository; + + @MockBean private RandomTableGenerator generator; + + private String campaignId; + + @BeforeEach + void setUp() { + campaignId = campaignRepository.save( + Campaign.builder().name("Camp").description("desc").build()).getId(); + } + + private RandomTableDTO dto(String name) { + RandomTableDTO dto = new RandomTableDTO(); + dto.setName(name); + dto.setDescription("Rencontres aleatoires"); + dto.setDiceFormula("1d20"); + dto.setIcon("dice"); + dto.setCampaignId(campaignId); + dto.setOrder(0); + return dto; + } + + // --- POST / ------------------------------------------------------------- + + @Test + void create_returns200() throws Exception { + mockMvc.perform(post("/api/random-tables") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(dto("Rencontres")))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.name").value("Rencontres")) + .andExpect(jsonPath("$.diceFormula").value("1d20")); + } + + // --- GET /{id} ---------------------------------------------------------- + + @Test + void getById_returns200() throws Exception { + RandomTable saved = tableRepository.save(RandomTable.builder() + .name("Butin").diceFormula("1d6").campaignId(campaignId).order(0).build()); + mockMvc.perform(get("/api/random-tables/{id}", saved.getId())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.name").value("Butin")); + } + + @Test + void getById_returns404_whenMissing() throws Exception { + mockMvc.perform(get("/api/random-tables/{id}", "999999999")) + .andExpect(status().isNotFound()); + } + + // --- GET /campaign/{campaignId} ----------------------------------------- + + @Test + void getByCampaign_returnsArray() throws Exception { + tableRepository.save(RandomTable.builder() + .name("A").diceFormula("1d20").campaignId(campaignId).order(0).build()); + mockMvc.perform(get("/api/random-tables/campaign/{campaignId}", campaignId)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$").isArray()) + .andExpect(jsonPath("$[0].name").value("A")); + } + + // --- PUT /{id} ---------------------------------------------------------- + + @Test + void update_returns200() throws Exception { + RandomTable saved = tableRepository.save(RandomTable.builder() + .name("old").diceFormula("1d20").campaignId(campaignId).order(0).build()); + mockMvc.perform(put("/api/random-tables/{id}", saved.getId()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(dto("new")))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.name").value("new")); + } + + @Test + void update_returns400_whenMissing() throws Exception { + // Le service leve IllegalArgumentException -> GlobalExceptionHandler -> 400. + mockMvc.perform(put("/api/random-tables/{id}", "999999999") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(dto("x")))) + .andExpect(status().isBadRequest()); + } + + // --- DELETE /{id} ------------------------------------------------------- + + @Test + void delete_returns204() throws Exception { + RandomTable saved = tableRepository.save(RandomTable.builder() + .name("X").diceFormula("1d20").campaignId(campaignId).order(0).build()); + mockMvc.perform(delete("/api/random-tables/{id}", saved.getId())) + .andExpect(status().isNoContent()); + } + + // --- GET /search -------------------------------------------------------- + + @Test + void search_returnsArray() throws Exception { + tableRepository.save(RandomTable.builder() + .name("Complications urbaines").diceFormula("1d20") + .campaignId(campaignId).order(0).build()); + mockMvc.perform(get("/api/random-tables/search").param("q", "Complications")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$").isArray()); + } + + // --- POST /generate ----------------------------------------------------- + + @Test + void generate_returns200() throws Exception { + when(generator.generate(any(), any(), any())).thenReturn( + new RandomTableGenerator.GeneratedTable( + "Rencontres en foret", + "Que croisez-vous ?", + List.of(RandomTableEntry.builder() + .minRoll(1).maxRoll(10).label("Gobelins").build()))); + + GenerateRequest req = new GenerateRequest(campaignId, "rencontres en foret", "1d20"); + mockMvc.perform(post("/api/random-tables/generate") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(req))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.name").value("Rencontres en foret")); + } + + @Test + void generate_returns502_whenBrainUnreachable() throws Exception { + when(generator.generate(any(), any(), any())) + .thenThrow(new RandomTableGenerationException("Brain injoignable")); + + GenerateRequest req = new GenerateRequest(campaignId, "rencontres", "1d20"); + mockMvc.perform(post("/api/random-tables/generate") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(req))) + .andExpect(status().isBadGateway()); + } + + // --- POST /improvise ---------------------------------------------------- + + @Test + void improvise_returns200() throws Exception { + when(generator.improvise(any(), any(), any(), any())) + .thenReturn("Les gobelins surgissent des fourres..."); + + ImproviseRequest req = new ImproviseRequest( + campaignId, "Rencontres", "Gobelins", "3 gobelins armes"); + mockMvc.perform(post("/api/random-tables/improvise") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(req))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.narration").value("Les gobelins surgissent des fourres...")); + } + + @Test + void improvise_returns502_whenBrainUnreachable() throws Exception { + when(generator.improvise(any(), any(), any(), any())) + .thenThrow(new RandomTableGenerationException("Brain injoignable")); + + ImproviseRequest req = new ImproviseRequest( + campaignId, "Rencontres", "Gobelins", "3 gobelins"); + mockMvc.perform(post("/api/random-tables/improvise") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(req))) + .andExpect(status().isBadGateway()); + } +} diff --git a/core/src/test/java/com/loremind/infrastructure/web/controller/SessionControllerTest.java b/core/src/test/java/com/loremind/infrastructure/web/controller/SessionControllerTest.java new file mode 100644 index 0000000..adbe138 --- /dev/null +++ b/core/src/test/java/com/loremind/infrastructure/web/controller/SessionControllerTest.java @@ -0,0 +1,188 @@ +package com.loremind.infrastructure.web.controller; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.loremind.domain.campaigncontext.Campaign; +import com.loremind.domain.campaigncontext.ports.CampaignRepository; +import com.loremind.domain.playcontext.Playthrough; +import com.loremind.domain.playcontext.Session; +import com.loremind.domain.playcontext.ports.PlaythroughRepository; +import com.loremind.domain.playcontext.ports.SessionRepository; +import com.loremind.infrastructure.web.controller.SessionController.RenameSessionRequest; +import com.loremind.infrastructure.web.controller.SessionController.StartSessionRequest; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * Tests d'intégration du SessionController. + * Chaîne de fixtures : Campaign -> Playthrough (une session appartient à une partie). + */ +@SpringBootTest +@AutoConfigureMockMvc +@Transactional +class SessionControllerTest { + + @Autowired private MockMvc mockMvc; + @Autowired private ObjectMapper objectMapper; + @Autowired private CampaignRepository campaignRepository; + @Autowired private PlaythroughRepository playthroughRepository; + @Autowired private SessionRepository sessionRepository; + + private String playthroughId; + + @BeforeEach + void setUp() { + String campaignId = campaignRepository.save( + Campaign.builder().name("C").description("").build()).getId(); + playthroughId = playthroughRepository.save( + Playthrough.builder().campaignId(campaignId).name("Partie").build()).getId(); + } + + private Session saveSession() { + return sessionRepository.save(Session.builder() + .name("Session test") + .playthroughId(playthroughId) + .startedAt(LocalDateTime.now()) + .build()); + } + + @Test + void startSession_returns200() throws Exception { + StartSessionRequest req = new StartSessionRequest(playthroughId); + mockMvc.perform(post("/api/sessions") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(req))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.playthroughId").value(playthroughId)) + .andExpect(jsonPath("$.active").value(true)); + } + + @Test + void startSession_returns400_whenPlaythroughMissing() throws Exception { + StartSessionRequest req = new StartSessionRequest("999999999"); + mockMvc.perform(post("/api/sessions") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(req))) + .andExpect(status().isBadRequest()); + } + + @Test + void getSessions_all_returnsArray() throws Exception { + saveSession(); + mockMvc.perform(get("/api/sessions")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$").isArray()); + } + + @Test + void getSessions_byPlaythrough_returnsArray() throws Exception { + saveSession(); + mockMvc.perform(get("/api/sessions").param("playthroughId", playthroughId)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$").isArray()); + } + + @Test + void getActive_global_returnsActive() throws Exception { + saveSession(); + mockMvc.perform(get("/api/sessions/active")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.active").value(true)); + } + + @Test + void getActive_byPlaythrough_returnsActive() throws Exception { + saveSession(); + mockMvc.perform(get("/api/sessions/active").param("playthroughId", playthroughId)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.playthroughId").value(playthroughId)); + } + + @Test + void getActive_returns204_whenNone() throws Exception { + // Session terminée -> aucune active + sessionRepository.save(Session.builder() + .name("ended").playthroughId(playthroughId) + .startedAt(LocalDateTime.now().minusHours(2)) + .endedAt(LocalDateTime.now().minusHours(1)) + .build()); + mockMvc.perform(get("/api/sessions/active").param("playthroughId", playthroughId)) + .andExpect(status().isNoContent()); + } + + @Test + void getById_returns200() throws Exception { + Session s = saveSession(); + mockMvc.perform(get("/api/sessions/{id}", s.getId())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.name").value("Session test")); + } + + @Test + void getById_returns404_whenMissing() throws Exception { + mockMvc.perform(get("/api/sessions/{id}", "999999999")) + .andExpect(status().isNotFound()); + } + + @Test + void endSession_returns200() throws Exception { + Session s = saveSession(); + mockMvc.perform(post("/api/sessions/{id}/end", s.getId())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.active").value(false)); + } + + @Test + void endSession_returns400_whenMissing() throws Exception { + mockMvc.perform(post("/api/sessions/{id}/end", "999999999")) + .andExpect(status().isBadRequest()); + } + + @Test + void renameSession_returns200() throws Exception { + Session s = saveSession(); + RenameSessionRequest req = new RenameSessionRequest("Nouveau nom"); + mockMvc.perform(patch("/api/sessions/{id}", s.getId()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(req))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.name").value("Nouveau nom")); + } + + @Test + void renameSession_returns400_whenBlankName() throws Exception { + Session s = saveSession(); + RenameSessionRequest req = new RenameSessionRequest(" "); + mockMvc.perform(patch("/api/sessions/{id}", s.getId()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(req))) + .andExpect(status().isBadRequest()); + } + + @Test + void deleteSession_returns204() throws Exception { + Session s = saveSession(); + mockMvc.perform(delete("/api/sessions/{id}", s.getId())) + .andExpect(status().isNoContent()); + } + + @Test + void deleteSession_returns400_whenMissing() throws Exception { + mockMvc.perform(delete("/api/sessions/{id}", "999999999")) + .andExpect(status().isBadRequest()); + } +} diff --git a/core/src/test/java/com/loremind/infrastructure/web/controller/SessionEntryControllerTest.java b/core/src/test/java/com/loremind/infrastructure/web/controller/SessionEntryControllerTest.java new file mode 100644 index 0000000..802186f --- /dev/null +++ b/core/src/test/java/com/loremind/infrastructure/web/controller/SessionEntryControllerTest.java @@ -0,0 +1,152 @@ +package com.loremind.infrastructure.web.controller; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.loremind.domain.campaigncontext.Campaign; +import com.loremind.domain.campaigncontext.ports.CampaignRepository; +import com.loremind.domain.playcontext.EntryType; +import com.loremind.domain.playcontext.Playthrough; +import com.loremind.domain.playcontext.Session; +import com.loremind.domain.playcontext.SessionEntry; +import com.loremind.domain.playcontext.ports.PlaythroughRepository; +import com.loremind.domain.playcontext.ports.SessionEntryRepository; +import com.loremind.domain.playcontext.ports.SessionRepository; +import com.loremind.infrastructure.web.controller.SessionEntryController.EntryRequest; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * Tests d'intégration du SessionEntryController (endpoints imbriqués sous une Session). + * Chaîne de fixtures : Campaign -> Playthrough -> Session. + */ +@SpringBootTest +@AutoConfigureMockMvc +@Transactional +class SessionEntryControllerTest { + + @Autowired private MockMvc mockMvc; + @Autowired private ObjectMapper objectMapper; + @Autowired private CampaignRepository campaignRepository; + @Autowired private PlaythroughRepository playthroughRepository; + @Autowired private SessionRepository sessionRepository; + @Autowired private SessionEntryRepository entryRepository; + + private String sessionId; + + @BeforeEach + void setUp() { + String campaignId = campaignRepository.save( + Campaign.builder().name("C").description("").build()).getId(); + String playthroughId = playthroughRepository.save( + Playthrough.builder().campaignId(campaignId).name("Partie").build()).getId(); + sessionId = sessionRepository.save(Session.builder() + .name("Session") + .playthroughId(playthroughId) + .startedAt(LocalDateTime.now()) + .build()).getId(); + } + + private SessionEntry saveEntry() { + return entryRepository.save(SessionEntry.builder() + .sessionId(sessionId) + .type(EntryType.NOTE) + .content("Contenu initial") + .occurredAt(LocalDateTime.now()) + .build()); + } + + @Test + void getEntries_returnsArray() throws Exception { + saveEntry(); + mockMvc.perform(get("/api/sessions/{sessionId}/entries", sessionId)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$").isArray()); + } + + @Test + void createEntry_returns200() throws Exception { + EntryRequest req = new EntryRequest(EntryType.EVENT, "Combat gagné", LocalDateTime.now()); + mockMvc.perform(post("/api/sessions/{sessionId}/entries", sessionId) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(req))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.content").value("Combat gagné")) + .andExpect(jsonPath("$.type").value("EVENT")); + } + + @Test + void createEntry_defaultsTypeToNote_whenNull() throws Exception { + EntryRequest req = new EntryRequest(null, "Note sans type", null); + mockMvc.perform(post("/api/sessions/{sessionId}/entries", sessionId) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(req))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.type").value("NOTE")); + } + + @Test + void createEntry_returns400_whenSessionMissing() throws Exception { + EntryRequest req = new EntryRequest(EntryType.NOTE, "x", null); + mockMvc.perform(post("/api/sessions/{sessionId}/entries", "999999999") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(req))) + .andExpect(status().isBadRequest()); + } + + @Test + void createEntry_returns400_whenContentBlank() throws Exception { + EntryRequest req = new EntryRequest(EntryType.NOTE, " ", null); + mockMvc.perform(post("/api/sessions/{sessionId}/entries", sessionId) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(req))) + .andExpect(status().isBadRequest()); + } + + @Test + void updateEntry_returns200() throws Exception { + SessionEntry e = saveEntry(); + EntryRequest req = new EntryRequest(EntryType.DICE_ROLL, "Jet modifié", LocalDateTime.now()); + mockMvc.perform(put("/api/sessions/{sessionId}/entries/{entryId}", sessionId, e.getId()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(req))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.content").value("Jet modifié")) + .andExpect(jsonPath("$.type").value("DICE_ROLL")); + } + + @Test + void updateEntry_returns400_whenMissing() throws Exception { + EntryRequest req = new EntryRequest(EntryType.NOTE, "x", null); + mockMvc.perform(put("/api/sessions/{sessionId}/entries/{entryId}", sessionId, "999999999") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(req))) + .andExpect(status().isBadRequest()); + } + + @Test + void deleteEntry_returns204() throws Exception { + SessionEntry e = saveEntry(); + mockMvc.perform(delete("/api/sessions/{sessionId}/entries/{entryId}", sessionId, e.getId())) + .andExpect(status().isNoContent()); + } + + @Test + void deleteEntry_returns400_whenMissing() throws Exception { + mockMvc.perform(delete("/api/sessions/{sessionId}/entries/{entryId}", sessionId, "999999999")) + .andExpect(status().isBadRequest()); + } +} diff --git a/core/src/test/java/com/loremind/infrastructure/web/controller/SettingsControllerDemoModeTest.java b/core/src/test/java/com/loremind/infrastructure/web/controller/SettingsControllerDemoModeTest.java new file mode 100644 index 0000000..f8d3cac --- /dev/null +++ b/core/src/test/java/com/loremind/infrastructure/web/controller/SettingsControllerDemoModeTest.java @@ -0,0 +1,71 @@ +package com.loremind.infrastructure.web.controller; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.web.servlet.MockMvc; + +import java.nio.charset.StandardCharsets; +import java.util.Base64; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * Verifie le blocage des endpoints sensibles de {@link SettingsController} quand + * {@code app.demo-mode=true} : lecture/ecriture des settings et gestion des modeles + * Ollama (pull/delete) doivent renvoyer 403, sans jamais toucher le Brain. + *

+ * Les requetes sont authentifiees en ADMIN (sinon la securite renverrait 401 avant + * d'atteindre le garde demo). Ce test valide donc aussi que le + * {@code GlobalExceptionHandler} propage le statut declare par le + * {@link org.springframework.web.server.ResponseStatusException} (403), sans l'ecraser en 500. + */ +@SpringBootTest +@AutoConfigureMockMvc +@TestPropertySource(properties = "app.demo-mode=true") +class SettingsControllerDemoModeTest { + + private static final String ADMIN_AUTH = "Basic " + Base64.getEncoder() + .encodeToString("test-admin:test-admin-password".getBytes(StandardCharsets.UTF_8)); + + @Autowired private MockMvc mockMvc; + + @Test + void getSettings_returns403() throws Exception { + mockMvc.perform(get("/api/settings").header(HttpHeaders.AUTHORIZATION, ADMIN_AUTH)) + .andExpect(status().isForbidden()); + } + + @Test + void updateSettings_returns403() throws Exception { + mockMvc.perform(put("/api/settings") + .header(HttpHeaders.AUTHORIZATION, ADMIN_AUTH) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"theme\":\"light\"}")) + .andExpect(status().isForbidden()); + } + + @Test + void pullOllamaModel_returns403() throws Exception { + mockMvc.perform(post("/api/settings/models/ollama/pull") + .header(HttpHeaders.AUTHORIZATION, ADMIN_AUTH) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"name\":\"llama3\"}")) + .andExpect(status().isForbidden()); + } + + @Test + void deleteOllamaModel_returns403() throws Exception { + mockMvc.perform(delete("/api/settings/models/ollama/{name}", "llama3") + .header(HttpHeaders.AUTHORIZATION, ADMIN_AUTH)) + .andExpect(status().isForbidden()); + } +} diff --git a/core/src/test/java/com/loremind/infrastructure/web/controller/SettingsControllerPullTest.java b/core/src/test/java/com/loremind/infrastructure/web/controller/SettingsControllerPullTest.java new file mode 100644 index 0000000..978110e --- /dev/null +++ b/core/src/test/java/com/loremind/infrastructure/web/controller/SettingsControllerPullTest.java @@ -0,0 +1,96 @@ +package com.loremind.infrastructure.web.controller; + +import com.sun.net.httpserver.HttpServer; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; + +import java.io.IOException; +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.util.Base64; + +import static org.hamcrest.Matchers.containsString; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.asyncDispatch; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.request; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * Couvre le pull streamé d'un modèle Ollama de {@link SettingsController} + * (POST /api/settings/models/ollama/pull). Cet endpoint BYPASS le RestTemplate + * (donc {@link SettingsControllerTest} ne peut pas le couvrir via MockRestServiceServer) : + * il ouvre un {@code java.net.http.HttpClient} directement vers le Brain et relaie + * le NDJSON chunk par chunk. + *

+ * On démarre donc un vrai mini serveur HTTP local (JDK {@link HttpServer}) qui imite + * la réponse NDJSON du Brain, et on pointe {@code brain.base-url} dessus via + * {@link DynamicPropertySource}. Cela exerce tout le corps streamé : sérialisation + * {@code toJson} du body, envoi HTTP/1.1, en-tête d'auth interne, boucle de lecture/relai. + */ +@SpringBootTest +@AutoConfigureMockMvc +class SettingsControllerPullTest { + + /** Identifiants ADMIN (cf. src/test/resources/application.properties) — endpoint sous /api/settings/**. */ + private static final String ADMIN_AUTH = "Basic " + Base64.getEncoder() + .encodeToString("test-admin:test-admin-password".getBytes(StandardCharsets.UTF_8)); + + private static HttpServer stubBrain; + + @Autowired private MockMvc mockMvc; + + @DynamicPropertySource + static void brainStub(DynamicPropertyRegistry registry) throws IOException { + stubBrain = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + stubBrain.createContext("/models/ollama/pull", exchange -> { + // On consomme la requête (le body JSON sérialisé par le controller) puis on + // renvoie un flux NDJSON de progression, comme le ferait le Brain/Ollama. + exchange.getRequestBody().readAllBytes(); + byte[] body = ("{\"status\":\"pulling manifest\"}\n" + + "{\"status\":\"success\"}\n").getBytes(StandardCharsets.UTF_8); + exchange.getResponseHeaders().set("Content-Type", "application/x-ndjson"); + exchange.sendResponseHeaders(200, body.length); + exchange.getResponseBody().write(body); + exchange.close(); + }); + stubBrain.start(); + int port = stubBrain.getAddress().getPort(); + registry.add("brain.base-url", () -> "http://127.0.0.1:" + port); + } + + @AfterAll + static void stopStub() { + if (stubBrain != null) { + stubBrain.stop(0); + } + } + + @Test + void pullOllamaModel_streamsBrainNdjson() throws Exception { + // Body avec des valeurs de types variés (String/Number/Boolean/null) pour + // exercer toutes les branches du sérialiseur maison toJson(...). + String body = "{\"name\":\"llama3\",\"n\":3,\"insecure\":true,\"opt\":null}"; + + MvcResult result = mockMvc.perform(post("/api/settings/models/ollama/pull") + .header(HttpHeaders.AUTHORIZATION, ADMIN_AUTH) + .contentType(MediaType.APPLICATION_JSON) + .content(body)) + .andExpect(request().asyncStarted()) + .andReturn(); + + mockMvc.perform(asyncDispatch(result)) + .andExpect(status().isOk()) + .andExpect(content().string(containsString("pulling manifest"))) + .andExpect(content().string(containsString("success"))); + } +} diff --git a/core/src/test/java/com/loremind/infrastructure/web/controller/SettingsControllerTest.java b/core/src/test/java/com/loremind/infrastructure/web/controller/SettingsControllerTest.java new file mode 100644 index 0000000..085ba67 --- /dev/null +++ b/core/src/test/java/com/loremind/infrastructure/web/controller/SettingsControllerTest.java @@ -0,0 +1,172 @@ +package com.loremind.infrastructure.web.controller; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.MediaType; +import org.springframework.test.web.client.MockRestServiceServer; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.web.client.RestTemplate; + +import java.nio.charset.StandardCharsets; +import java.util.Base64; + +import static org.hamcrest.Matchers.endsWith; +import static org.springframework.test.web.client.match.MockRestRequestMatchers.content; +import static org.springframework.test.web.client.match.MockRestRequestMatchers.method; +import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo; +import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * Tests d'integration pour {@link SettingsController} (proxy fin vers le Brain). + *

+ * Le {@link RestTemplate} (@Primary {@code brainRestTemplate}, celui injecte dans + * le controller) est instrumente avec {@link MockRestServiceServer} : on intercepte + * les appels sortants vers le Brain et on renvoie des reponses canned, sans reseau. + * Cela verifie le contrat de proxy (methode, chemin, body relaye, reponse propagee). + *

+ * {@code /api/settings/**} exige le role ADMIN (HTTP Basic) : chaque requete porte + * donc l'entete d'auth construite a partir des identifiants de test. Le blocage + * {@code app.demo-mode} est couvert par {@link SettingsControllerDemoModeTest}. + */ +@SpringBootTest +@AutoConfigureMockMvc +class SettingsControllerTest { + + /** Identifiants definis dans src/test/resources/application.properties. */ + private static final String ADMIN_AUTH = "Basic " + Base64.getEncoder() + .encodeToString("test-admin:test-admin-password".getBytes(StandardCharsets.UTF_8)); + + @Autowired private MockMvc mockMvc; + @Autowired private RestTemplate restTemplate; + + private MockRestServiceServer brain; + + @BeforeEach + void setUp() { + brain = MockRestServiceServer.createServer(restTemplate); + } + + @Test + void getSettings_forwardsToBrain_andReturnsBody() throws Exception { + brain.expect(requestTo(endsWith("/settings"))) + .andExpect(method(HttpMethod.GET)) + .andRespond(withSuccess("{\"theme\":\"dark\"}", MediaType.APPLICATION_JSON)); + + mockMvc.perform(get("/api/settings").header(HttpHeaders.AUTHORIZATION, ADMIN_AUTH)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.theme").value("dark")); + brain.verify(); + } + + @Test + void updateSettings_forwardsPatchBody() throws Exception { + brain.expect(requestTo(endsWith("/settings"))) + .andExpect(method(HttpMethod.PUT)) + .andExpect(content().json("{\"theme\":\"light\"}")) + .andRespond(withSuccess("{\"theme\":\"light\"}", MediaType.APPLICATION_JSON)); + + mockMvc.perform(put("/api/settings") + .header(HttpHeaders.AUTHORIZATION, ADMIN_AUTH) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"theme\":\"light\"}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.theme").value("light")); + brain.verify(); + } + + @Test + void listOllamaModels_forwards() throws Exception { + brain.expect(requestTo(endsWith("/models/ollama"))) + .andExpect(method(HttpMethod.GET)) + .andRespond(withSuccess("{\"models\":[]}", MediaType.APPLICATION_JSON)); + + mockMvc.perform(get("/api/settings/models/ollama").header(HttpHeaders.AUTHORIZATION, ADMIN_AUTH)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.models").isArray()); + brain.verify(); + } + + @Test + void getOllamaModelInfo_forwardsPostBody() throws Exception { + brain.expect(requestTo(endsWith("/models/ollama/info"))) + .andExpect(method(HttpMethod.POST)) + .andExpect(content().json("{\"name\":\"llama3\"}")) + .andRespond(withSuccess("{\"size\":123}", MediaType.APPLICATION_JSON)); + + mockMvc.perform(post("/api/settings/models/ollama/info") + .header(HttpHeaders.AUTHORIZATION, ADMIN_AUTH) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"name\":\"llama3\"}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.size").value(123)); + brain.verify(); + } + + @Test + void deleteOllamaModel_forwardsWithNameInPath() throws Exception { + brain.expect(requestTo(endsWith("/models/ollama/llama3"))) + .andExpect(method(HttpMethod.DELETE)) + .andRespond(withSuccess("{\"deleted\":true}", MediaType.APPLICATION_JSON)); + + mockMvc.perform(delete("/api/settings/models/ollama/{name}", "llama3") + .header(HttpHeaders.AUTHORIZATION, ADMIN_AUTH)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.deleted").value(true)); + brain.verify(); + } + + @Test + void listOneMinModels_forwards() throws Exception { + brain.expect(requestTo(endsWith("/models/onemin"))) + .andExpect(method(HttpMethod.GET)) + .andRespond(withSuccess("{\"models\":[]}", MediaType.APPLICATION_JSON)); + + mockMvc.perform(get("/api/settings/models/onemin").header(HttpHeaders.AUTHORIZATION, ADMIN_AUTH)) + .andExpect(status().isOk()); + brain.verify(); + } + + @Test + void listOpenRouterModels_forwards() throws Exception { + brain.expect(requestTo(endsWith("/models/openrouter"))) + .andExpect(method(HttpMethod.GET)) + .andRespond(withSuccess("{\"models\":[]}", MediaType.APPLICATION_JSON)); + + mockMvc.perform(get("/api/settings/models/openrouter").header(HttpHeaders.AUTHORIZATION, ADMIN_AUTH)) + .andExpect(status().isOk()); + brain.verify(); + } + + @Test + void listMistralModels_forwards() throws Exception { + brain.expect(requestTo(endsWith("/models/mistral"))) + .andExpect(method(HttpMethod.GET)) + .andRespond(withSuccess("{\"models\":[]}", MediaType.APPLICATION_JSON)); + + mockMvc.perform(get("/api/settings/models/mistral").header(HttpHeaders.AUTHORIZATION, ADMIN_AUTH)) + .andExpect(status().isOk()); + brain.verify(); + } + + @Test + void listGeminiModels_forwards() throws Exception { + brain.expect(requestTo(endsWith("/models/gemini"))) + .andExpect(method(HttpMethod.GET)) + .andRespond(withSuccess("{\"models\":[]}", MediaType.APPLICATION_JSON)); + + mockMvc.perform(get("/api/settings/models/gemini").header(HttpHeaders.AUTHORIZATION, ADMIN_AUTH)) + .andExpect(status().isOk()); + brain.verify(); + } +} diff --git a/core/src/test/java/com/loremind/infrastructure/web/controller/UpdatesControllerDemoModeTest.java b/core/src/test/java/com/loremind/infrastructure/web/controller/UpdatesControllerDemoModeTest.java new file mode 100644 index 0000000..1d8ad0b --- /dev/null +++ b/core/src/test/java/com/loremind/infrastructure/web/controller/UpdatesControllerDemoModeTest.java @@ -0,0 +1,58 @@ +package com.loremind.infrastructure.web.controller; + +import com.loremind.infrastructure.updates.UpdateCheckService; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.http.HttpHeaders; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.web.servlet.MockMvc; + +import java.nio.charset.StandardCharsets; +import java.util.Base64; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * Verifie le verrouillage de {@link UpdatesController} quand {@code app.demo-mode=true} : + * check / check-beta / apply doivent renvoyer 403 (garde demo), jamais 401/200. + *

+ * Les requetes sont authentifiees en ADMIN (sinon la securite renverrait 401 avant + * d'atteindre le garde demo). Le {@code GlobalExceptionHandler} doit propager le + * statut du {@link org.springframework.web.server.ResponseStatusException} (403). + * Contexte distinct (fichier separe) car la valeur demo-mode est injectee a la + * construction du controleur — meme convention que SettingsControllerDemoModeTest. + */ +@SpringBootTest +@AutoConfigureMockMvc +@TestPropertySource(properties = "app.demo-mode=true") +class UpdatesControllerDemoModeTest { + + private static final String ADMIN_AUTH = "Basic " + Base64.getEncoder() + .encodeToString("test-admin:test-admin-password".getBytes(StandardCharsets.UTF_8)); + + @Autowired private MockMvc mockMvc; + @MockBean private UpdateCheckService updates; + + @Test + void check_returns403_inDemoMode() throws Exception { + mockMvc.perform(get("/api/admin/updates/check").header(HttpHeaders.AUTHORIZATION, ADMIN_AUTH)) + .andExpect(status().isForbidden()); + } + + @Test + void checkBeta_returns403_inDemoMode() throws Exception { + mockMvc.perform(get("/api/admin/updates/check-beta").header(HttpHeaders.AUTHORIZATION, ADMIN_AUTH)) + .andExpect(status().isForbidden()); + } + + @Test + void apply_returns403_inDemoMode() throws Exception { + mockMvc.perform(post("/api/admin/updates/apply").header(HttpHeaders.AUTHORIZATION, ADMIN_AUTH)) + .andExpect(status().isForbidden()); + } +} diff --git a/core/src/test/java/com/loremind/infrastructure/web/controller/UpdatesControllerTest.java b/core/src/test/java/com/loremind/infrastructure/web/controller/UpdatesControllerTest.java new file mode 100644 index 0000000..1be83ba --- /dev/null +++ b/core/src/test/java/com/loremind/infrastructure/web/controller/UpdatesControllerTest.java @@ -0,0 +1,104 @@ +package com.loremind.infrastructure.web.controller; + +import com.loremind.infrastructure.updates.UpdateCheckService; +import com.loremind.infrastructure.updates.UpdateCheckService.BetaStatus; +import com.loremind.infrastructure.updates.UpdateCheckService.ImageStatus; +import com.loremind.infrastructure.updates.UpdateCheckService.ImageStatusKind; +import com.loremind.infrastructure.updates.UpdateCheckService.UpdateStatus; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.http.HttpHeaders; +import org.springframework.test.web.servlet.MockMvc; + +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.util.Base64; +import java.util.List; + +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * Tests d'integration pour {@link UpdatesController} (endpoints admin). + *

+ * Le {@link UpdateCheckService} est mocke : c'est un port externe (registry + + * Watchtower). Aucun appel reseau reel. La classe externe couvre le mode normal + * (demo off) ; la classe imbriquee {@link DemoMode} force {@code app.demo-mode=true} + * pour verifier le verrouillage 403. + */ +@SpringBootTest +@AutoConfigureMockMvc +class UpdatesControllerTest { + + /** Identifiants admin definis dans src/test/resources/application.properties. */ + private static final String ADMIN_AUTH = "Basic " + Base64.getEncoder() + .encodeToString("test-admin:test-admin-password".getBytes(StandardCharsets.UTF_8)); + + @Autowired private MockMvc mockMvc; + @MockBean private UpdateCheckService updates; + + private UpdateStatus sampleUpdate() { + return new UpdateStatus(true, true, false, "1.0.0", + List.of(new ImageStatus("img", "1.0.0", "1.1.0", ImageStatusKind.UPDATE_AVAILABLE)), + Instant.now()); + } + + private BetaStatus sampleBeta() { + return new BetaStatus(true, false, false, List.of(), Instant.now(), null); + } + + // --- GET /check --------------------------------------------------------- + + @Test + void check_returns200() throws Exception { + when(updates.check()).thenReturn(sampleUpdate()); + mockMvc.perform(get("/api/admin/updates/check").header(HttpHeaders.AUTHORIZATION, ADMIN_AUTH)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.enabled").value(true)) + .andExpect(jsonPath("$.updateAvailable").value(true)); + } + + // --- GET /check-beta ---------------------------------------------------- + + @Test + void checkBeta_returns200() throws Exception { + when(updates.checkBeta()).thenReturn(sampleBeta()); + mockMvc.perform(get("/api/admin/updates/check-beta").header(HttpHeaders.AUTHORIZATION, ADMIN_AUTH)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.enabled").value(true)); + } + + // --- POST /apply -------------------------------------------------------- + + @Test + void apply_returns202_whenEnabledAndTriggered() throws Exception { + when(updates.isEnabled()).thenReturn(true); + mockMvc.perform(post("/api/admin/updates/apply").header(HttpHeaders.AUTHORIZATION, ADMIN_AUTH)) + .andExpect(status().isAccepted()) + .andExpect(jsonPath("$.status").value("triggered")); + } + + @Test + void apply_returns503_whenNotConfigured() throws Exception { + when(updates.isEnabled()).thenReturn(false); + mockMvc.perform(post("/api/admin/updates/apply").header(HttpHeaders.AUTHORIZATION, ADMIN_AUTH)) + .andExpect(status().isServiceUnavailable()) + .andExpect(jsonPath("$.error").exists()); + } + + @Test + void apply_returns502_whenWatchtowerUnreachable() throws Exception { + when(updates.isEnabled()).thenReturn(true); + doThrow(new RuntimeException("connect timeout")).when(updates).apply(); + mockMvc.perform(post("/api/admin/updates/apply").header(HttpHeaders.AUTHORIZATION, ADMIN_AUTH)) + .andExpect(status().isBadGateway()) + .andExpect(jsonPath("$.error").exists()); + } +} diff --git a/core/src/test/java/com/loremind/infrastructure/web/controller/VersionControllerTest.java b/core/src/test/java/com/loremind/infrastructure/web/controller/VersionControllerTest.java new file mode 100644 index 0000000..f2c00c0 --- /dev/null +++ b/core/src/test/java/com/loremind/infrastructure/web/controller/VersionControllerTest.java @@ -0,0 +1,34 @@ +package com.loremind.infrastructure.web.controller; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.web.servlet.MockMvc; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * Tests d'integration pour {@link VersionController}. + *

+ * Endpoint trivial : GET /api/version renvoie {"version": "..."}. + * En test, {@code BuildProperties} peut etre absent (pas de build Maven) : + * le controleur retombe alors sur "dev". On verifie seulement que la cle + * "version" est presente et non vide. + */ +@SpringBootTest +@AutoConfigureMockMvc +class VersionControllerTest { + + @Autowired private MockMvc mockMvc; + + @Test + void getVersion_returns200_withVersionKey() throws Exception { + mockMvc.perform(get("/api/version")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.version").exists()) + .andExpect(jsonPath("$.version").isNotEmpty()); + } +} diff --git a/core/src/test/resources/application.properties b/core/src/test/resources/application.properties index 1ee4c4a..8d90fdb 100644 --- a/core/src/test/resources/application.properties +++ b/core/src/test/resources/application.properties @@ -14,6 +14,15 @@ spring.jpa.database-platform=org.hibernate.dialect.PostgreSQLDialect spring.jpa.hibernate.ddl-auto=create-drop spring.jpa.show-sql=false +# Pool Hikari volontairement minuscule en test : la suite cree de nombreux +# contextes Spring distincts (combinaisons de @MockBean / @TestPropertySource), +# tous gardes en cache simultanement. Avec le pool par defaut (10), on epuisait +# les connexions Postgres ("remaining connection slots are reserved"). 2 par +# contexte suffit (tests sequentiels) et borne le total bien sous max_connections. +spring.datasource.hikari.maximum-pool-size=2 +spring.datasource.hikari.minimum-idle=0 +spring.datasource.hikari.idle-timeout=10000 + # Credentials admin factices pour satisfaire le fail-closed de SecurityConfig. admin.username=test-admin admin.password=test-admin-password