Amélioration de la couverture de tests pour la partie infrastructure.ai
This commit is contained in:
@@ -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<ChatUsage> usages = new ArrayList<>();
|
||||||
|
private final List<String> tokens = new ArrayList<>();
|
||||||
|
private final AtomicBoolean completed = new AtomicBoolean(false);
|
||||||
|
private final AtomicReference<Throwable> error = new AtomicReference<>();
|
||||||
|
|
||||||
|
private final Consumer<ChatUsage> onUsage = usages::add;
|
||||||
|
private final Consumer<String> onToken = tokens::add;
|
||||||
|
private final Runnable onComplete = () -> completed.set(true);
|
||||||
|
private final Consumer<Throwable> 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<String, String> 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<String> urlCaptor = ArgumentCaptor.forClass(String.class);
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
ArgumentCaptor<HttpEntity<BrainGeneratePageRequest>> entityCaptor =
|
||||||
|
ArgumentCaptor.forClass(HttpEntity.class);
|
||||||
|
|
||||||
|
org.mockito.Mockito.verify(rt).postForObject(
|
||||||
|
urlCaptor.capture(),
|
||||||
|
entityCaptor.capture(),
|
||||||
|
eq(BrainGeneratePageResponse.class));
|
||||||
|
|
||||||
|
assertEquals(EXPECTED_URL, urlCaptor.getValue());
|
||||||
|
|
||||||
|
HttpEntity<BrainGeneratePageRequest> 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<Throwable> 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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}.
|
||||||
|
* <p>
|
||||||
|
* 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<CampaignImportProgress> progresses = new ArrayList<>();
|
||||||
|
final AtomicInteger heartbeats = new AtomicInteger(0);
|
||||||
|
final List<String> statuses = new ArrayList<>();
|
||||||
|
final AtomicReference<CampaignImportProposal> done = new AtomicReference<>();
|
||||||
|
final AtomicReference<Throwable> 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 " : <msg>".
|
||||||
|
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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,12 +7,21 @@ import com.loremind.domain.generationcontext.CampaignStructuralContext.ChapterSu
|
|||||||
import com.loremind.domain.generationcontext.CampaignStructuralContext.SceneSummary;
|
import com.loremind.domain.generationcontext.CampaignStructuralContext.SceneSummary;
|
||||||
import com.loremind.domain.generationcontext.ChatMessage;
|
import com.loremind.domain.generationcontext.ChatMessage;
|
||||||
import com.loremind.domain.generationcontext.ChatRequest;
|
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;
|
||||||
import com.loremind.domain.generationcontext.LoreStructuralContext.PageSummary;
|
import com.loremind.domain.generationcontext.LoreStructuralContext.PageSummary;
|
||||||
import com.loremind.domain.generationcontext.NarrativeEntityContext;
|
import com.loremind.domain.generationcontext.NarrativeEntityContext;
|
||||||
import com.loremind.domain.generationcontext.PageContext;
|
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 org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
@@ -284,4 +293,252 @@ class BrainChatPayloadBuilderTest {
|
|||||||
assertFalse(payload.containsKey("lore_context"));
|
assertFalse(payload.containsKey("lore_context"));
|
||||||
assertFalse(payload.containsKey("page_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<String, Object> 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<String, Object> 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<String, Object> cctx = asMap(builder.build(req).get("campaign_context"));
|
||||||
|
List<Map<String, Object>> chars = (List<Map<String, Object>>) 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<Map<String, Object>> npcs = (List<Map<String, Object>>) 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<String, Object> arcMap = firstOf(asMap(builder.build(req).get("campaign_context")), "arcs");
|
||||||
|
Map<String, Object> sceneMap = firstOf(firstOf(arcMap, "chapters"), "scenes");
|
||||||
|
Map<String, Object> 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<String, Object> 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<String, Object> arcMap = firstOf(asMap(builder.build(req).get("campaign_context")), "arcs");
|
||||||
|
Map<String, Object> sceneMap = firstOf(firstOf(arcMap, "chapters"), "scenes");
|
||||||
|
Map<String, Object> roomMap = firstOf(sceneMap, "rooms");
|
||||||
|
|
||||||
|
assertFalse(roomMap.containsKey("floor"));
|
||||||
|
assertFalse(roomMap.containsKey("description"));
|
||||||
|
assertFalse(roomMap.containsKey("enemies"));
|
||||||
|
Map<String, Object> 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<String, Object> arcMap = firstOf(asMap(builder.build(req).get("campaign_context")), "arcs");
|
||||||
|
Map<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> prevMap = firstOf(scm, "previous_events");
|
||||||
|
assertEquals("Session 1", prevMap.get("source_session_name"));
|
||||||
|
|
||||||
|
// available_quests : description renseignée ; in_progress : description blank omise.
|
||||||
|
Map<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<String, String> 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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<String, Object> item(Object name, Object price, Object category, Object description) {
|
||||||
|
Map<String, Object> 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<Object> 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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 :
|
||||||
|
* <ul>
|
||||||
|
* <li>clients RestTemplate : on capture le {@link HttpEntity} envoyé et on lit la
|
||||||
|
* ressource « file » pour appeler {@code getFilename()} ;</li>
|
||||||
|
* <li>clients WebClient : l'{@link ExchangeFunction} sérialise réellement le corps
|
||||||
|
* multipart dans un {@link MockClientHttpRequest} (ce qui invoque getFilename).</li>
|
||||||
|
* </ul>
|
||||||
|
*/
|
||||||
|
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<HttpEntity> cap = ArgumentCaptor.forClass(HttpEntity.class);
|
||||||
|
verify(rt).postForObject(anyString(), cap.capture(), any());
|
||||||
|
MultiValueMap<String, Object> body = (MultiValueMap<String, Object>) 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 -> { });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<String> sources = new AtomicReference<>();
|
||||||
|
final StringBuilder tokens = new StringBuilder();
|
||||||
|
final List<Progress> progresses = new ArrayList<>();
|
||||||
|
final AtomicBoolean done = new AtomicBoolean(false);
|
||||||
|
final AtomicReference<Throwable> 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
|
* <p>
|
||||||
|
* {@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<String> 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<String, Object> entry(Object min, Object max, Object label, Object detail) {
|
||||||
|
Map<String, Object> 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<Object> 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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<RulesImportProgress> progress = new ArrayList<>();
|
||||||
|
final AtomicInteger heartbeats = new AtomicInteger();
|
||||||
|
final List<String> statuses = new ArrayList<>();
|
||||||
|
final AtomicReference<RulesImportResult> done = new AtomicReference<>();
|
||||||
|
final AtomicReference<Throwable> 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user