Ajout, corrections et modifications de tests unitaires pour la partie infrastructure.web.controller.
Amélioration de la couverture de test
This commit is contained in:
@@ -11,6 +11,7 @@ import org.springframework.web.bind.MethodArgumentNotValidException;
|
|||||||
import org.springframework.web.bind.annotation.ExceptionHandler;
|
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||||
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
||||||
import org.springframework.web.context.request.async.AsyncRequestNotUsableException;
|
import org.springframework.web.context.request.async.AsyncRequestNotUsableException;
|
||||||
|
import org.springframework.web.server.ResponseStatusException;
|
||||||
|
|
||||||
import java.util.LinkedHashMap;
|
import java.util.LinkedHashMap;
|
||||||
import java.util.Map;
|
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).
|
||||||
|
* <p>
|
||||||
|
* 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<Map<String, String>> 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
|
* Client HTTP parti pendant une reponse asynchrone (SSE) : le navigateur a ferme
|
||||||
* la connexion (onglet ferme, proxy coupe...), la reponse n'est plus utilisable.
|
* la connexion (onglet ferme, proxy coupe...), la reponse n'est plus utilisable.
|
||||||
|
|||||||
@@ -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).
|
||||||
|
* <p>
|
||||||
|
* Les trois use cases ({@link StreamChatForLoreUseCase},
|
||||||
|
* {@link StreamChatForCampaignUseCase}, {@link StreamChatForSessionUseCase}) sont
|
||||||
|
* mockes : sinon chaque test ferait un vrai appel au Brain (indisponible en test).
|
||||||
|
* <p>
|
||||||
|
* 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<ChatUsage> onUsage = inv.getArgument(3);
|
||||||
|
Consumer<String> 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<List<ChatMessage>> 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<ChatMessage> 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<Throwable> 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<String> 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<String> 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")));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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).
|
||||||
|
* <p>
|
||||||
|
* 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.
|
||||||
|
* <p>
|
||||||
|
* 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<String> 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<Throwable> 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")));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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).
|
||||||
|
* <p>
|
||||||
|
* 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.
|
||||||
|
* <p>
|
||||||
|
* 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.
|
||||||
|
* <p>
|
||||||
|
* 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<CampaignImportProposal> 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<Throwable> 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<CampaignImportProgress> onProgress = inv.getArgument(2);
|
||||||
|
Runnable onHeartbeat = inv.getArgument(3);
|
||||||
|
Consumer<String> onStatus = inv.getArgument(4);
|
||||||
|
Consumer<CampaignImportProposal> 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<String> onStatus = inv.getArgument(4);
|
||||||
|
Consumer<CampaignImportProposal> 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<Throwable> 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
|
* <p>
|
||||||
|
* 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<CampaignImportProgress> onProgress = inv.getArgument(2);
|
||||||
|
Runnable onHeartbeat = inv.getArgument(3);
|
||||||
|
Consumer<CampaignImportProposal> onDone = inv.getArgument(5);
|
||||||
|
Consumer<Throwable> 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,8 @@ import com.loremind.domain.campaigncontext.Chapter;
|
|||||||
import com.loremind.domain.campaigncontext.ports.ArcRepository;
|
import com.loremind.domain.campaigncontext.ports.ArcRepository;
|
||||||
import com.loremind.domain.campaigncontext.ports.CampaignRepository;
|
import com.loremind.domain.campaigncontext.ports.CampaignRepository;
|
||||||
import com.loremind.domain.campaigncontext.ports.ChapterRepository;
|
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 com.loremind.infrastructure.web.dto.campaigncontext.ChapterDTO;
|
||||||
import org.junit.jupiter.api.BeforeEach;
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
@@ -34,13 +36,18 @@ class ChapterControllerTest {
|
|||||||
@Autowired private CampaignRepository campaignRepository;
|
@Autowired private CampaignRepository campaignRepository;
|
||||||
@Autowired private ArcRepository arcRepository;
|
@Autowired private ArcRepository arcRepository;
|
||||||
@Autowired private ChapterRepository chapterRepository;
|
@Autowired private ChapterRepository chapterRepository;
|
||||||
|
@Autowired private PlaythroughRepository playthroughRepository;
|
||||||
|
|
||||||
private String arcId;
|
private String arcId;
|
||||||
|
private String playthroughId;
|
||||||
|
|
||||||
@BeforeEach
|
@BeforeEach
|
||||||
void setUp() {
|
void setUp() {
|
||||||
String campaignId = campaignRepository.save(Campaign.builder().name("C").description("").build()).getId();
|
String campaignId = campaignRepository.save(Campaign.builder().name("C").description("").build()).getId();
|
||||||
arcId = arcRepository.save(Arc.builder().campaignId(campaignId).name("A").order(0).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
|
@Test
|
||||||
@@ -106,4 +113,52 @@ class ChapterControllerTest {
|
|||||||
mockMvc.perform(delete("/api/chapters/{id}", saved.getId()))
|
mockMvc.perform(delete("/api/chapters/{id}", saved.getId()))
|
||||||
.andExpect(status().isNoContent());
|
.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());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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}.
|
||||||
|
* <p>
|
||||||
|
* 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,23 +1,43 @@
|
|||||||
package com.loremind.infrastructure.web.controller;
|
package com.loremind.infrastructure.web.controller;
|
||||||
|
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
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.gamesystemcontext.GameSystemDTO;
|
||||||
import com.loremind.infrastructure.web.dto.shared.TemplateFieldDTO;
|
import com.loremind.infrastructure.web.dto.shared.TemplateFieldDTO;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
|
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
|
||||||
import org.springframework.boot.test.context.SpringBootTest;
|
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.http.MediaType;
|
||||||
|
import org.springframework.mock.web.MockMultipartFile;
|
||||||
import org.springframework.test.web.servlet.MockMvc;
|
import org.springframework.test.web.servlet.MockMvc;
|
||||||
import org.springframework.test.web.servlet.MvcResult;
|
import org.springframework.test.web.servlet.MvcResult;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
import java.util.List;
|
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.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.post;
|
||||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put;
|
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.jsonPath;
|
||||||
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.request;
|
||||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -32,6 +52,29 @@ class GameSystemControllerTest {
|
|||||||
@Autowired private MockMvc mockMvc;
|
@Autowired private MockMvc mockMvc;
|
||||||
@Autowired private ObjectMapper objectMapper;
|
@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
|
@Test
|
||||||
void create_persistsCharacterAndNpcTemplates() throws Exception {
|
void create_persistsCharacterAndNpcTemplates() throws Exception {
|
||||||
GameSystemDTO dto = new GameSystemDTO();
|
GameSystemDTO dto = new GameSystemDTO();
|
||||||
@@ -105,4 +148,228 @@ class GameSystemControllerTest {
|
|||||||
.content(objectMapper.writeValueAsString(dto)))
|
.content(objectMapper.writeValueAsString(dto)))
|
||||||
.andExpect(status().is4xxClientError());
|
.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<RulesImportProgress> onProgress = inv.getArgument(2);
|
||||||
|
Consumer<RulesImportResult> 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<Throwable> 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<String> onStatus = inv.getArgument(4);
|
||||||
|
Consumer<RulesImportResult> 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<String> onStatus = inv.getArgument(4);
|
||||||
|
Consumer<RulesImportResult> 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")));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<RulesImportProgress> onProgress = inv.getArgument(2);
|
||||||
|
Runnable onHeartbeat = inv.getArgument(3);
|
||||||
|
Consumer<RulesImportResult> onDone = inv.getArgument(5);
|
||||||
|
Consumer<Throwable> 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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).
|
||||||
|
* <p>
|
||||||
|
* 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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).
|
||||||
|
* <p>
|
||||||
|
* 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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).
|
||||||
|
* <p>
|
||||||
|
* {@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.
|
||||||
|
* <p>
|
||||||
|
* 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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).
|
||||||
|
* <p>
|
||||||
|
* 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).
|
||||||
|
* <p>
|
||||||
|
* 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<String> 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<Throwable> 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<String> onSourcesJson = inv.getArgument(4);
|
||||||
|
java.util.function.Consumer<String> onToken = inv.getArgument(5);
|
||||||
|
java.util.function.Consumer<NotebookChatStreamer.Progress> 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")));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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).
|
||||||
|
* <p>
|
||||||
|
* 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 :
|
||||||
|
* <ul>
|
||||||
|
* <li>OK -> 200 + {@code {values:{...}}}</li>
|
||||||
|
* <li>IllegalArgumentException (page introuvable) -> 404</li>
|
||||||
|
* <li>AiProviderException (Brain HS) -> 502</li>
|
||||||
|
* <li>IllegalStateException "aucun champ" -> 422</li>
|
||||||
|
* <li>IllegalStateException autre (incoherence BDD) -> 500</li>
|
||||||
|
* </ul>
|
||||||
|
*/
|
||||||
|
@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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
* <p>
|
||||||
|
* 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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).
|
||||||
|
* <p>
|
||||||
|
* 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
|
* <p>
|
||||||
|
* 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
|
* <p>
|
||||||
|
* 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")));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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).
|
||||||
|
* <p>
|
||||||
|
* 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).
|
||||||
|
* <p>
|
||||||
|
* {@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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
|
* <p>
|
||||||
|
* 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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).
|
||||||
|
* <p>
|
||||||
|
* 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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}.
|
||||||
|
* <p>
|
||||||
|
* 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,6 +14,15 @@ spring.jpa.database-platform=org.hibernate.dialect.PostgreSQLDialect
|
|||||||
spring.jpa.hibernate.ddl-auto=create-drop
|
spring.jpa.hibernate.ddl-auto=create-drop
|
||||||
spring.jpa.show-sql=false
|
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.
|
# Credentials admin factices pour satisfaire le fail-closed de SecurityConfig.
|
||||||
admin.username=test-admin
|
admin.username=test-admin
|
||||||
admin.password=test-admin-password
|
admin.password=test-admin-password
|
||||||
|
|||||||
Reference in New Issue
Block a user