diff --git a/core/src/test/java/com/loremind/infrastructure/ai/BrainAiChatClientTest.java b/core/src/test/java/com/loremind/infrastructure/ai/BrainAiChatClientTest.java new file mode 100644 index 0000000..ae64793 --- /dev/null +++ b/core/src/test/java/com/loremind/infrastructure/ai/BrainAiChatClientTest.java @@ -0,0 +1,180 @@ +package com.loremind.infrastructure.ai; + +import com.loremind.domain.generationcontext.ChatRequest; +import com.loremind.domain.generationcontext.ChatUsage; +import com.loremind.domain.generationcontext.ports.AiProviderException; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.web.reactive.function.client.ClientResponse; +import org.springframework.web.reactive.function.client.ExchangeFunction; +import org.springframework.web.reactive.function.client.WebClient; +import reactor.core.publisher.Mono; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Consumer; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * Tests unitaires purs (JUnit 5 + Mockito, sans Spring, sans réseau) de + * {@link BrainAiChatClient}. + * + * Principe : WebClient.Builder préconfiguré avec une ExchangeFunction mock + * renvoyant un flux SSE canned. Le payloadBuilder est mocké ; le sseParser est + * une instance réelle (simple parseur sans dépendance). + */ +class BrainAiChatClientTest { + + /** ChatRequest minimal valide : messages vide suffit (payloadBuilder mocké). */ + private ChatRequest minimalRequest() { + return ChatRequest.builder().messages(List.of()).build(); + } + + /** Construit un client dont le WebClient renvoie le corps SSE fourni. */ + private BrainAiChatClient clientWithSse(String sseBody) { + ExchangeFunction ef = req -> Mono.just( + ClientResponse.create(HttpStatus.OK) + .header(HttpHeaders.CONTENT_TYPE, MediaType.TEXT_EVENT_STREAM_VALUE) + .body(sseBody) + .build()); + return buildClient(ef); + } + + /** Construit un client dont le WebClient émet une erreur transport. */ + private BrainAiChatClient clientErroring() { + ExchangeFunction ef = req -> Mono.error(new RuntimeException("boom")); + return buildClient(ef); + } + + private BrainAiChatClient buildClient(ExchangeFunction ef) { + WebClient.Builder builder = WebClient.builder().exchangeFunction(ef); + BrainChatPayloadBuilder payloadBuilder = mock(BrainChatPayloadBuilder.class); + when(payloadBuilder.build(org.mockito.ArgumentMatchers.any())).thenReturn(Map.of()); + return new BrainAiChatClient(builder, "http://brain", payloadBuilder, new BrainSseParser()); + } + + // --- Collecteurs partagés pour les callbacks --- + private final List usages = new ArrayList<>(); + private final List tokens = new ArrayList<>(); + private final AtomicBoolean completed = new AtomicBoolean(false); + private final AtomicReference error = new AtomicReference<>(); + + private final Consumer onUsage = usages::add; + private final Consumer onToken = tokens::add; + private final Runnable onComplete = () -> completed.set(true); + private final Consumer onError = error::set; + + @Test + void flux_complet_parse_usage_et_token_puis_complete() { + String sse = + "event:usage\ndata:{\"system\":1,\"history\":2,\"current\":3,\"max\":100}\n\n" + + "data:{\"token\":\"Bonjour\"}\n\n" + + "event:done\ndata:{}\n\n"; + BrainAiChatClient client = clientWithSse(sse); + + client.streamChat(minimalRequest(), onUsage, onToken, onComplete, onError); + + // usage parsé et propagé + assertThat(usages).hasSize(1); + assertThat(usages.get(0)).isEqualTo(new ChatUsage(1, 2, 3, 100)); + // token propagé + assertThat(tokens).containsExactly("Bonjour"); + // event done ignoré (pas de token/usage supplémentaire) + // complétion appelée, pas d'erreur + assertThat(completed).isTrue(); + assertThat(error.get()).isNull(); + } + + @Test + void plusieurs_tokens_propages_dans_l_ordre() { + String sse = + "data:{\"token\":\"Bon\"}\n\n" + + "data:{\"token\":\"jour\"}\n\n" + + "event:done\ndata:{}\n\n"; + BrainAiChatClient client = clientWithSse(sse); + + client.streamChat(minimalRequest(), onUsage, onToken, onComplete, onError); + + assertThat(tokens).containsExactly("Bon", "jour"); + assertThat(completed).isTrue(); + assertThat(error.get()).isNull(); + } + + @Test + void event_error_declenche_onError_avec_AiProviderException() { + String sse = + "event:error\ndata:boom\n\n" + + "event:done\ndata:{}\n\n"; + BrainAiChatClient client = clientWithSse(sse); + + client.streamChat(minimalRequest(), onUsage, onToken, onComplete, onError); + + assertThat(error.get()) + .isInstanceOf(AiProviderException.class) + .hasMessageContaining("boom"); + // Aucun token émis sur ce flux. + assertThat(tokens).isEmpty(); + } + + @Test + void token_vide_n_est_pas_propage() { + String sse = + "data:{\"token\":\"\"}\n\n" + + "event:done\ndata:{}\n\n"; + BrainAiChatClient client = clientWithSse(sse); + + client.streamChat(minimalRequest(), onUsage, onToken, onComplete, onError); + + assertThat(tokens).isEmpty(); + assertThat(completed).isTrue(); + } + + @Test + void usage_illisible_n_est_pas_propage() { + // data usage sans champs numériques -> parser renvoie ChatUsage(0,0,0,0), + // donc propagé ; ici on teste un usage avec data non-null mais vide d'entiers. + String sse = + "event:usage\ndata:{\"system\":5}\n\n" + + "event:done\ndata:{}\n\n"; + BrainAiChatClient client = clientWithSse(sse); + + client.streamChat(minimalRequest(), onUsage, onToken, onComplete, onError); + + // Les champs absents tombent à 0 (parser tolérant). + assertThat(usages).containsExactly(new ChatUsage(5, 0, 0, 0)); + } + + @Test + void erreur_transport_declenche_onError_avec_AiProviderException() { + BrainAiChatClient client = clientErroring(); + + client.streamChat(minimalRequest(), onUsage, onToken, onComplete, onError); + + assertThat(error.get()) + .isInstanceOf(AiProviderException.class) + .hasMessageContaining("streaming chat"); + // onComplete NON appelé puisqu'une exception a interrompu blockLast(). + assertThat(completed).isFalse(); + } + + @Test + void flux_vide_appelle_seulement_onComplete() { + // Flux SSE vide : aucun évènement, blockLast renvoie null, onComplete appelé. + BrainAiChatClient client = clientWithSse(""); + + client.streamChat(minimalRequest(), onUsage, onToken, onComplete, onError); + + assertThat(tokens).isEmpty(); + assertThat(usages).isEmpty(); + assertThat(completed).isTrue(); + assertThat(error.get()).isNull(); + } +} diff --git a/core/src/test/java/com/loremind/infrastructure/ai/BrainAiClientTest.java b/core/src/test/java/com/loremind/infrastructure/ai/BrainAiClientTest.java new file mode 100644 index 0000000..1824056 --- /dev/null +++ b/core/src/test/java/com/loremind/infrastructure/ai/BrainAiClientTest.java @@ -0,0 +1,213 @@ +package com.loremind.infrastructure.ai; + +import com.loremind.domain.generationcontext.GenerationContext; +import com.loremind.domain.generationcontext.GenerationResult; +import com.loremind.domain.generationcontext.ports.AiProviderException; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.web.client.HttpServerErrorException; +import org.springframework.web.client.ResourceAccessException; +import org.springframework.web.client.RestTemplate; + +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * Tests unitaires PURS (JUnit 5 + Mockito, sans Spring, sans reseau) pour + * BrainAiClient. Le RestTemplate est mocke ; on couvre toutes les branches + * de callBrain ainsi que la traduction domaine -> wire -> domaine. + */ +class BrainAiClientTest { + + private static final String BASE_URL = "http://brain"; + private static final String EXPECTED_URL = "http://brain/generate-page"; + + private GenerationContext sampleContext() { + return new GenerationContext( + "Aetheria", + "Un monde de cendres", + "PNJ", + "Fiche personnage", + List.of("histoire", "motto"), + "Garde rouge" + ); + } + + private BrainGeneratePageResponse responseWith(Map values) { + BrainGeneratePageResponse r = new BrainGeneratePageResponse(); + r.setValues(values); + return r; + } + + // --- Branche succes ------------------------------------------------------ + + @Test + void generatePage_succes_traduitReponseWireEnResultatDomaine() { + RestTemplate rt = mock(RestTemplate.class); + BrainAiClient client = new BrainAiClient(rt, BASE_URL); + + BrainGeneratePageResponse wire = responseWith(Map.of( + "histoire", "Nee sous une etoile rouge", + "motto", "Jamais genou en terre" + )); + when(rt.postForObject(anyString(), any(), eq(BrainGeneratePageResponse.class))) + .thenReturn(wire); + + GenerationResult result = client.generatePage(sampleContext()); + + assertEquals(2, result.values().size()); + assertEquals("Jamais genou en terre", result.values().get("motto")); + } + + @Test + void generatePage_appelleBonneUrlEtContentTypeJson_avecCorpsTraduit() { + RestTemplate rt = mock(RestTemplate.class); + BrainAiClient client = new BrainAiClient(rt, BASE_URL); + + when(rt.postForObject(anyString(), any(), eq(BrainGeneratePageResponse.class))) + .thenReturn(responseWith(Map.of("histoire", "v"))); + + client.generatePage(sampleContext()); + + // Capture de l'URL et de l'HttpEntity envoyes au RestTemplate + ArgumentCaptor urlCaptor = ArgumentCaptor.forClass(String.class); + @SuppressWarnings("unchecked") + ArgumentCaptor> entityCaptor = + ArgumentCaptor.forClass(HttpEntity.class); + + org.mockito.Mockito.verify(rt).postForObject( + urlCaptor.capture(), + entityCaptor.capture(), + eq(BrainGeneratePageResponse.class)); + + assertEquals(EXPECTED_URL, urlCaptor.getValue()); + + HttpEntity entity = entityCaptor.getValue(); + HttpHeaders headers = entity.getHeaders(); + assertEquals(MediaType.APPLICATION_JSON, headers.getContentType()); + + // Verifie la traduction domaine -> wire (exerce les getters du record) + BrainGeneratePageRequest body = entity.getBody(); + assertEquals("Aetheria", body.loreName()); + assertEquals("Un monde de cendres", body.loreDescription()); + assertEquals("PNJ", body.folderName()); + assertEquals("Fiche personnage", body.templateName()); + assertEquals(List.of("histoire", "motto"), body.templateFields()); + assertEquals("Garde rouge", body.pageTitle()); + } + + // --- Branche reponse null ------------------------------------------------ + + @Test + void generatePage_reponseNull_leveAiProviderException() { + RestTemplate rt = mock(RestTemplate.class); + BrainAiClient client = new BrainAiClient(rt, BASE_URL); + + when(rt.postForObject(anyString(), any(), eq(BrainGeneratePageResponse.class))) + .thenReturn(null); + + AiProviderException ex = assertThrows(AiProviderException.class, + () -> client.generatePage(sampleContext())); + assertTrue(ex.getMessage().contains("reponse vide") + || ex.getMessage().contains("réponse vide")); + } + + // --- Branche values null ------------------------------------------------- + + @Test + void generatePage_valuesNull_leveAiProviderException() { + RestTemplate rt = mock(RestTemplate.class); + BrainAiClient client = new BrainAiClient(rt, BASE_URL); + + // Reponse non null mais avec values == null + when(rt.postForObject(anyString(), any(), eq(BrainGeneratePageResponse.class))) + .thenReturn(responseWith(null)); + + assertThrows(AiProviderException.class, + () -> client.generatePage(sampleContext())); + } + + // --- Branche ResourceAccessException (Brain injoignable) ----------------- + + @Test + void generatePage_brainInjoignable_leveAiProviderException() { + RestTemplate rt = mock(RestTemplate.class); + BrainAiClient client = new BrainAiClient(rt, BASE_URL); + + ResourceAccessException cause = new ResourceAccessException("down"); + when(rt.postForObject(anyString(), any(), eq(BrainGeneratePageResponse.class))) + .thenThrow(cause); + + AiProviderException ex = assertThrows(AiProviderException.class, + () -> client.generatePage(sampleContext())); + assertTrue(ex.getMessage().contains("injoignable")); + assertSame(cause, ex.getCause()); + } + + // --- Branche RestClientResponseException (HTTP 4xx/5xx) ------------------ + + @Test + void generatePage_erreurHttp_leveAiProviderExceptionAvecCode() { + RestTemplate rt = mock(RestTemplate.class); + BrainAiClient client = new BrainAiClient(rt, BASE_URL); + + HttpServerErrorException cause = HttpServerErrorException.create( + HttpStatus.BAD_GATEWAY, "Bad Gateway", + new HttpHeaders(), new byte[0], null); + when(rt.postForObject(anyString(), any(), eq(BrainGeneratePageResponse.class))) + .thenThrow(cause); + + AiProviderException ex = assertThrows(AiProviderException.class, + () -> client.generatePage(sampleContext())); + assertTrue(ex.getMessage().contains("502")); + assertSame(cause, ex.getCause()); + } + + // --- Branche AiProviderException deja traduite : re-levee telle quelle --- + + @Test + void generatePage_aiProviderExceptionDejaTraduite_estRelancee() { + RestTemplate rt = mock(RestTemplate.class); + BrainAiClient client = new BrainAiClient(rt, BASE_URL); + + AiProviderException original = new AiProviderException("deja traduite"); + when(rt.postForObject(anyString(), any(), eq(BrainGeneratePageResponse.class))) + .thenThrow(original); + + AiProviderException ex = assertThrows(AiProviderException.class, + () -> client.generatePage(sampleContext())); + // Pas de re-enveloppement : c'est exactement la meme instance + assertSame(original, ex); + } + + // --- Branche Exception generique (filet de securite) --------------------- + + @Test + void generatePage_exceptionGenerique_leveAiProviderException() { + RestTemplate rt = mock(RestTemplate.class); + BrainAiClient client = new BrainAiClient(rt, BASE_URL); + + RuntimeException cause = new IllegalStateException("JSON invalide"); + when(rt.postForObject(anyString(), any(), eq(BrainGeneratePageResponse.class))) + .thenThrow(cause); + + AiProviderException ex = assertThrows(AiProviderException.class, + () -> client.generatePage(sampleContext())); + assertTrue(ex.getMessage().contains("inattendue")); + assertSame(cause, ex.getCause()); + } +} diff --git a/core/src/test/java/com/loremind/infrastructure/ai/BrainCampaignAdaptClientTest.java b/core/src/test/java/com/loremind/infrastructure/ai/BrainCampaignAdaptClientTest.java new file mode 100644 index 0000000..5c67977 --- /dev/null +++ b/core/src/test/java/com/loremind/infrastructure/ai/BrainCampaignAdaptClientTest.java @@ -0,0 +1,166 @@ +package com.loremind.infrastructure.ai; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.web.reactive.function.client.ClientResponse; +import org.springframework.web.reactive.function.client.ExchangeFunction; +import org.springframework.web.reactive.function.client.WebClient; +import reactor.core.publisher.Mono; + +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Tests unitaires PURS (JUnit 5, sans Spring ni réseau) pour {@link BrainCampaignAdaptClient}. + * Le WebClient.Builder injecté embarque une ExchangeFunction qui renvoie un corps SSE canned. + */ +class BrainCampaignAdaptClientTest { + + private static final ObjectMapper MAPPER = new ObjectMapper(); + + private BrainCampaignAdaptClient clientReturning(String sseBody) { + ExchangeFunction ef = req -> Mono.just( + ClientResponse.create(HttpStatus.OK) + .header(HttpHeaders.CONTENT_TYPE, MediaType.TEXT_EVENT_STREAM_VALUE) + .body(sseBody) + .build()); + WebClient.Builder builder = WebClient.builder().exchangeFunction(ef); + return new BrainCampaignAdaptClient(builder, MAPPER, "http://brain", 30); + } + + private BrainCampaignAdaptClient clientFailingWith(Throwable boom) { + ExchangeFunction ef = req -> Mono.error(boom); + WebClient.Builder builder = WebClient.builder().exchangeFunction(ef); + return new BrainCampaignAdaptClient(builder, MAPPER, "http://brain", 30); + } + + /** Collecteur de callbacks + déclenchement de adviseStreaming. */ + private static final class Collector { + final StringBuilder tokens = new StringBuilder(); + final AtomicBoolean complete = new AtomicBoolean(false); + final AtomicReference error = new AtomicReference<>(); + + void invoke(BrainCampaignAdaptClient client, String filename, String brief, String messagesJson) { + client.adviseStreaming( + new byte[]{1, 2, 3}, + filename, + brief, + messagesJson, + tokens::append, + () -> complete.set(true), + error::set); + } + + void invoke(BrainCampaignAdaptClient client) { + invoke(client, "camp.pdf", "brief", "[]"); + } + } + + @Test + void streame_tokens_puis_done() { + String sse = + "event:token\ndata:{\"token\":\"Conseil\"}\n\n" + + "event:token\ndata:{\"token\":\" final\"}\n\n" + + "event:done\ndata:{}\n\n"; + Collector c = new Collector(); + c.invoke(clientReturning(sse)); + + assertEquals("Conseil final", c.tokens.toString()); + assertTrue(c.complete.get(), "onComplete appelé via event done"); + assertNull(c.error.get()); + } + + @Test + void token_vide_ou_absent_ignore() { + // token "" -> non émis ; champ token absent -> readField renvoie data (non vide) + // donc émis tel quel : on vérifie ce comportement réel. + String sse = + "event:token\ndata:{\"token\":\"\"}\n\n" + + "event:token\ndata:{\"token\":\"OK\"}\n\n" + + "event:done\ndata:{}\n\n"; + Collector c = new Collector(); + c.invoke(clientReturning(sse)); + + assertEquals("OK", c.tokens.toString()); + assertTrue(c.complete.get()); + } + + @Test + void event_error_appelle_onError_avec_runtimeexception() { + String sse = + "event:token\ndata:{\"token\":\"avant\"}\n\n" + + "event:error\ndata:{\"message\":\"PDF illisible\"}\n\n"; + Collector c = new Collector(); + c.invoke(clientReturning(sse)); + + assertNotNull(c.error.get()); + assertInstanceOf(RuntimeException.class, c.error.get()); + assertTrue(c.error.get().getMessage().contains("PDF illisible")); + assertFalse(c.complete.get(), "onComplete non appelé après error terminal"); + } + + @Test + void event_error_data_non_json_relaie_data_brut() { + // readField : data non parsable -> catch -> renvoie data brut. + String sse = "event:error\ndata:panne-brute\n\n"; + Collector c = new Collector(); + c.invoke(clientReturning(sse)); + + assertNotNull(c.error.get()); + assertTrue(c.error.get().getMessage().contains("panne-brute")); + } + + @Test + void flux_clos_sans_done_appelle_onComplete() { + // Pas de done/error -> terminated false -> onComplete de secours. + String sse = "event:token\ndata:{\"token\":\"x\"}\n\n"; + Collector c = new Collector(); + c.invoke(clientReturning(sse)); + + assertEquals("x", c.tokens.toString()); + assertTrue(c.complete.get()); + assertNull(c.error.get()); + } + + @Test + void erreur_transport_traduite_en_runtimeexception() { + Collector c = new Collector(); + c.invoke(clientFailingWith(new RuntimeException("boom"))); + + assertNotNull(c.error.get()); + assertInstanceOf(RuntimeException.class, c.error.get()); + assertTrue(c.error.get().getMessage().contains("adaptation")); + assertFalse(c.complete.get()); + } + + @Test + void filename_null_et_brief_null_et_messages_null_acceptes() { + // Couvre les branches : filename blank -> "campaign.pdf", brief null -> "", + // messagesJson null/blank -> "[]". + String sse = "event:done\ndata:{}\n\n"; + Collector c = new Collector(); + c.invoke(clientReturning(sse), null, null, null); + + assertTrue(c.complete.get()); + assertNull(c.error.get()); + } + + @Test + void messages_blank_remplace_par_tableau_vide() { + String sse = "event:done\ndata:{}\n\n"; + Collector c = new Collector(); + c.invoke(clientReturning(sse), " ", " ", " "); + + assertTrue(c.complete.get()); + } +} diff --git a/core/src/test/java/com/loremind/infrastructure/ai/BrainCampaignImportClientTest.java b/core/src/test/java/com/loremind/infrastructure/ai/BrainCampaignImportClientTest.java new file mode 100644 index 0000000..675591b --- /dev/null +++ b/core/src/test/java/com/loremind/infrastructure/ai/BrainCampaignImportClientTest.java @@ -0,0 +1,429 @@ +package com.loremind.infrastructure.ai; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.loremind.domain.campaigncontext.CampaignImportProgress; +import com.loremind.domain.campaigncontext.CampaignImportProposal; +import com.loremind.domain.campaigncontext.ports.CampaignImportException; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.web.reactive.function.client.ClientResponse; +import org.springframework.web.reactive.function.client.ExchangeFunction; +import org.springframework.web.reactive.function.client.WebClient; +import reactor.core.publisher.Mono; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Tests unitaires PURS (JUnit 5, sans Spring ni réseau) pour {@link BrainCampaignImportClient}. + *

+ * NB : contrairement à la consigne initiale, ce client est entièrement WebClient + SSE + * (POST /import/campaign/stream) — il n'y a PAS de RestTemplate ni d'appel one-shot. + * On injecte donc un WebClient.Builder dont l'ExchangeFunction renvoie un corps SSE + * canned (ou échoue), ce qui couvre {@code handleEvent} et tous les helpers de parsing. + */ +class BrainCampaignImportClientTest { + + private static final ObjectMapper MAPPER = new ObjectMapper(); + + /** Construit un client dont le WebClient renvoie le corps SSE fourni. */ + private BrainCampaignImportClient clientReturning(String sseBody) { + ExchangeFunction ef = req -> Mono.just( + ClientResponse.create(HttpStatus.OK) + .header(HttpHeaders.CONTENT_TYPE, MediaType.TEXT_EVENT_STREAM_VALUE) + .body(sseBody) + .build()); + WebClient.Builder builder = WebClient.builder().exchangeFunction(ef); + return new BrainCampaignImportClient(builder, MAPPER, "http://brain", 30); + } + + /** Construit un client dont le transport échoue immédiatement (Mono.error). */ + private BrainCampaignImportClient clientFailingWith(Throwable boom) { + ExchangeFunction ef = req -> Mono.error(boom); + WebClient.Builder builder = WebClient.builder().exchangeFunction(ef); + return new BrainCampaignImportClient(builder, MAPPER, "http://brain", 30); + } + + /** Collecteur mutable réunissant tous les callbacks de l'import streamé. */ + private static final class Collector { + final List progresses = new ArrayList<>(); + final AtomicInteger heartbeats = new AtomicInteger(0); + final List statuses = new ArrayList<>(); + final AtomicReference done = new AtomicReference<>(); + final AtomicReference error = new AtomicReference<>(); + + void invoke(BrainCampaignImportClient client) { + invoke(client, "campaign.pdf"); + } + + void invoke(BrainCampaignImportClient client, String filename) { + client.importCampaignStreaming( + new byte[]{1, 2, 3}, + filename, + progresses::add, + heartbeats::incrementAndGet, + statuses::add, + done::set, + error::set); + } + } + + // ---------- flux nominal : start -> progress -> done -------------------- + + @Test + void streame_start_progress_done_construit_la_proposition() { + // SSE déclenchant start (page/ocr counts), progress (compteurs), puis done (arbre complet). + String sse = + "event:start\ndata:{\"total\":5,\"page_count\":12,\"ocr_page_count\":3}\n\n" + + "event:progress\ndata:{\"current\":2,\"total\":5,\"arc_count\":1,\"chapter_count\":2,\"scene_count\":4,\"npc_count\":6}\n\n" + + "event:done\ndata:" + doneJson() + "\n\n"; + Collector c = new Collector(); + c.invoke(clientReturning(sse)); + + // start : current=0, total=5, pageCount=12, ocrPageCount=3, reste 0. + assertEquals(2, c.progresses.size()); + CampaignImportProgress start = c.progresses.get(0); + assertEquals(0, start.current()); + assertEquals(5, start.total()); + assertEquals(12, start.pageCount()); + assertEquals(3, start.ocrPageCount()); + + // progress : compteurs propagés + pageCount/ocr mémorisés depuis start. + CampaignImportProgress prog = c.progresses.get(1); + assertEquals(2, prog.current()); + assertEquals(5, prog.total()); + assertEquals(12, prog.pageCount()); + assertEquals(3, prog.ocrPageCount()); + assertEquals(1, prog.arcCount()); + assertEquals(2, prog.chapterCount()); + assertEquals(4, prog.sceneCount()); + assertEquals(6, prog.npcCount()); + + // done : arbre désérialisé (arcs/chapters/scenes/rooms + npcs). + CampaignImportProposal proposal = c.done.get(); + assertNotNull(proposal); + assertEquals(1, proposal.arcs().size()); + var arc = proposal.arcs().get(0); + assertEquals("Acte I", arc.name()); + assertEquals("Mise en place", arc.description()); + assertEquals("LINEAR", arc.type()); + assertEquals(1, arc.chapters().size()); + var chapter = arc.chapters().get(0); + assertEquals("Chapitre 1", chapter.name()); + assertEquals(1, chapter.scenes().size()); + var scene = chapter.scenes().get(0); + assertEquals("L'auberge", scene.name()); + assertEquals("Lisez ceci", scene.playerNarration()); + assertEquals("Secret MJ", scene.gmNotes()); + assertEquals(1, scene.rooms().size()); + var room = scene.rooms().get(0); + assertEquals("Cave", room.name()); + assertEquals("2 gobelins", room.enemies()); + assertEquals("50 po", room.loot()); + assertEquals(1, proposal.npcs().size()); + assertEquals("Thorin", proposal.npcs().get(0).name()); + assertEquals("Nain bougon", proposal.npcs().get(0).description()); + + assertNull(c.error.get(), "aucune erreur sur un flux terminé par done"); + } + + private static String doneJson() { + return "{" + + "\"arcs\":[{" + + " \"name\":\"Acte I\",\"description\":\"Mise en place\",\"type\":\"LINEAR\"," + + " \"chapters\":[{" + + " \"name\":\"Chapitre 1\",\"description\":\"intro\"," + + " \"scenes\":[{" + + " \"name\":\"L'auberge\",\"description\":\"tendue\"," + + " \"player_narration\":\"Lisez ceci\",\"gm_notes\":\"Secret MJ\"," + + " \"rooms\":[{\"name\":\"Cave\",\"description\":\"sombre\",\"enemies\":\"2 gobelins\",\"loot\":\"50 po\"}]" + + " }]" + + " }]" + + "}]," + + "\"npcs\":[{\"name\":\"Thorin\",\"description\":\"Nain bougon\"}]" + + "}"; + } + + // ---------- events simples : heartbeat / status / chunk_failed / extracting + + @Test + void event_heartbeat_propage_le_keepalive() { + String sse = + "event:heartbeat\ndata:\n\n" + + "event:heartbeat\ndata:\n\n" + + "event:done\ndata:" + emptyDoneJson() + "\n\n"; + Collector c = new Collector(); + c.invoke(clientReturning(sse)); + + assertEquals(2, c.heartbeats.get()); + assertNotNull(c.done.get()); + } + + @Test + void event_status_relaie_le_message_lisible() { + String sse = + "event:status\ndata:{\"message\":\"Fournisseur saturé, nouvelle tentative\"}\n\n" + + "event:done\ndata:" + emptyDoneJson() + "\n\n"; + Collector c = new Collector(); + c.invoke(clientReturning(sse)); + + assertEquals(1, c.statuses.size()); + assertEquals("Fournisseur saturé, nouvelle tentative", c.statuses.get(0)); + } + + @Test + void event_status_sans_champ_message_relaie_data_brut() { + // readMessage : pas de champ "message" -> renvoie la data brute. + String sse = + "event:status\ndata:texte-brut\n\n" + + "event:done\ndata:" + emptyDoneJson() + "\n\n"; + Collector c = new Collector(); + c.invoke(clientReturning(sse)); + + assertEquals("texte-brut", c.statuses.get(0)); + } + + @Test + void event_chunk_failed_compose_un_status_avec_compteurs_et_message() { + String sse = + "event:chunk_failed\ndata:{\"current\":3,\"total\":10,\"message\":\"timeout LLM\"}\n\n" + + "event:done\ndata:" + emptyDoneJson() + "\n\n"; + Collector c = new Collector(); + c.invoke(clientReturning(sse)); + + assertEquals("Morceau 3/10 ignoré : timeout LLM", c.statuses.get(0)); + } + + @Test + void event_chunk_failed_sans_message_termine_par_un_point() { + // Branche msg.isEmpty() -> suffixe "." au lieu de " : ". + String sse = + "event:chunk_failed\ndata:{\"current\":1,\"total\":4}\n\n" + + "event:done\ndata:" + emptyDoneJson() + "\n\n"; + Collector c = new Collector(); + c.invoke(clientReturning(sse)); + + assertEquals("Morceau 1/4 ignoré.", c.statuses.get(0)); + } + + @Test + void event_chunk_failed_avec_json_invalide_donne_zero_zero() { + // data non-JSON -> readJson renvoie null -> current/total à 0, suffixe ".". + String sse = + "event:chunk_failed\ndata:pas-du-json\n\n" + + "event:done\ndata:" + emptyDoneJson() + "\n\n"; + Collector c = new Collector(); + c.invoke(clientReturning(sse)); + + assertEquals("Morceau 0/0 ignoré.", c.statuses.get(0)); + } + + @Test + void event_extracting_emet_un_progress_neutre() { + String sse = + "event:extracting\ndata:\n\n" + + "event:done\ndata:" + emptyDoneJson() + "\n\n"; + Collector c = new Collector(); + c.invoke(clientReturning(sse)); + + assertEquals(1, c.progresses.size()); + CampaignImportProgress p = c.progresses.get(0); + assertEquals(0, p.current()); + assertEquals(0, p.total()); + assertEquals(0, p.pageCount()); + assertEquals(0, p.npcCount()); + } + + private static String emptyDoneJson() { + return "{\"arcs\":[],\"npcs\":[]}"; + } + + // ---------- event error (terminal) ------------------------------------- + + @Test + void event_error_appelle_onError_et_n_appelle_pas_onDone() { + String sse = + "event:start\ndata:{\"total\":2,\"page_count\":1,\"ocr_page_count\":0}\n\n" + + "event:error\ndata:{\"message\":\"PDF illisible\"}\n\n"; + Collector c = new Collector(); + c.invoke(clientReturning(sse)); + + assertNotNull(c.error.get()); + assertInstanceOf(CampaignImportException.class, c.error.get()); + assertTrue(c.error.get().getMessage().contains("PDF illisible")); + assertNull(c.done.get(), "onDone non appelé après un error terminal"); + } + + @Test + void event_error_sans_message_relaie_data_brut() { + String sse = "event:error\ndata:erreur-brute\n\n"; + Collector c = new Collector(); + c.invoke(clientReturning(sse)); + + assertNotNull(c.error.get()); + assertTrue(c.error.get().getMessage().contains("erreur-brute")); + } + + // ---------- branches de robustesse du parsing -------------------------- + + @Test + void event_inconnu_avec_data_non_json_est_ignore() { + // event non géré + data non-JSON -> readJson null -> return sans effet ; + // flux clos sans done -> branche d'interruption (onError). + String sse = "event:mystere\ndata:pas-du-json\n\n"; + Collector c = new Collector(); + c.invoke(clientReturning(sse)); + + assertTrue(c.progresses.isEmpty()); + assertTrue(c.statuses.isEmpty()); + assertNull(c.done.get()); + assertNotNull(c.error.get(), "flux interrompu sans done/error -> onError"); + } + + @Test + void start_avec_champs_absents_utilise_les_valeurs_par_defaut() { + // JSON valide mais sans page_count/ocr/total -> path().asInt() == 0. + String sse = + "event:start\ndata:{}\n\n" + + "event:done\ndata:" + emptyDoneJson() + "\n\n"; + Collector c = new Collector(); + c.invoke(clientReturning(sse)); + + CampaignImportProgress p = c.progresses.get(0); + assertEquals(0, p.total()); + assertEquals(0, p.pageCount()); + assertEquals(0, p.ocrPageCount()); + } + + @Test + void done_avec_arbre_vide_donne_une_proposition_vide() { + // Couvre toArcs/toNpcs sur des tableaux vides + text() sur champs absents. + String sse = "event:done\ndata:" + emptyDoneJson() + "\n\n"; + Collector c = new Collector(); + c.invoke(clientReturning(sse)); + + assertNotNull(c.done.get()); + assertTrue(c.done.get().arcs().isEmpty()); + assertTrue(c.done.get().npcs().isEmpty()); + } + + @Test + void done_avec_arc_sans_chapitres_et_npc_sans_description() { + // toChapters sur noeud absent (path -> MissingNode, non-array) -> liste vide ; + // text() sur "description" absent -> "". + String sse = "event:done\ndata:{" + + "\"arcs\":[{\"name\":\"Solo\"}]," + + "\"npcs\":[{\"name\":\"Anon\"}]" + + "}\n\n"; + Collector c = new Collector(); + c.invoke(clientReturning(sse)); + + var proposal = c.done.get(); + assertNotNull(proposal); + var arc = proposal.arcs().get(0); + assertEquals("Solo", arc.name()); + assertEquals("", arc.description()); + assertEquals("", arc.type()); + assertTrue(arc.chapters().isEmpty()); + assertEquals("Anon", proposal.npcs().get(0).name()); + assertEquals("", proposal.npcs().get(0).description()); + } + + @Test + void done_avec_champ_explicitement_null_donne_chaine_vide() { + // text() : valeur JSON null -> "" (branche v.isNull()). + String sse = "event:done\ndata:{" + + "\"arcs\":[{\"name\":null,\"description\":\"d\"}]," + + "\"npcs\":[]" + + "}\n\n"; + Collector c = new Collector(); + c.invoke(clientReturning(sse)); + + assertEquals("", c.done.get().arcs().get(0).name()); + assertEquals("d", c.done.get().arcs().get(0).description()); + } + + // ---------- fin de flux sans terminaison ------------------------------- + + @Test + void flux_clos_sans_done_ni_error_appelle_onError() { + // terminated reste false -> branche "Le flux d'import s'est interrompu...". + String sse = "event:progress\ndata:{\"current\":1,\"total\":3}\n\n"; + Collector c = new Collector(); + c.invoke(clientReturning(sse)); + + assertNull(c.done.get()); + assertNotNull(c.error.get()); + assertInstanceOf(CampaignImportException.class, c.error.get()); + assertTrue(c.error.get().getMessage().contains("interrompu")); + } + + // ---------- erreur de transport ---------------------------------------- + + @Test + void erreur_transport_traduite_en_CampaignImportException() { + // Mono.error -> blockLast lève -> branche catch, expose type + message de la cause. + Collector c = new Collector(); + c.invoke(clientFailingWith(new RuntimeException("connexion coupée"))); + + assertNotNull(c.error.get()); + assertInstanceOf(CampaignImportException.class, c.error.get()); + assertTrue(c.error.get().getMessage().contains("streaming d'import")); + assertTrue(c.error.get().getMessage().contains("connexion coupée")); + assertNull(c.done.get()); + } + + @Test + void erreur_transport_sans_message_reste_geree() { + // Cause sans message -> branche getMessage() == null (pas de " — ..."). + Collector c = new Collector(); + c.invoke(clientFailingWith(new RuntimeException())); + + assertNotNull(c.error.get()); + assertInstanceOf(CampaignImportException.class, c.error.get()); + assertTrue(c.error.get().getMessage().contains("streaming d'import")); + } + + @Test + void erreur_transport_apres_event_error_ne_double_pas_le_callback() { + // error terminal puis le flux se clôt : terminated[0]==true -> pas de second onError. + // (vérifie que le callback n'est appelé qu'une fois via le message attendu.) + String sse = "event:error\ndata:{\"message\":\"stop\"}\n\n"; + Collector c = new Collector(); + c.invoke(clientReturning(sse)); + + assertNotNull(c.error.get()); + assertTrue(c.error.get().getMessage().contains("stop")); + assertFalse(c.error.get().getMessage().contains("interrompu")); + } + + // ---------- nom de fichier --------------------------------------------- + + @Test + void filename_null_ou_blanc_est_accepte() { + // Couvre la branche de repli "campaign.pdf" dans filePart + part().filename(). + String sse = "event:done\ndata:" + emptyDoneJson() + "\n\n"; + Collector c1 = new Collector(); + c1.invoke(clientReturning(sse), null); + assertNotNull(c1.done.get()); + + Collector c2 = new Collector(); + c2.invoke(clientReturning(sse), " "); + assertNotNull(c2.done.get()); + + Collector c3 = new Collector(); + c3.invoke(clientReturning(sse), "mon-livre.pdf"); + assertNotNull(c3.done.get()); + } +} diff --git a/core/src/test/java/com/loremind/infrastructure/ai/BrainChatPayloadBuilderTest.java b/core/src/test/java/com/loremind/infrastructure/ai/BrainChatPayloadBuilderTest.java index 8a131d6..23a96c8 100644 --- a/core/src/test/java/com/loremind/infrastructure/ai/BrainChatPayloadBuilderTest.java +++ b/core/src/test/java/com/loremind/infrastructure/ai/BrainChatPayloadBuilderTest.java @@ -7,12 +7,21 @@ import com.loremind.domain.generationcontext.CampaignStructuralContext.ChapterSu import com.loremind.domain.generationcontext.CampaignStructuralContext.SceneSummary; import com.loremind.domain.generationcontext.ChatMessage; import com.loremind.domain.generationcontext.ChatRequest; +import com.loremind.domain.generationcontext.CampaignStructuralContext.CharacterSummary; +import com.loremind.domain.generationcontext.CampaignStructuralContext.NpcSummary; +import com.loremind.domain.generationcontext.CampaignStructuralContext.RoomBranchHint; +import com.loremind.domain.generationcontext.CampaignStructuralContext.RoomSummary; +import com.loremind.domain.generationcontext.GameSystemContext; import com.loremind.domain.generationcontext.LoreStructuralContext; import com.loremind.domain.generationcontext.LoreStructuralContext.PageSummary; import com.loremind.domain.generationcontext.NarrativeEntityContext; import com.loremind.domain.generationcontext.PageContext; +import com.loremind.domain.generationcontext.SessionContext; +import com.loremind.domain.generationcontext.SessionContext.JournalEntrySummary; +import com.loremind.domain.generationcontext.SessionContext.QuestSummary; import org.junit.jupiter.api.Test; +import java.time.LocalDateTime; import java.util.List; import java.util.Map; @@ -284,4 +293,252 @@ class BrainChatPayloadBuilderTest { assertFalse(payload.containsKey("lore_context")); assertFalse(payload.containsKey("page_context")); } + + // ---------- arc HUB + characters/npcs ---------------------------------- + + @Test + @SuppressWarnings("unchecked") + void build_arcHub_injecteArcTypeHub() { + // arc.hub() == true -> ajoute "arc_type":"HUB" (vocabulaire « quêtes »). + ArcSummary arc = new ArcSummary("Hub central", "", true, 0, List.of()); + CampaignStructuralContext camp = new CampaignStructuralContext( + "X", "", List.of(arc), List.of(), List.of()); + ChatRequest req = ChatRequest.builder().messages(sampleMessages).campaignContext(camp).build(); + + Map arcMap = firstOf(asMap(builder.build(req).get("campaign_context")), "arcs"); + assertEquals("HUB", arcMap.get("arc_type")); + } + + @Test + @SuppressWarnings("unchecked") + void build_arcLineaire_n_injectePasArcType() { + ArcSummary arc = new ArcSummary("Lineaire", "", false, 0, List.of()); + CampaignStructuralContext camp = new CampaignStructuralContext( + "X", "", List.of(arc), List.of(), List.of()); + ChatRequest req = ChatRequest.builder().messages(sampleMessages).campaignContext(camp).build(); + + Map arcMap = firstOf(asMap(builder.build(req).get("campaign_context")), "arcs"); + assertFalse(arcMap.containsKey("arc_type")); + } + + @Test + @SuppressWarnings("unchecked") + void build_campaignContext_serialiseCharactersEtNpcs_avecOmissionSnippetBlank() { + // snippet renseigné -> présent ; snippet blank/null -> omis. + CharacterSummary pj1 = new CharacterSummary("Aria", "Magicienne elfe"); + CharacterSummary pj2 = new CharacterSummary("Bran", " "); + NpcSummary pnj1 = new NpcSummary("Garde", "Sentinelle bourrue"); + NpcSummary pnj2 = new NpcSummary("Mendiant", null); + CampaignStructuralContext camp = new CampaignStructuralContext( + "X", "", List.of(), List.of(pj1, pj2), List.of(pnj1, pnj2)); + ChatRequest req = ChatRequest.builder().messages(sampleMessages).campaignContext(camp).build(); + + Map cctx = asMap(builder.build(req).get("campaign_context")); + List> chars = (List>) cctx.get("characters"); + assertEquals("Aria", chars.get(0).get("name")); + assertTrue(chars.get(0).containsKey("snippet")); + assertFalse(chars.get(1).containsKey("snippet"), "snippet blank omis"); + + List> npcs = (List>) cctx.get("npcs"); + assertEquals("Garde", npcs.get(0).get("name")); + assertTrue(npcs.get(0).containsKey("snippet")); + assertFalse(npcs.get(1).containsKey("snippet"), "snippet null omis"); + } + + // ---------- rooms d'une scène ------------------------------------------ + + @Test + @SuppressWarnings("unchecked") + void build_sceneRooms_serialiseTousLesChampsEtBranchesEntrePieces() { + RoomBranchHint exit = new RoomBranchHint("porte nord", "Salle du trone", "clé en main"); + RoomSummary room = new RoomSummary("Hall", 1, "Vaste entrée", "Spectres", List.of(exit)); + SceneSummary scene = new SceneSummary("Donjon", "", 0, List.of(), List.of(room)); + ChapterSummary chapter = new ChapterSummary("Ch", "", 0, List.of(scene)); + ArcSummary arc = new ArcSummary("A", "", false, 0, List.of(chapter)); + CampaignStructuralContext camp = new CampaignStructuralContext( + "X", "", List.of(arc), List.of(), List.of()); + ChatRequest req = ChatRequest.builder().messages(sampleMessages).campaignContext(camp).build(); + + Map arcMap = firstOf(asMap(builder.build(req).get("campaign_context")), "arcs"); + Map sceneMap = firstOf(firstOf(arcMap, "chapters"), "scenes"); + Map roomMap = firstOf(sceneMap, "rooms"); + + assertEquals("Hall", roomMap.get("name")); + assertEquals(1, roomMap.get("floor")); + assertEquals("Vaste entrée", roomMap.get("description")); + assertEquals("Spectres", roomMap.get("enemies")); + Map branchMap = firstOf(roomMap, "branches"); + assertEquals("porte nord", branchMap.get("label")); + assertEquals("Salle du trone", branchMap.get("target_room_name")); + assertEquals("clé en main", branchMap.get("condition")); + } + + @Test + @SuppressWarnings("unchecked") + void build_sceneRooms_ometLesChampsOptionnelsVides() { + // floor null, description/enemies blank, condition blank, branches non vides. + RoomBranchHint exit = new RoomBranchHint("sortie", "Autre", " "); + RoomSummary room = new RoomSummary("Cellule", null, " ", " ", List.of(exit)); + SceneSummary scene = new SceneSummary("Donjon", "", 0, List.of(), List.of(room)); + ChapterSummary chapter = new ChapterSummary("Ch", "", 0, List.of(scene)); + ArcSummary arc = new ArcSummary("A", "", false, 0, List.of(chapter)); + CampaignStructuralContext camp = new CampaignStructuralContext( + "X", "", List.of(arc), List.of(), List.of()); + ChatRequest req = ChatRequest.builder().messages(sampleMessages).campaignContext(camp).build(); + + Map arcMap = firstOf(asMap(builder.build(req).get("campaign_context")), "arcs"); + Map sceneMap = firstOf(firstOf(arcMap, "chapters"), "scenes"); + Map roomMap = firstOf(sceneMap, "rooms"); + + assertFalse(roomMap.containsKey("floor")); + assertFalse(roomMap.containsKey("description")); + assertFalse(roomMap.containsKey("enemies")); + Map branchMap = firstOf(roomMap, "branches"); + assertFalse(branchMap.containsKey("condition"), "condition blank omise"); + } + + @Test + @SuppressWarnings("unchecked") + void build_sceneRooms_omisesSiListeVide() { + // s.rooms() vide -> clé "rooms" absente. + SceneSummary scene = new SceneSummary("Lineaire", "", 0, List.of(), List.of()); + ChapterSummary chapter = new ChapterSummary("Ch", "", 0, List.of(scene)); + ArcSummary arc = new ArcSummary("A", "", false, 0, List.of(chapter)); + CampaignStructuralContext camp = new CampaignStructuralContext( + "X", "", List.of(arc), List.of(), List.of()); + ChatRequest req = ChatRequest.builder().messages(sampleMessages).campaignContext(camp).build(); + + Map arcMap = firstOf(asMap(builder.build(req).get("campaign_context")), "arcs"); + Map sceneMap = firstOf(firstOf(arcMap, "chapters"), "scenes"); + assertFalse(sceneMap.containsKey("rooms")); + } + + // ---------- game_system_context ---------------------------------------- + + @Test + @SuppressWarnings("unchecked") + void build_gameSystemContext_completInclutDescriptionEtSections() { + GameSystemContext gs = new GameSystemContext( + "Nimble", "JDR rapide", Map.of("Combat", "Lancez 1d20")); + ChatRequest req = ChatRequest.builder().messages(sampleMessages).gameSystemContext(gs).build(); + + Map gsm = asMap(builder.build(req).get("game_system_context")); + assertEquals("Nimble", gsm.get("system_name")); + assertEquals("JDR rapide", gsm.get("system_description")); + assertEquals(Map.of("Combat", "Lancez 1d20"), gsm.get("sections")); + } + + @Test + @SuppressWarnings("unchecked") + void build_gameSystemContext_descriptionBlankEtSectionsNull() { + // systemDescription blank -> omis ; sections null -> Map.of() de repli. + GameSystemContext gs = new GameSystemContext("D&D 5.1", " ", null); + ChatRequest req = ChatRequest.builder().messages(sampleMessages).gameSystemContext(gs).build(); + + Map gsm = asMap(builder.build(req).get("game_system_context")); + assertEquals("D&D 5.1", gsm.get("system_name")); + assertFalse(gsm.containsKey("system_description")); + assertEquals(Map.of(), gsm.get("sections")); + } + + // ---------- session_context -------------------------------------------- + + @Test + @SuppressWarnings("unchecked") + void build_sessionContext_complet_inclutToutesLesListesPeuplees() { + LocalDateTime now = LocalDateTime.of(2026, 6, 14, 20, 0); + JournalEntrySummary entry = new JournalEntrySummary( + "combat", "Le dragon attaque", now, null); + JournalEntrySummary prev = new JournalEntrySummary( + "info", "Le roi est mort", now, "Session 1"); + QuestSummary avail = new QuestSummary("Sauver le village", "Acte I", "Urgence"); + QuestSummary inProg = new QuestSummary("Trouver l'épée", "Acte II", " "); + SessionContext sc = new SessionContext( + "Session 2", true, now, + List.of(entry), List.of(prev), + List.of(avail), List.of(inProg), + List.of("Quête secrète"), List.of("porte_ouverte")); + ChatRequest req = ChatRequest.builder().messages(sampleMessages).sessionContext(sc).build(); + + Map scm = asMap(builder.build(req).get("session_context")); + assertEquals("Session 2", scm.get("session_name")); + assertEquals(true, scm.get("active")); + assertEquals(now.toString(), scm.get("started_at")); + + // entries : type/content/occurredAt présents, source omis (null) sur l'entrée courante. + Map entryMap = firstOf(scm, "entries"); + assertEquals("combat", entryMap.get("type")); + assertEquals("Le dragon attaque", entryMap.get("content")); + assertEquals(now.toString(), entryMap.get("occurred_at")); + assertFalse(entryMap.containsKey("source_session_name")); + + // previous_events : source renseignée. + Map prevMap = firstOf(scm, "previous_events"); + assertEquals("Session 1", prevMap.get("source_session_name")); + + // available_quests : description renseignée ; in_progress : description blank omise. + Map availMap = firstOf(scm, "available_quests"); + assertEquals("Sauver le village", availMap.get("name")); + assertEquals("Acte I", availMap.get("arc_name")); + assertEquals("Urgence", availMap.get("description")); + Map inProgMap = firstOf(scm, "in_progress_quests"); + assertFalse(inProgMap.containsKey("description"), "description blank omise"); + + assertEquals(List.of("Quête secrète"), scm.get("locked_quest_titles")); + assertEquals(List.of("porte_ouverte"), scm.get("active_flags")); + } + + @Test + @SuppressWarnings("unchecked") + void build_sessionContext_minimal_ometListesVidesEtChampsNull() { + // startedAt null -> omis ; entries null -> List.of() ; toutes les autres listes + // vides/null -> clés absentes. + SessionContext sc = new SessionContext( + "Session vide", false, null, + null, List.of(), + List.of(), List.of(), + List.of(), List.of()); + ChatRequest req = ChatRequest.builder().messages(sampleMessages).sessionContext(sc).build(); + + Map scm = asMap(builder.build(req).get("session_context")); + assertEquals("Session vide", scm.get("session_name")); + assertEquals(false, scm.get("active")); + assertFalse(scm.containsKey("started_at")); + assertEquals(List.of(), scm.get("entries")); + assertFalse(scm.containsKey("previous_events")); + assertFalse(scm.containsKey("available_quests")); + assertFalse(scm.containsKey("in_progress_quests")); + assertFalse(scm.containsKey("locked_quest_titles")); + assertFalse(scm.containsKey("active_flags")); + } + + // ---------- tous les contextes présents simultanément ------------------ + + @Test + void build_tousLesContextes_sontTousPresents() { + LoreStructuralContext lore = new LoreStructuralContext("L", "", Map.of(), List.of()); + PageContext page = new PageContext("P", "T", List.of(), Map.of()); + CampaignStructuralContext camp = new CampaignStructuralContext("C", "", List.of(), List.of(), List.of()); + NarrativeEntityContext entity = new NarrativeEntityContext("scene", "S", Map.of()); + GameSystemContext gs = new GameSystemContext("G", null, Map.of()); + SessionContext sc = new SessionContext("Sess", true, null, List.of(), List.of(), + List.of(), List.of(), List.of(), List.of()); + ChatRequest req = ChatRequest.builder() + .messages(sampleMessages) + .loreContext(lore) + .pageContext(page) + .campaignContext(camp) + .narrativeEntity(entity) + .gameSystemContext(gs) + .sessionContext(sc) + .build(); + + Map payload = builder.build(req); + assertTrue(payload.containsKey("lore_context")); + assertTrue(payload.containsKey("page_context")); + assertTrue(payload.containsKey("campaign_context")); + assertTrue(payload.containsKey("narrative_entity")); + assertTrue(payload.containsKey("game_system_context")); + assertTrue(payload.containsKey("session_context")); + } } diff --git a/core/src/test/java/com/loremind/infrastructure/ai/BrainConversationTitleClientTest.java b/core/src/test/java/com/loremind/infrastructure/ai/BrainConversationTitleClientTest.java new file mode 100644 index 0000000..ba951c6 --- /dev/null +++ b/core/src/test/java/com/loremind/infrastructure/ai/BrainConversationTitleClientTest.java @@ -0,0 +1,134 @@ +package com.loremind.infrastructure.ai; + +import com.loremind.domain.conversationcontext.ConversationMessage; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.web.reactive.function.client.ClientResponse; +import org.springframework.web.reactive.function.client.ExchangeFunction; +import org.springframework.web.reactive.function.client.WebClient; +import reactor.core.publisher.Mono; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests unitaires purs (JUnit 5, sans Spring, sans réseau) de + * {@link BrainConversationTitleClient}. + * + * Principe : on injecte un WebClient.Builder préconfiguré avec une + * ExchangeFunction mock qui renvoie des réponses canned -> aucun appel réseau. + */ +class BrainConversationTitleClientTest { + + private static final String FALLBACK = "Nouvelle conversation"; + + /** Construit un client dont le WebClient répond avec la réponse fournie. */ + private BrainConversationTitleClient clientReturning(ClientResponse response) { + ExchangeFunction ef = req -> Mono.just(response); + WebClient.Builder builder = WebClient.builder().exchangeFunction(ef); + return new BrainConversationTitleClient(builder, "http://brain"); + } + + /** Construit un client dont le WebClient émet une erreur transport. */ + private BrainConversationTitleClient clientErroring() { + ExchangeFunction ef = req -> Mono.error(new RuntimeException("boom")); + WebClient.Builder builder = WebClient.builder().exchangeFunction(ef); + return new BrainConversationTitleClient(builder, "http://brain"); + } + + private ClientResponse jsonOk(String body) { + return ClientResponse.create(HttpStatus.OK) + .header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE) + .body(body) + .build(); + } + + private ConversationMessage msg(String role, String content) { + return ConversationMessage.builder().role(role).content(content).build(); + } + + @Test + void liste_null_renvoie_fallback() { + // Pas besoin d'appel réseau : court-circuit sur entrée null. + BrainConversationTitleClient client = clientErroring(); + assertThat(client.generate(null)).isEqualTo(FALLBACK); + } + + @Test + void liste_vide_renvoie_fallback() { + BrainConversationTitleClient client = clientErroring(); + assertThat(client.generate(List.of())).isEqualTo(FALLBACK); + } + + @Test + void reponse_ok_avec_titre_renvoie_le_titre() { + BrainConversationTitleClient client = clientReturning(jsonOk("{\"title\":\"Mon titre\"}")); + String result = client.generate(List.of(msg("user", "Salut"))); + assertThat(result).isEqualTo("Mon titre"); + } + + @Test + void titre_avec_espaces_est_trimme() { + BrainConversationTitleClient client = clientReturning(jsonOk("{\"title\":\" Espacé \"}")); + String result = client.generate(List.of(msg("user", "Bonjour"))); + assertThat(result).isEqualTo("Espacé"); + } + + @Test + void contenu_message_null_traite_sans_npe() { + // content null -> mappé en "" dans le payload, ne doit pas lever. + BrainConversationTitleClient client = clientReturning(jsonOk("{\"title\":\"Ok\"}")); + String result = client.generate(List.of(msg("assistant", null))); + assertThat(result).isEqualTo("Ok"); + } + + @Test + void titre_absent_renvoie_fallback() { + BrainConversationTitleClient client = clientReturning(jsonOk("{\"autre\":\"x\"}")); + String result = client.generate(List.of(msg("user", "Hello"))); + assertThat(result).isEqualTo(FALLBACK); + } + + @Test + void titre_vide_renvoie_fallback() { + BrainConversationTitleClient client = clientReturning(jsonOk("{\"title\":\"\"}")); + String result = client.generate(List.of(msg("user", "Hello"))); + assertThat(result).isEqualTo(FALLBACK); + } + + @Test + void titre_blanc_renvoie_fallback() { + BrainConversationTitleClient client = clientReturning(jsonOk("{\"title\":\" \"}")); + String result = client.generate(List.of(msg("user", "Hello"))); + assertThat(result).isEqualTo(FALLBACK); + } + + @Test + void corps_json_vide_renvoie_fallback() { + // Map décodée non null mais sans clé "title". + BrainConversationTitleClient client = clientReturning(jsonOk("{}")); + String result = client.generate(List.of(msg("user", "Hello"))); + assertThat(result).isEqualTo(FALLBACK); + } + + @Test + void erreur_transport_renvoie_fallback() { + BrainConversationTitleClient client = clientErroring(); + String result = client.generate(List.of(msg("user", "Hello"))); + assertThat(result).isEqualTo(FALLBACK); + } + + @Test + void reponse_500_renvoie_fallback() { + ClientResponse err = ClientResponse.create(HttpStatus.INTERNAL_SERVER_ERROR) + .header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE) + .body("{\"error\":\"down\"}") + .build(); + BrainConversationTitleClient client = clientReturning(err); + String result = client.generate(List.of(msg("user", "Hello"))); + assertThat(result).isEqualTo(FALLBACK); + } +} diff --git a/core/src/test/java/com/loremind/infrastructure/ai/BrainGeneratePageRequestTest.java b/core/src/test/java/com/loremind/infrastructure/ai/BrainGeneratePageRequestTest.java new file mode 100644 index 0000000..f0d8db3 --- /dev/null +++ b/core/src/test/java/com/loremind/infrastructure/ai/BrainGeneratePageRequestTest.java @@ -0,0 +1,85 @@ +package com.loremind.infrastructure.ai; + +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +/** + * Tests unitaires des DTOs wire de l'Adapter IA : + * - BrainGeneratePageRequest (record envoye au Brain) ; + * - BrainGeneratePageResponse (@Data/@NoArgsConstructor recu du Brain). + * On instancie et on appelle les accesseurs pour couvrir le code genere. + */ +class BrainGeneratePageRequestTest { + + // --- BrainGeneratePageRequest ------------------------------------------- + + @Test + void request_accesseursExposentLesChamps() { + BrainGeneratePageRequest req = new BrainGeneratePageRequest( + "Aetheria", + "Un monde de cendres", + "PNJ", + "Fiche personnage", + List.of("histoire", "motto"), + "Garde rouge" + ); + + assertEquals("Aetheria", req.loreName()); + assertEquals("Un monde de cendres", req.loreDescription()); + assertEquals("PNJ", req.folderName()); + assertEquals("Fiche personnage", req.templateName()); + assertEquals(List.of("histoire", "motto"), req.templateFields()); + assertEquals("Garde rouge", req.pageTitle()); + } + + @Test + void request_egaliteStructurelleEtToString() { + BrainGeneratePageRequest a = new BrainGeneratePageRequest( + "n", "d", "f", "t", List.of("x"), "p"); + BrainGeneratePageRequest b = new BrainGeneratePageRequest( + "n", "d", "f", "t", List.of("x"), "p"); + BrainGeneratePageRequest c = new BrainGeneratePageRequest( + "AUTRE", "d", "f", "t", List.of("x"), "p"); + + assertEquals(a, b); + assertEquals(a.hashCode(), b.hashCode()); + assertNotEquals(a, c); + // toString genere : on verifie juste qu'il est non vide et contient un champ + org.junit.jupiter.api.Assertions.assertTrue(a.toString().contains("n")); + } + + // --- BrainGeneratePageResponse ------------------------------------------ + + @Test + void response_setterEtGetterValues() { + BrainGeneratePageResponse resp = new BrainGeneratePageResponse(); + assertNull(resp.getValues()); + + Map values = Map.of("histoire", "Nee sous une etoile rouge"); + resp.setValues(values); + + assertEquals(values, resp.getValues()); + assertEquals("Nee sous une etoile rouge", resp.getValues().get("histoire")); + } + + @Test + void response_egaliteEtToStringGeneresParLombok() { + BrainGeneratePageResponse a = new BrainGeneratePageResponse(); + a.setValues(Map.of("f", "v")); + BrainGeneratePageResponse b = new BrainGeneratePageResponse(); + b.setValues(Map.of("f", "v")); + BrainGeneratePageResponse c = new BrainGeneratePageResponse(); + c.setValues(Map.of("f", "AUTRE")); + + assertEquals(a, b); + assertEquals(a.hashCode(), b.hashCode()); + assertNotEquals(a, c); + org.junit.jupiter.api.Assertions.assertTrue(a.toString().contains("values")); + } +} diff --git a/core/src/test/java/com/loremind/infrastructure/ai/BrainItemCatalogClientTest.java b/core/src/test/java/com/loremind/infrastructure/ai/BrainItemCatalogClientTest.java new file mode 100644 index 0000000..ecdd90b --- /dev/null +++ b/core/src/test/java/com/loremind/infrastructure/ai/BrainItemCatalogClientTest.java @@ -0,0 +1,202 @@ +package com.loremind.infrastructure.ai; + +import com.loremind.domain.campaigncontext.CatalogItem; +import com.loremind.domain.campaigncontext.ports.ItemCatalogGenerationException; +import com.loremind.domain.campaigncontext.ports.ItemCatalogGenerator.GeneratedCatalog; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.web.client.HttpServerErrorException; +import org.springframework.web.client.ResourceAccessException; +import org.springframework.web.client.RestTemplate; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * Tests unitaires purs (JUnit 5 + Mockito, sans Spring) de {@link BrainItemCatalogClient}. + * Le RestTemplate est mocké : {@code postForObject(url, entity, Map.class)}. + */ +class BrainItemCatalogClientTest { + + private RestTemplate rt; + private BrainItemCatalogClient client; + + @BeforeEach + void setUp() { + rt = mock(RestTemplate.class); + client = new BrainItemCatalogClient(rt, "http://brain"); + } + + /** Construit une map d'objet pour le payload "items". */ + private static Map item(Object name, Object price, Object category, Object description) { + Map m = new LinkedHashMap<>(); + m.put("name", name); + m.put("price", price); + m.put("category", category); + m.put("description", description); + return m; + } + + // ---------- generate : cas nominal et mapping ---------- + + @Test + void generate_reponseValide_mappeItems_etIgnoreInvalides() { + List items = new ArrayList<>(); + items.add(item("Épée longue", "15 po", "Armes", "Tranchante")); // valide + items.add(item("Potion", null, null, null)); // valide, champs null + items.add(item(null, "1 po", "x", "y")); // ignoré : name null + items.add(item(" ", "1 po", "x", "y")); // ignoré : name blank + items.add("pas une map"); // ignoré : item non-Map + + Map resp = new LinkedHashMap<>(); + resp.put("name", "Boutique du forgeron"); + resp.put("description", "Au village"); + resp.put("items", items); + + when(rt.postForObject(anyString(), any(), eq(Map.class))).thenReturn(resp); + + GeneratedCatalog cat = client.generate("desc", "ctx"); + + assertEquals("Boutique du forgeron", cat.name()); + assertEquals("Au village", cat.description()); + assertEquals(2, cat.items().size()); + + CatalogItem i0 = cat.items().get(0); + assertEquals("Épée longue", i0.getName()); + assertEquals("15 po", i0.getPrice()); + assertEquals("Armes", i0.getCategory()); + assertEquals("Tranchante", i0.getDescription()); + + CatalogItem i1 = cat.items().get(1); + assertEquals("Potion", i1.getName()); + assertNull(i1.getPrice()); + assertNull(i1.getCategory()); + assertNull(i1.getDescription()); + } + + @Test + void generate_nameAbsent_fallbackSurDescription() { + Map resp = new LinkedHashMap<>(); + resp.put("items", List.of(item("Objet", null, null, null))); + // pas de "name" -> fallback description + + when(rt.postForObject(anyString(), any(), eq(Map.class))).thenReturn(resp); + + GeneratedCatalog cat = client.generate("MaDescription", "ctx"); + assertEquals("MaDescription", cat.name()); + assertNull(cat.description()); + } + + @Test + void generate_nameBlank_fallbackSurDescription() { + Map resp = new LinkedHashMap<>(); + resp.put("name", " "); + resp.put("items", List.of(item("Objet", null, null, null))); + + when(rt.postForObject(anyString(), any(), eq(Map.class))).thenReturn(resp); + + GeneratedCatalog cat = client.generate("Fallback", "ctx"); + assertEquals("Fallback", cat.name()); + } + + @Test + void generate_argumentsNull_remplisParDefauts() { + Map resp = new LinkedHashMap<>(); + resp.put("name", "Cat"); + resp.put("items", List.of(item("Objet", null, null, null))); + + when(rt.postForObject(anyString(), any(), eq(Map.class))).thenReturn(resp); + + GeneratedCatalog cat = client.generate(null, null); + assertEquals("Cat", cat.name()); + } + + // ---------- generate : réponse vide / pas d'items ---------- + + @Test + void generate_reponseNull_leveException() { + when(rt.postForObject(anyString(), any(), eq(Map.class))).thenReturn(null); + + ItemCatalogGenerationException ex = assertThrows(ItemCatalogGenerationException.class, + () -> client.generate("d", "c")); + assertTrue(ex.getMessage().contains("réponse vide")); + } + + @Test + void generate_itemsAbsents_leveException() { + Map resp = new LinkedHashMap<>(); + resp.put("name", "Cat"); // pas de "items" + + when(rt.postForObject(anyString(), any(), eq(Map.class))).thenReturn(resp); + + ItemCatalogGenerationException ex = assertThrows(ItemCatalogGenerationException.class, + () -> client.generate("d", "c")); + assertTrue(ex.getMessage().contains("Aucun objet")); + } + + @Test + void generate_itemsVide_leveException() { + Map resp = new LinkedHashMap<>(); + resp.put("items", new ArrayList<>()); + + when(rt.postForObject(anyString(), any(), eq(Map.class))).thenReturn(resp); + + assertThrows(ItemCatalogGenerationException.class, () -> client.generate("d", "c")); + } + + @Test + void generate_tousItemsInvalides_leveException() { + Map resp = new LinkedHashMap<>(); + resp.put("items", List.of(item(null, null, null, null))); + + when(rt.postForObject(anyString(), any(), eq(Map.class))).thenReturn(resp); + + assertThrows(ItemCatalogGenerationException.class, () -> client.generate("d", "c")); + } + + // ---------- generate : branches du catch ---------- + + @Test + void generate_brainInjoignable_resourceAccess() { + when(rt.postForObject(anyString(), any(), eq(Map.class))) + .thenThrow(new ResourceAccessException("down")); + + ItemCatalogGenerationException ex = assertThrows(ItemCatalogGenerationException.class, + () -> client.generate("d", "c")); + assertTrue(ex.getMessage().contains("injoignable")); + assertInstanceOf(ResourceAccessException.class, ex.getCause()); + } + + @Test + void generate_erreurHttp_restClientResponse() { + when(rt.postForObject(anyString(), any(), eq(Map.class))) + .thenThrow(HttpServerErrorException.create( + HttpStatus.BAD_GATEWAY, "Bad Gateway", new HttpHeaders(), new byte[0], null)); + + ItemCatalogGenerationException ex = assertThrows(ItemCatalogGenerationException.class, + () -> client.generate("d", "c")); + assertTrue(ex.getMessage().contains("HTTP 502")); + } + + @Test + void generate_erreurInattendue_exceptionGenerique() { + when(rt.postForObject(anyString(), any(), eq(Map.class))) + .thenThrow(new IllegalStateException("boom")); + + ItemCatalogGenerationException ex = assertThrows(ItemCatalogGenerationException.class, + () -> client.generate("d", "c")); + assertTrue(ex.getMessage().contains("inattendue")); + assertInstanceOf(IllegalStateException.class, ex.getCause()); + } +} diff --git a/core/src/test/java/com/loremind/infrastructure/ai/BrainMultipartFilenameTest.java b/core/src/test/java/com/loremind/infrastructure/ai/BrainMultipartFilenameTest.java new file mode 100644 index 0000000..5a16b6d --- /dev/null +++ b/core/src/test/java/com/loremind/infrastructure/ai/BrainMultipartFilenameTest.java @@ -0,0 +1,127 @@ +package com.loremind.infrastructure.ai; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.core.io.Resource; +import org.springframework.core.io.buffer.DataBufferUtils; +import org.springframework.http.HttpEntity; +import org.springframework.mock.http.client.reactive.MockClientHttpRequest; +import org.springframework.util.MultiValueMap; +import org.springframework.web.client.RestTemplate; +import org.springframework.web.reactive.function.client.ClientResponse; +import org.springframework.web.reactive.function.client.ExchangeFunction; +import org.springframework.web.reactive.function.client.ExchangeStrategies; +import org.springframework.web.reactive.function.client.WebClient; +import reactor.core.publisher.Mono; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; + +/** + * Couvre les sous-classes anonymes {@code new ByteArrayResource(){ getFilename() }} + * des clients d'upload multipart : leur {@code getFilename()} (nom par défaut si + * absent/vide) n'est appelé que lors de la SÉRIALISATION du corps, qui n'a pas lieu + * quand le transport est mocké. On la déclenche donc explicitement : + *
    + *
  • clients RestTemplate : on capture le {@link HttpEntity} envoyé et on lit la + * ressource « file » pour appeler {@code getFilename()} ;
  • + *
  • clients WebClient : l'{@link ExchangeFunction} sérialise réellement le corps + * multipart dans un {@link MockClientHttpRequest} (ce qui invoque getFilename).
  • + *
+ */ +class BrainMultipartFilenameTest { + + // --- Helpers ------------------------------------------------------------ + + /** ExchangeFunction qui sérialise le corps de la requête (déclenche getFilename) puis renvoie un SSE 'done'. */ + private static ExchangeFunction serializingExchange(String sse) { + return request -> { + MockClientHttpRequest mock = new MockClientHttpRequest(request.method(), request.url()); + // Le write-handler par défaut ne souscrit pas au corps : on draine donc le + // flux nous-mêmes pour forcer l'écriture des parts (et l'appel à getFilename). + mock.setWriteHandler(body -> DataBufferUtils.join(body) + .doOnNext(DataBufferUtils::release).then()); + request.writeTo(mock, ExchangeStrategies.withDefaults()).block(); + return Mono.just(ClientResponse.create(org.springframework.http.HttpStatus.OK) + .header(org.springframework.http.HttpHeaders.CONTENT_TYPE, + org.springframework.http.MediaType.TEXT_EVENT_STREAM_VALUE) + .body(sse) + .build()); + }; + } + + @SuppressWarnings("unchecked") + private static String capturedFilename(RestTemplate rt) { + ArgumentCaptor cap = ArgumentCaptor.forClass(HttpEntity.class); + verify(rt).postForObject(anyString(), cap.capture(), any()); + MultiValueMap body = (MultiValueMap) cap.getValue().getBody(); + return ((Resource) body.getFirst("file")).getFilename(); + } + + // --- BrainNotebookIndexClient (RestTemplate) ---------------------------- + + @Test + void notebookIndex_filePart_usesGivenFilename_elseDefault() { + RestTemplate rt = mock(RestTemplate.class); + BrainNotebookIndexClient client = new BrainNotebookIndexClient(rt, "http://brain"); + + try { client.index("src-1", new byte[]{1, 2}, "doc.pdf"); } catch (RuntimeException ignored) { } + assertEquals("doc.pdf", capturedFilename(rt)); + + RestTemplate rt2 = mock(RestTemplate.class); + BrainNotebookIndexClient client2 = new BrainNotebookIndexClient(rt2, "http://brain"); + try { client2.index("src-1", new byte[]{1, 2}, null); } catch (RuntimeException ignored) { } + assertEquals("source.pdf", capturedFilename(rt2)); + } + + // --- BrainRulesImportClient (RestTemplate one-shot) --------------------- + + @Test + void rulesImport_filePart_usesGivenFilename_elseDefault() { + RestTemplate rt = mock(RestTemplate.class); + BrainRulesImportClient client = new BrainRulesImportClient( + rt, WebClient.builder(), new ObjectMapper(), "http://brain", 600); + + try { client.importRules(new byte[]{1, 2}, "regles.pdf"); } catch (RuntimeException ignored) { } + assertEquals("regles.pdf", capturedFilename(rt)); + + RestTemplate rt2 = mock(RestTemplate.class); + BrainRulesImportClient client2 = new BrainRulesImportClient( + rt2, WebClient.builder(), new ObjectMapper(), "http://brain", 600); + try { client2.importRules(new byte[]{1, 2}, " "); } catch (RuntimeException ignored) { } + assertEquals("rules.pdf", capturedFilename(rt2)); + } + + // --- BrainCampaignAdaptClient (WebClient multipart) --------------------- + + @Test + void campaignAdapt_filePart_serializedFilename() { + ExchangeFunction ef = serializingExchange("event:done\ndata:{}\n\n"); + BrainCampaignAdaptClient client = new BrainCampaignAdaptClient( + WebClient.builder().exchangeFunction(ef), new ObjectMapper(), "http://brain", 30); + + // filename présent puis null : les deux branches de getFilename sont sérialisées. + client.adviseStreaming(new byte[]{1, 2}, "doc.pdf", "brief", "[]", + t -> { }, () -> { }, e -> { }); + client.adviseStreaming(new byte[]{1, 2}, null, null, null, + t -> { }, () -> { }, e -> { }); + } + + // --- BrainCampaignImportClient (WebClient multipart) -------------------- + + @Test + void campaignImport_filePart_serializedFilename() { + ExchangeFunction ef = serializingExchange("event:done\ndata:{\"sections\":{}}\n\n"); + BrainCampaignImportClient client = new BrainCampaignImportClient( + WebClient.builder().exchangeFunction(ef), new ObjectMapper(), "http://brain", 30); + + client.importCampaignStreaming(new byte[]{1, 2}, "doc.pdf", + p -> { }, () -> { }, s -> { }, r -> { }, e -> { }); + client.importCampaignStreaming(new byte[]{1, 2}, null, + p -> { }, () -> { }, s -> { }, r -> { }, e -> { }); + } +} diff --git a/core/src/test/java/com/loremind/infrastructure/ai/BrainNotebookChatClientTest.java b/core/src/test/java/com/loremind/infrastructure/ai/BrainNotebookChatClientTest.java new file mode 100644 index 0000000..c18e1fd --- /dev/null +++ b/core/src/test/java/com/loremind/infrastructure/ai/BrainNotebookChatClientTest.java @@ -0,0 +1,201 @@ +package com.loremind.infrastructure.ai; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.loremind.domain.campaigncontext.ports.NotebookChatStreamer.Msg; +import com.loremind.domain.campaigncontext.ports.NotebookChatStreamer.Progress; +import com.loremind.domain.campaigncontext.ports.NotebookException; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.web.reactive.function.client.ClientResponse; +import org.springframework.web.reactive.function.client.ExchangeFunction; +import org.springframework.web.reactive.function.client.WebClient; +import reactor.core.publisher.Mono; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Tests unitaires PURS (JUnit 5 + Mockito-less) pour {@link BrainNotebookChatClient}. + * On injecte un WebClient.Builder dont l'ExchangeFunction renvoie un corps SSE canned : + * aucun réseau n'est sollicité. + */ +class BrainNotebookChatClientTest { + + private static final ObjectMapper MAPPER = new ObjectMapper(); + + /** Construit un client dont le WebClient renvoie le corps SSE fourni. */ + private BrainNotebookChatClient clientReturning(String sseBody) { + ExchangeFunction ef = req -> Mono.just( + ClientResponse.create(HttpStatus.OK) + .header(HttpHeaders.CONTENT_TYPE, MediaType.TEXT_EVENT_STREAM_VALUE) + .body(sseBody) + .build()); + WebClient.Builder builder = WebClient.builder().exchangeFunction(ef); + return new BrainNotebookChatClient(builder, MAPPER, "http://brain", 30); + } + + /** Construit un client dont le transport échoue immédiatement. */ + private BrainNotebookChatClient clientFailingWith(Throwable boom) { + ExchangeFunction ef = req -> Mono.error(boom); + WebClient.Builder builder = WebClient.builder().exchangeFunction(ef); + return new BrainNotebookChatClient(builder, MAPPER, "http://brain", 30); + } + + /** Collecteur réutilisable pour les callbacks. */ + private static final class Collector { + final AtomicReference sources = new AtomicReference<>(); + final StringBuilder tokens = new StringBuilder(); + final List progresses = new ArrayList<>(); + final AtomicBoolean done = new AtomicBoolean(false); + final AtomicReference error = new AtomicReference<>(); + + void invoke(BrainNotebookChatClient client, boolean deep) { + client.stream( + List.of("s1", "s2"), + List.of(new Msg("user", "Bonjour")), + "ctx", + deep, + sources::set, + tokens::append, + progresses::add, + () -> done.set(true), + error::set); + } + } + + @Test + void streame_tous_les_events_token_sources_progress_done() { + // SSE déclenchant chaque branche de handleEvent : sources, progress, token, done. + String sse = + "event:sources\ndata:{\"passages\":[1,2]}\n\n" + + "event:progress\ndata:{\"current\":2,\"total\":5}\n\n" + + "event:token\ndata:{\"token\":\"Salut\"}\n\n" + + "event:token\ndata:{\"token\":\" toi\"}\n\n" + + "event:done\ndata:{}\n\n"; + Collector c = new Collector(); + c.invoke(clientReturning(sse), true); + + assertEquals("{\"passages\":[1,2]}", c.sources.get(), "JSON sources relayé brut"); + assertEquals("Salut toi", c.tokens.toString(), "tokens concaténés dans l'ordre"); + assertEquals(1, c.progresses.size()); + assertEquals(2, c.progresses.get(0).current()); + assertEquals(5, c.progresses.get(0).total()); + assertTrue(c.done.get(), "onDone appelé via event done"); + assertNull(c.error.get()); + } + + @Test + void token_vide_ou_absent_ignore() { + // token vide -> non émis ; token absent -> readField null -> non émis. + String sse = + "event:token\ndata:{\"token\":\"\"}\n\n" + + "event:token\ndata:{\"foo\":\"bar\"}\n\n" + + "event:token\ndata:{\"token\":\"X\"}\n\n" + + "event:done\ndata:{}\n\n"; + Collector c = new Collector(); + c.invoke(clientReturning(sse), false); + + assertEquals("X", c.tokens.toString(), "seul le token non vide est émis"); + assertTrue(c.done.get()); + } + + @Test + void progress_avec_json_invalide_donne_zero() { + // data non-JSON -> readInt catch -> 0/0 (couvre la branche d'exception). + String sse = + "event:progress\ndata:pas-du-json\n\n" + + "event:done\ndata:{}\n\n"; + Collector c = new Collector(); + c.invoke(clientReturning(sse), true); + + assertEquals(1, c.progresses.size()); + assertEquals(0, c.progresses.get(0).current()); + assertEquals(0, c.progresses.get(0).total()); + } + + @Test + void event_error_appelle_onError_avec_NotebookException() { + String sse = + "event:token\ndata:{\"token\":\"avant\"}\n\n" + + "event:error\ndata:{\"message\":\"oups modèle\"}\n\n"; + Collector c = new Collector(); + c.invoke(clientReturning(sse), false); + + assertNotNull(c.error.get()); + assertInstanceOf(NotebookException.class, c.error.get()); + assertTrue(c.error.get().getMessage().contains("oups modèle")); + assertFalse(c.done.get(), "onDone non appelé après un error terminal"); + } + + @Test + void event_error_sans_message_relaie_data_brut() { + // readMessage : pas de champ message -> renvoie data brut. + String sse = "event:error\ndata:erreur-brute\n\n"; + Collector c = new Collector(); + c.invoke(clientReturning(sse), false); + + assertNotNull(c.error.get()); + assertTrue(c.error.get().getMessage().contains("erreur-brute")); + } + + @Test + void flux_termine_sans_done_appelle_onDone() { + // Aucun event done/error -> terminated reste false -> onDone() de secours. + String sse = "event:token\ndata:{\"token\":\"fin\"}\n\n"; + Collector c = new Collector(); + c.invoke(clientReturning(sse), false); + + assertEquals("fin", c.tokens.toString()); + assertTrue(c.done.get(), "onDone de secours appelé pour flux clos sans done"); + assertNull(c.error.get()); + } + + @Test + void erreur_transport_traduite_en_NotebookException() { + Collector c = new Collector(); + c.invoke(clientFailingWith(new RuntimeException("boom")), false); + + assertNotNull(c.error.get()); + assertInstanceOf(NotebookException.class, c.error.get()); + assertTrue(c.error.get().getMessage().contains("boom")); + assertFalse(c.done.get()); + } + + @Test + void context_null_est_accepte() { + // Couvre la branche context == null -> "" lors de la construction du payload. + String sse = "event:done\ndata:{}\n\n"; + ExchangeFunction ef = req -> Mono.just( + ClientResponse.create(HttpStatus.OK) + .header(HttpHeaders.CONTENT_TYPE, MediaType.TEXT_EVENT_STREAM_VALUE) + .body(sse) + .build()); + BrainNotebookChatClient client = + new BrainNotebookChatClient(WebClient.builder().exchangeFunction(ef), MAPPER, "http://brain", 30); + + AtomicBoolean done = new AtomicBoolean(false); + client.stream( + List.of("s1"), + List.of(new Msg("user", "hi")), + null, + false, + json -> {}, + tok -> {}, + p -> {}, + () -> done.set(true), + err -> {}); + + assertTrue(done.get()); + } +} diff --git a/core/src/test/java/com/loremind/infrastructure/ai/BrainNotebookIndexClientTest.java b/core/src/test/java/com/loremind/infrastructure/ai/BrainNotebookIndexClientTest.java new file mode 100644 index 0000000..d454348 --- /dev/null +++ b/core/src/test/java/com/loremind/infrastructure/ai/BrainNotebookIndexClientTest.java @@ -0,0 +1,166 @@ +package com.loremind.infrastructure.ai; + +import com.loremind.domain.campaigncontext.ports.NotebookException; +import com.loremind.domain.campaigncontext.ports.NotebookIndexer.IndexResult; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.web.client.HttpServerErrorException; +import org.springframework.web.client.ResourceAccessException; +import org.springframework.web.client.RestTemplate; + +import java.lang.reflect.Constructor; +import java.lang.reflect.Field; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * Tests unitaires purs (JUnit 5 + Mockito, sans Spring/réseau) de + * {@link BrainNotebookIndexClient} : indexation multipart one-shot (RestTemplate) + * et suppression best-effort. + *

+ * {@code IndexResponse} étant une classe privée de l'adapter, on l'instancie par + * réflexion pour piloter la valeur renvoyée par le mock RestTemplate. + */ +class BrainNotebookIndexClientTest { + + private static final String BASE_URL = "http://brain"; + + private RestTemplate restTemplate; + private BrainNotebookIndexClient client; + + @BeforeEach + void setUp() { + restTemplate = mock(RestTemplate.class); + client = new BrainNotebookIndexClient(restTemplate, BASE_URL); + } + + /** Récupère la Class de l'IndexResponse privée (telle qu'attendue par postForObject). */ + private Class indexResponseClass() throws ClassNotFoundException { + return Class.forName("com.loremind.infrastructure.ai.BrainNotebookIndexClient$IndexResponse"); + } + + /** Instancie l'IndexResponse privée et remplit ses champs par réflexion. */ + private Object newIndexResponse(int chunks, int pageCount, int ocrPageCount) throws Exception { + Class cls = indexResponseClass(); + Constructor ctor = cls.getDeclaredConstructor(); + ctor.setAccessible(true); + Object resp = ctor.newInstance(); + setField(resp, "chunks", chunks); + setField(resp, "pageCount", pageCount); + setField(resp, "ocrPageCount", ocrPageCount); + return resp; + } + + private void setField(Object target, String name, int value) throws Exception { + Field f = target.getClass().getDeclaredField(name); + f.setAccessible(true); + f.setInt(target, value); + } + + // --- index() ------------------------------------------------------------- + + @Test + @SuppressWarnings("unchecked") + void index_succes_retourneIndexResult() throws Exception { + Object wire = newIndexResponse(42, 100, 7); + // doReturn évite l'inférence générique (Class -> wildcard) qui casse thenReturn. + doReturn(wire).when(restTemplate) + .postForObject(anyString(), any(), eq(indexResponseClass())); + + IndexResult result = client.index("src-1", new byte[]{1, 2, 3}, "livre.pdf"); + + assertEquals(42, result.chunks()); + assertEquals(100, result.pageCount()); + assertEquals(7, result.ocrPageCount()); + + // L'URL appelée concatène baseUrl + INDEX_PATH. + ArgumentCaptor url = ArgumentCaptor.forClass(String.class); + verify(restTemplate).postForObject(url.capture(), any(), eq(indexResponseClass())); + assertEquals(BASE_URL + "/index/notebook-source", url.getValue()); + } + + @Test + void index_filenameNull_utiliseNomParDefaut() throws Exception { + Object wire = newIndexResponse(1, 1, 0); + doReturn(wire).when(restTemplate) + .postForObject(anyString(), any(), eq(indexResponseClass())); + + // filename null/blank -> branche "source.pdf" du filePart. + IndexResult result = client.index("src-1", new byte[]{9}, null); + assertEquals(1, result.chunks()); + } + + @Test + void index_reponseNull_leveNotebookException() { + when(restTemplate.postForObject(anyString(), any(), any(Class.class))) + .thenReturn(null); + + NotebookException ex = assertThrows(NotebookException.class, + () -> client.index("src-1", new byte[]{1}, "f.pdf")); + assertTrue(ex.getMessage().contains("vide")); + } + + @Test + void index_resourceAccess_leveNotebookException() { + when(restTemplate.postForObject(anyString(), any(), any(Class.class))) + .thenThrow(new ResourceAccessException("timeout")); + + NotebookException ex = assertThrows(NotebookException.class, + () -> client.index("src-1", new byte[]{1}, "f.pdf")); + assertTrue(ex.getMessage().contains("injoignable")); + } + + @Test + void index_httpServerError_leveNotebookExceptionAvecStatut() { + HttpServerErrorException http = HttpServerErrorException.create( + HttpStatus.BAD_GATEWAY, "Bad Gateway", new HttpHeaders(), new byte[0], null); + when(restTemplate.postForObject(anyString(), any(), any(Class.class))) + .thenThrow(http); + + NotebookException ex = assertThrows(NotebookException.class, + () -> client.index("src-1", new byte[]{1}, "f.pdf")); + assertTrue(ex.getMessage().contains("502")); + } + + @Test + void index_erreurInattendue_leveNotebookException() { + when(restTemplate.postForObject(anyString(), any(), any(Class.class))) + .thenThrow(new IllegalStateException("boom")); + + NotebookException ex = assertThrows(NotebookException.class, + () -> client.index("src-1", new byte[]{1}, "f.pdf")); + assertTrue(ex.getMessage().contains("inattendue")); + } + + // --- delete() ------------------------------------------------------------ + + @Test + void delete_appelleRestTemplateAvecBonneUrl() { + client.delete("src-99"); + verify(restTemplate).delete(BASE_URL + "/index/notebook-source/src-99"); + } + + @Test + void delete_erreurIgnoree_neRelancePas() { + // Best-effort : une exception du RestTemplate est avalée (log warn). + doThrow(new ResourceAccessException("down")) + .when(restTemplate).delete(anyString()); + + // Ne doit pas lever. + client.delete("src-99"); + verify(restTemplate).delete(BASE_URL + "/index/notebook-source/src-99"); + } +} diff --git a/core/src/test/java/com/loremind/infrastructure/ai/BrainRandomTableClientTest.java b/core/src/test/java/com/loremind/infrastructure/ai/BrainRandomTableClientTest.java new file mode 100644 index 0000000..3a64043 --- /dev/null +++ b/core/src/test/java/com/loremind/infrastructure/ai/BrainRandomTableClientTest.java @@ -0,0 +1,269 @@ +package com.loremind.infrastructure.ai; + +import com.loremind.domain.campaigncontext.RandomTableEntry; +import com.loremind.domain.campaigncontext.ports.RandomTableGenerationException; +import com.loremind.domain.campaigncontext.ports.RandomTableGenerator.GeneratedTable; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.web.client.HttpServerErrorException; +import org.springframework.web.client.ResourceAccessException; +import org.springframework.web.client.RestTemplate; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * Tests unitaires purs (JUnit 5 + Mockito, sans Spring) de {@link BrainRandomTableClient}. + * Le RestTemplate est mocké : {@code postForObject(url, entity, Map.class)}. + */ +class BrainRandomTableClientTest { + + private RestTemplate rt; + private BrainRandomTableClient client; + + @BeforeEach + void setUp() { + rt = mock(RestTemplate.class); + client = new BrainRandomTableClient(rt, "http://brain"); + } + + /** Construit une map d'entrée pour le payload "entries". */ + private static Map entry(Object min, Object max, Object label, Object detail) { + Map m = new LinkedHashMap<>(); + m.put("min_roll", min); + m.put("max_roll", max); + m.put("label", label); + m.put("detail", detail); + return m; + } + + // ---------- generate : cas nominal et branches de parsing ---------- + + @Test + void generate_reponseValide_plusieursEntrees_avecEntreesInvalidesIgnorees() { + List entries = new ArrayList<>(); + entries.add(entry(1, 5, "Embuscade", "des gobelins")); // valide (Number) + entries.add(entry("6", "10", "Trésor", null)); // valide (String -> asInt) + entries.add(entry(null, 3, "min null", "x")); // ignorée : min null + entries.add(entry(3, null, "max null", "x")); // ignorée : max null + entries.add(entry(1, 2, null, "x")); // ignorée : label null + entries.add(entry(1, 2, " ", "x")); // ignorée : label blank + entries.add(entry("abc", 2, "min non-num", "x")); // ignorée : asInt String non-numérique + entries.add("pas une map"); // ignorée : item non-Map + + Map resp = new LinkedHashMap<>(); + resp.put("name", "Table des rencontres"); + resp.put("description", "En forêt"); + resp.put("entries", entries); + + when(rt.postForObject(anyString(), any(), eq(Map.class))).thenReturn(resp); + + GeneratedTable table = client.generate("desc", "1d10", "ctx"); + + assertEquals("Table des rencontres", table.name()); + assertEquals("En forêt", table.description()); + assertEquals(2, table.entries().size()); + + RandomTableEntry e0 = table.entries().get(0); + assertEquals(1, e0.getMinRoll()); + assertEquals(5, e0.getMaxRoll()); + assertEquals("Embuscade", e0.getLabel()); + assertEquals("des gobelins", e0.getDetail()); + + RandomTableEntry e1 = table.entries().get(1); + assertEquals(6, e1.getMinRoll()); + assertEquals(10, e1.getMaxRoll()); + assertEquals("Trésor", e1.getLabel()); + assertNull(e1.getDetail()); // detail null -> asString(null) == null + } + + @Test + void generate_maxInferieurAMin_estCorrigeParMathMax() { + Map resp = new LinkedHashMap<>(); + resp.put("entries", List.of(entry(8, 3, "Inversé", null))); + + when(rt.postForObject(anyString(), any(), eq(Map.class))).thenReturn(resp); + + GeneratedTable table = client.generate("d", "1d8", "c"); + RandomTableEntry e = table.entries().get(0); + assertEquals(8, e.getMinRoll()); + assertEquals(8, e.getMaxRoll()); // Math.max(8,3) == 8 + } + + @Test + void generate_nameAbsent_fallbackSurDescription() { + Map resp = new LinkedHashMap<>(); + resp.put("entries", List.of(entry(1, 1, "Ok", null))); + // pas de "name" -> asString(null) == null -> fallback description + + when(rt.postForObject(anyString(), any(), eq(Map.class))).thenReturn(resp); + + GeneratedTable table = client.generate("MaDescription", "1d20", "c"); + assertEquals("MaDescription", table.name()); + assertNull(table.description()); // description absente + } + + @Test + void generate_nameBlank_fallbackSurDescription() { + Map resp = new LinkedHashMap<>(); + resp.put("name", " "); + resp.put("entries", List.of(entry(1, 1, "Ok", null))); + + when(rt.postForObject(anyString(), any(), eq(Map.class))).thenReturn(resp); + + GeneratedTable table = client.generate("Fallback", "1d20", "c"); + assertEquals("Fallback", table.name()); + } + + @Test + void generate_argumentsNull_remplisParDefauts_etAppelle() { + Map resp = new LinkedHashMap<>(); + resp.put("name", "T"); + resp.put("entries", List.of(entry(1, 1, "Ok", null))); + + when(rt.postForObject(anyString(), any(), eq(Map.class))).thenReturn(resp); + + // description/diceFormula/context null -> branches de défaut couvertes + GeneratedTable table = client.generate(null, null, null); + assertEquals("T", table.name()); + } + + @Test + void generate_reponseNull_leveException() { + when(rt.postForObject(anyString(), any(), eq(Map.class))).thenReturn(null); + + RandomTableGenerationException ex = assertThrows(RandomTableGenerationException.class, + () -> client.generate("d", "1d20", "c")); + assertTrue(ex.getMessage().contains("réponse vide")); + } + + @Test + void generate_entriesAbsentes_aucuneEntree_leveException() { + Map resp = new LinkedHashMap<>(); + resp.put("name", "T"); + // pas de "entries" -> rawEntries null, pas un List + + when(rt.postForObject(anyString(), any(), eq(Map.class))).thenReturn(resp); + + RandomTableGenerationException ex = assertThrows(RandomTableGenerationException.class, + () -> client.generate("d", "1d20", "c")); + assertTrue(ex.getMessage().contains("Aucune entrée")); + } + + @Test + void generate_entriesVide_leveException() { + Map resp = new LinkedHashMap<>(); + resp.put("entries", new ArrayList<>()); // List mais vide + + when(rt.postForObject(anyString(), any(), eq(Map.class))).thenReturn(resp); + + RandomTableGenerationException ex = assertThrows(RandomTableGenerationException.class, + () -> client.generate("d", "1d20", "c")); + assertTrue(ex.getMessage().contains("Aucune entrée")); + } + + @Test + void generate_toutesEntreesInvalides_leveException() { + Map resp = new LinkedHashMap<>(); + resp.put("entries", List.of(entry(null, null, null, null))); + + when(rt.postForObject(anyString(), any(), eq(Map.class))).thenReturn(resp); + + assertThrows(RandomTableGenerationException.class, + () -> client.generate("d", "1d20", "c")); + } + + // ---------- generate : branches du catch ---------- + + @Test + void generate_brainInjoignable_resourceAccess() { + when(rt.postForObject(anyString(), any(), eq(Map.class))) + .thenThrow(new ResourceAccessException("down")); + + RandomTableGenerationException ex = assertThrows(RandomTableGenerationException.class, + () -> client.generate("d", "1d20", "c")); + assertTrue(ex.getMessage().contains("injoignable")); + assertInstanceOf(ResourceAccessException.class, ex.getCause()); + } + + @Test + void generate_erreurHttp_restClientResponse() { + when(rt.postForObject(anyString(), any(), eq(Map.class))) + .thenThrow(HttpServerErrorException.create( + HttpStatus.BAD_GATEWAY, "Bad Gateway", new HttpHeaders(), new byte[0], null)); + + RandomTableGenerationException ex = assertThrows(RandomTableGenerationException.class, + () -> client.generate("d", "1d20", "c")); + assertTrue(ex.getMessage().contains("HTTP 502")); + } + + @Test + void generate_erreurInattendue_exceptionGenerique() { + when(rt.postForObject(anyString(), any(), eq(Map.class))) + .thenThrow(new IllegalStateException("boom")); + + RandomTableGenerationException ex = assertThrows(RandomTableGenerationException.class, + () -> client.generate("d", "1d20", "c")); + assertTrue(ex.getMessage().contains("inattendue")); + assertInstanceOf(IllegalStateException.class, ex.getCause()); + } + + // ---------- improvise ---------- + + @Test + void improvise_narrationPresente_retourneNarration() { + Map resp = new LinkedHashMap<>(); + resp.put("narration", "Les gobelins surgissent des fourrés."); + + when(rt.postForObject(anyString(), any(), eq(Map.class))).thenReturn(resp); + + String out = client.improvise("Table", "Embuscade", "détail", "ctx"); + assertEquals("Les gobelins surgissent des fourrés.", out); + } + + @Test + void improvise_argumentsNull_remplisParDefauts() { + Map resp = new LinkedHashMap<>(); + resp.put("narration", "ok"); + + when(rt.postForObject(anyString(), any(), eq(Map.class))).thenReturn(resp); + + assertEquals("ok", client.improvise(null, null, null, null)); + } + + @Test + void improvise_reponseNull_retourneChaineVide() { + when(rt.postForObject(anyString(), any(), eq(Map.class))).thenReturn(null); + + assertEquals("", client.improvise("T", "L", "D", "C")); + } + + @Test + void improvise_narrationAbsente_retourneChaineVide() { + Map resp = new LinkedHashMap<>(); // pas de "narration" + + when(rt.postForObject(anyString(), any(), eq(Map.class))).thenReturn(resp); + + assertEquals("", client.improvise("T", "L", "D", "C")); + } + + @Test + void improvise_brainInjoignable_propageException() { + when(rt.postForObject(anyString(), any(), eq(Map.class))) + .thenThrow(new ResourceAccessException("down")); + + assertThrows(RandomTableGenerationException.class, + () -> client.improvise("T", "L", "D", "C")); + } +} diff --git a/core/src/test/java/com/loremind/infrastructure/ai/BrainRulesImportClientTest.java b/core/src/test/java/com/loremind/infrastructure/ai/BrainRulesImportClientTest.java new file mode 100644 index 0000000..6b629da --- /dev/null +++ b/core/src/test/java/com/loremind/infrastructure/ai/BrainRulesImportClientTest.java @@ -0,0 +1,334 @@ +package com.loremind.infrastructure.ai; + +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 org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.web.client.HttpServerErrorException; +import org.springframework.web.client.ResourceAccessException; +import org.springframework.web.client.RestTemplate; +import org.springframework.web.reactive.function.client.ClientResponse; +import org.springframework.web.reactive.function.client.ExchangeFunction; +import org.springframework.web.reactive.function.client.WebClient; +import reactor.core.publisher.Mono; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * Tests unitaires purs (JUnit 5 + Mockito, sans Spring/réseau) de + * {@link BrainRulesImportClient} : + * - one-shot via RestTemplate (importRules) ; + * - streaming SSE via WebClient + ExchangeFunction (importRulesStreaming). + * Couvre aussi indirectement les getters du DTO {@link BrainRulesImportResponse}. + */ +class BrainRulesImportClientTest { + + private static final String BASE_URL = "http://brain"; + + private RestTemplate restTemplate; + private ObjectMapper objectMapper; + + @BeforeEach + void setUp() { + restTemplate = mock(RestTemplate.class); + objectMapper = new ObjectMapper(); + } + + /** Construit le client avec un WebClient câblé sur l'ExchangeFunction fournie. */ + private BrainRulesImportClient client(ExchangeFunction ef) { + WebClient.Builder builder = WebClient.builder().exchangeFunction(ef); + return new BrainRulesImportClient(restTemplate, builder, objectMapper, BASE_URL, 30); + } + + /** Client one-shot : l'ExchangeFunction n'est jamais utilisée. */ + private BrainRulesImportClient oneShotClient() { + return client(req -> Mono.empty()); + } + + // --- One-shot (RestTemplate) -------------------------------------------- + + @Test + void importRules_succes_retourneResultatEtCouvreGettersDuDto() { + BrainRulesImportResponse wire = new BrainRulesImportResponse(); + wire.setSections(Map.of("Combat", "## Combat")); + wire.setPageCount(12); + wire.setOcrPageCount(3); + // Couvre aussi les getters Lombok du DTO. + assertEquals(Map.of("Combat", "## Combat"), wire.getSections()); + assertEquals(12, wire.getPageCount()); + assertEquals(3, wire.getOcrPageCount()); + + when(restTemplate.postForObject(anyString(), any(), eq(BrainRulesImportResponse.class))) + .thenReturn(wire); + + RulesImportResult result = oneShotClient().importRules(new byte[]{1, 2, 3}, "regles.pdf"); + + assertEquals(Map.of("Combat", "## Combat"), result.sections()); + assertEquals(12, result.pageCount()); + assertEquals(3, result.ocrPageCount()); + } + + @Test + void importRules_filenameNull_utiliseNomParDefaut() { + BrainRulesImportResponse wire = new BrainRulesImportResponse(); + wire.setSections(Map.of("A", "x")); + when(restTemplate.postForObject(anyString(), any(), eq(BrainRulesImportResponse.class))) + .thenReturn(wire); + + // filename null -> branche du filePart anonyme (getFilename par défaut). + RulesImportResult result = oneShotClient().importRules(new byte[]{9}, null); + assertEquals(1, result.sections().size()); + } + + @Test + void importRules_reponseNull_leveRulesImportException() { + when(restTemplate.postForObject(anyString(), any(), eq(BrainRulesImportResponse.class))) + .thenReturn(null); + + RulesImportException ex = assertThrows(RulesImportException.class, + () -> oneShotClient().importRules(new byte[]{1}, "r.pdf")); + assertTrue(ex.getMessage().contains("vide")); + } + + @Test + void importRules_sectionsNull_leveRulesImportException() { + BrainRulesImportResponse wire = new BrainRulesImportResponse(); + wire.setSections(null); + when(restTemplate.postForObject(anyString(), any(), eq(BrainRulesImportResponse.class))) + .thenReturn(wire); + + assertThrows(RulesImportException.class, + () -> oneShotClient().importRules(new byte[]{1}, "r.pdf")); + } + + @Test + void importRules_resourceAccess_leveRulesImportException() { + when(restTemplate.postForObject(anyString(), any(), eq(BrainRulesImportResponse.class))) + .thenThrow(new ResourceAccessException("timeout")); + + RulesImportException ex = assertThrows(RulesImportException.class, + () -> oneShotClient().importRules(new byte[]{1}, "r.pdf")); + assertTrue(ex.getMessage().contains("injoignable")); + } + + @Test + void importRules_httpServerError_leveRulesImportExceptionAvecStatut() { + HttpServerErrorException http = HttpServerErrorException.create( + HttpStatus.BAD_GATEWAY, "Bad Gateway", new HttpHeaders(), new byte[0], null); + when(restTemplate.postForObject(anyString(), any(), eq(BrainRulesImportResponse.class))) + .thenThrow(http); + + RulesImportException ex = assertThrows(RulesImportException.class, + () -> oneShotClient().importRules(new byte[]{1}, "r.pdf")); + assertTrue(ex.getMessage().contains("502")); + } + + @Test + void importRules_erreurInattendue_leveRulesImportException() { + when(restTemplate.postForObject(anyString(), any(), eq(BrainRulesImportResponse.class))) + .thenThrow(new IllegalStateException("boom")); + + RulesImportException ex = assertThrows(RulesImportException.class, + () -> oneShotClient().importRules(new byte[]{1}, "r.pdf")); + assertTrue(ex.getMessage().contains("inattendue")); + } + + // --- Collecteur de callbacks pour le streaming -------------------------- + + private static final class Collector { + final List progress = new ArrayList<>(); + final AtomicInteger heartbeats = new AtomicInteger(); + final List statuses = new ArrayList<>(); + final AtomicReference done = new AtomicReference<>(); + final AtomicReference error = new AtomicReference<>(); + } + + private void runStreaming(String sse, Collector c) { + ExchangeFunction ef = req -> Mono.just( + ClientResponse.create(HttpStatus.OK) + .header(HttpHeaders.CONTENT_TYPE, MediaType.TEXT_EVENT_STREAM_VALUE) + .body(sse) + .build()); + client(ef).importRulesStreaming( + new byte[]{1, 2}, "r.pdf", + c.progress::add, + c.heartbeats::incrementAndGet, + c.statuses::add, + c.done::set, + c.error::set); + } + + // --- Streaming (WebClient + SSE) ---------------------------------------- + + @Test + void streaming_tousLesEvenements_declenchentLesBonsCallbacks() { + String sse = + "event:extracting\ndata:{}\n\n" + + "event:start\ndata:{\"total\":2,\"page_count\":10,\"ocr_page_count\":1}\n\n" + + "event:progress\ndata:{\"current\":1,\"total\":2,\"new_sections\":[\"Combat\"]}\n\n" + + "event:heartbeat\ndata:{}\n\n" + + "event:status\ndata:{\"message\":\"retry\"}\n\n" + + "event:chunk_failed\ndata:{\"current\":2,\"total\":2,\"message\":\"timeout\"}\n\n" + + "event:done\ndata:{\"sections\":{\"Combat\":\"## Combat\"},\"page_count\":10,\"ocr_page_count\":1}\n\n"; + + Collector c = new Collector(); + runStreaming(sse, c); + + // extracting -> progress(0,0,0,0,[]) ; start -> progress(0,2,10,1,[]) ; progress -> progress(1,2,10,1,[Combat]) + assertEquals(3, c.progress.size()); + + RulesImportProgress extracting = c.progress.get(0); + assertEquals(0, extracting.total()); + assertTrue(extracting.newSectionTitles().isEmpty()); + + RulesImportProgress start = c.progress.get(1); + assertEquals(0, start.current()); + assertEquals(2, start.total()); + assertEquals(10, start.pageCount()); + assertEquals(1, start.ocrPageCount()); + + RulesImportProgress prog = c.progress.get(2); + assertEquals(1, prog.current()); + assertEquals(2, prog.total()); + assertEquals(10, prog.pageCount()); + assertEquals(1, prog.ocrPageCount()); + assertEquals(List.of("Combat"), prog.newSectionTitles()); + + assertEquals(1, c.heartbeats.get()); + + // status (readMessage -> "retry") + chunk_failed (statut formaté). + assertEquals(2, c.statuses.size()); + assertEquals("retry", c.statuses.get(0)); + assertTrue(c.statuses.get(1).contains("Morceau 2/2")); + assertTrue(c.statuses.get(1).contains("timeout")); + + assertNotNull(c.done.get()); + assertEquals(Map.of("Combat", "## Combat"), c.done.get().sections()); + assertEquals(10, c.done.get().pageCount()); + assertEquals(1, c.done.get().ocrPageCount()); + + // done a positionné terminated -> pas d'onError. + assertEquals(null, c.error.get()); + } + + @Test + void streaming_evenementError_appelleOnError() { + String sse = "event:error\ndata:{\"message\":\"LLM saturé\"}\n\n"; + Collector c = new Collector(); + runStreaming(sse, c); + + assertNotNull(c.error.get()); + assertTrue(c.error.get() instanceof RulesImportException); + assertTrue(c.error.get().getMessage().contains("LLM saturé")); + assertNull(c.done.get()); + } + + @Test + void streaming_chunkFailedSansMessage_statutAvecPoint() { + // node sans champ "message" -> branche "." finale. + String sse = "event:chunk_failed\ndata:{\"current\":1,\"total\":3}\n\n"; + Collector c = new Collector(); + runStreaming(sse, c); + + assertEquals(1, c.statuses.size()); + assertTrue(c.statuses.get(0).contains("Morceau 1/3 ignoré.")); + } + + @Test + void streaming_statusDataNonJson_readMessageRenvoieDataBrut() { + // data non-JSON -> readJson renvoie null -> readMessage retourne le data brut. + String sse = "event:status\ndata:texte brut\n\n"; + Collector c = new Collector(); + runStreaming(sse, c); + + assertEquals(1, c.statuses.size()); + assertEquals("texte brut", c.statuses.get(0)); + } + + @Test + void streaming_evenementInconnuAvecDataNonJson_estIgnore() { + // Pas heartbeat/status/chunk_failed/error/extracting, et readJson==null -> return. + String sse = "event:mystere\ndata:pas du json\n\n"; + Collector c = new Collector(); + runStreaming(sse, c); + + assertTrue(c.progress.isEmpty()); + assertTrue(c.statuses.isEmpty()); + // Aucun done/error -> flux interrompu -> onError. + assertNotNull(c.error.get()); + assertTrue(c.error.get().getMessage().contains("interrompu")); + } + + @Test + void streaming_finSansDoneNiError_signaleFluxInterrompu() { + // Que des heartbeats : pas de done/error -> branche "flux interrompu". + String sse = "event:heartbeat\ndata:{}\n\n"; + Collector c = new Collector(); + runStreaming(sse, c); + + assertEquals(1, c.heartbeats.get()); + assertNotNull(c.error.get()); + assertTrue(c.error.get() instanceof RulesImportException); + assertTrue(c.error.get().getMessage().contains("interrompu")); + } + + @Test + void streaming_erreurTransport_appelleOnErrorAvecCauseExposee() { + ExchangeFunction ef = req -> Mono.error(new RuntimeException("boom")); + Collector c = new Collector(); + client(ef).importRulesStreaming( + new byte[]{1}, "r.pdf", + c.progress::add, c.heartbeats::incrementAndGet, c.statuses::add, + c.done::set, c.error::set); + + assertNotNull(c.error.get()); + assertTrue(c.error.get() instanceof RulesImportException); + // La cause réelle (type + message) est exposée dans le message. + assertTrue(c.error.get().getMessage().contains("boom")); + assertNotNull(c.error.get().getCause()); + } + + @Test + void streaming_filenameVide_utiliseNomParDefautSansErreur() { + // filename vide -> branches "rules.pdf" du filePart et du part().filename(). + String sse = "event:done\ndata:{\"sections\":{},\"page_count\":0,\"ocr_page_count\":0}\n\n"; + Collector c = new Collector(); + ExchangeFunction ef = req -> Mono.just( + ClientResponse.create(HttpStatus.OK) + .header(HttpHeaders.CONTENT_TYPE, MediaType.TEXT_EVENT_STREAM_VALUE) + .body(sse) + .build()); + client(ef).importRulesStreaming( + new byte[]{1}, " ", + c.progress::add, c.heartbeats::incrementAndGet, c.statuses::add, + c.done::set, c.error::set); + + assertNotNull(c.done.get()); + assertTrue(c.done.get().sections().isEmpty()); + assertNull(c.error.get()); + } + + // petits helpers d'assertion null/non-null (évite import statique supplémentaire) + private static void assertNull(Object o) { + assertTrue(o == null, "attendu null"); + } +}