Compare commits

..

13 Commits

Author SHA1 Message Date
bf871852b8 Mise à jour de l'orchestrateur
Some checks failed
E2E Tests / e2e (push) Failing after 2m31s
2026-06-14 17:18:48 +02:00
78e735c959 Mise à jour de la partie watchtower ; pas nécéssaire tout le temps
Some checks are pending
E2E Tests / e2e (push) Waiting to run
2026-06-14 16:51:46 +02:00
c734de447f Merge branch 'beta'
Some checks failed
E2E Tests / e2e (push) Failing after 24s
Build & Push Images / build (brain) (push) Successful in 1m42s
Build & Push Images / build (core) (push) Successful in 1m52s
Build & Push Images / build-switcher (push) Successful in 53s
Build & Push Images / build (web) (push) Successful in 2m0s
2026-06-14 16:32:51 +02:00
914767f793 Montée version v0.14.0-beta
All checks were successful
Build & Push Images / build (brain) (push) Successful in 1m49s
Build & Push Images / build (core) (push) Successful in 2m12s
Build & Push Images / build-switcher (push) Successful in 16s
Build & Push Images / build (web) (push) Successful in 1m58s
2026-06-14 16:24:52 +02:00
af3a6d443c Mise en place de l'anglais comme deuxième langue pour l'application
Some checks failed
Build & Push Images / build (brain) (push) Has been cancelled
Build & Push Images / build (core) (push) Has been cancelled
Build & Push Images / build (web) (push) Has been cancelled
Build & Push Images / build-switcher (push) Has been cancelled
2026-06-14 16:24:05 +02:00
6e75326779 Amélioration de la couverture de tests pour la partie infrastructure.ai 2026-06-14 11:45:20 +02:00
d0b53bb15a Ajout, corrections et modifications de tests unitaires pour la partie infrastructure.web.controller.
Amélioration de la couverture de test
2026-06-14 11:25:29 +02:00
bbcb5ee34e Correction du test CampaignStructuralContextBuilderTest
Ajout du mock EnemyRepository manquant (dependance ajoutee au
constructeur lors du referencement des ennemis). Sans ce mock,
@InjectMocks injectait null -> NPE sur enemyRepository.findByCampaignId.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 10:11:27 +02:00
c77c0bc994 référencement des ennemis dans les lieux d'une quête ou d'un chapitre
All checks were successful
Build & Push Images / build (brain) (push) Successful in 1m33s
Build & Push Images / build (core) (push) Successful in 1m58s
Build & Push Images / build-switcher (push) Successful in 17s
Build & Push Images / build (web) (push) Successful in 1m53s
2026-06-13 11:08:16 +02:00
6035df262d Ajout de la possibilité de faire des stats blocs pour tout ce qui est ennemis / créatures adverses.
All checks were successful
Build & Push Images / build (brain) (push) Successful in 1m32s
Build & Push Images / build (core) (push) Successful in 1m52s
Build & Push Images / build-switcher (push) Successful in 20s
Build & Push Images / build (web) (push) Successful in 1m48s
Dorénavant, l'IA est capable de prendre en compte le format des quêtes, chapitres, Arc.... pour proposer des blocs plus complets.
Les ennemis sont également référençables directement dans la campagne.
Les références vers les ennemis dans la partie "donjon" est en cours d'ajout
2026-06-12 23:38:43 +02:00
809e00ce49 Ajout de la possibilité d'archiver le chat dans l'atelier PDF + IA, ainsi que de référencer l'archive dans la conversation actuelle.
All checks were successful
Build & Push Images / build (core) (push) Successful in 1m47s
Build & Push Images / build (brain) (push) Successful in 1m52s
Build & Push Images / build-switcher (push) Successful in 27s
Build & Push Images / build (web) (push) Successful in 1m57s
Le chat est limité à 16 000 caractères pour l'archive et le début est tronqué pour laisser plutôt la conclusion en visibilité.
Passage bêta 0.12.6
2026-06-12 16:57:57 +02:00
bc0cbb0f7b Correction sur le NotebookController....
All checks were successful
Build & Push Images / build (brain) (push) Successful in 1m34s
Build & Push Images / build (core) (push) Successful in 2m1s
Build & Push Images / build-switcher (push) Successful in 20s
Build & Push Images / build (web) (push) Successful in 1m50s
2026-06-12 16:05:34 +02:00
6740ed2177 Mise en place de la sélection des source que l'on souhaite que ce soit la partie RAG ou la partie analyse approfondie : on est plus obligé d'envoyer tous les PDFs qu'on a dans la partie atelier PDF + IA.
Some checks failed
Build & Push Images / build (brain) (push) Successful in 1m41s
Build & Push Images / build (core) (push) Failing after 1m46s
Build & Push Images / build-switcher (push) Successful in 26s
Build & Push Images / build (web) (push) Successful in 1m54s
Les réponses ne ce baseront que sur les sources que l'on aura cocher au préalable
2026-06-12 15:58:45 +02:00
321 changed files with 17977 additions and 1785 deletions

View File

@@ -34,23 +34,59 @@ Règles :
PROPOSITIONS D'INTÉGRATION (IMPORTANT) : PROPOSITIONS D'INTÉGRATION (IMPORTANT) :
Quand l'utilisateur veut CRÉER ou ADAPTER un élément concret pour sa campagne (un PNJ, Quand l'utilisateur veut CRÉER ou ADAPTER un élément concret pour sa campagne (un PNJ,
une scène, un chapitre, un arc, une table aléatoire), termine ta réponse par un ou une scène, un chapitre, une quête, un arc, une table aléatoire), termine ta réponse par
plusieurs BLOCS D'ACTION — un objet JSON par bloc, dans une clôture ```loremind-action. un ou plusieurs BLOCS D'ACTION — un objet JSON par bloc, dans une clôture
L'interface les transformera en boutons « Créer dans la campagne ». N'en mets que si ```loremind-action. L'interface les transformera en boutons « Créer dans la campagne ».
c'est pertinent et explicitement souhaité. Formats acceptés : Si l'utilisateur demande PLUSIEURS éléments (« propose-moi 3 quêtes »), produis UN bloc
par élément. N'en mets pas si l'utilisateur pose une simple question.
VOCABULAIRE DE LA CAMPAGNE : une « quête » n'est PAS un type à part — c'est un CHAPITRE
rangé dans un arc de type HUB (quêtes parallèles, sans ordre imposé), tandis qu'un arc
LINEAR contient des chapitres joués en séquence. Donc :
- demande de QUÊTE → action "chapter" (l'utilisateur la placera dans son arc HUB) ;
s'il n'a aucun arc HUB dans sa campagne, propose AUSSI une action "arc" avec
"arcType": "HUB" pour les accueillir.
- demande de CHAPITRE → action "chapter" (destinée plutôt à un arc LINEAR).
RÈGLE CLÉ : remplis TOUS les champs pour lesquels tu as de la matière — pas seulement
le résumé ou les notes MJ. Chaque champ rempli atterrit au bon endroit de la fiche ;
un champ laissé vide est une fiche que l'utilisateur devra compléter à la main. Vise
2 à 5 phrases concrètes par champ narratif, tirées de la source et de la campagne.
Omets simplement un champ si tu n'as rien de précis à y mettre. Formats acceptés :
```loremind-action ```loremind-action
{{"type": "npc", "name": "Nom", "description": "Fiche en quelques phrases."}} {{"type": "npc", "name": "Nom",
"description": "Résumé du PNJ (rôle, apparence, motivation).",
"values": {{"<champ de la fiche PNJ>": "contenu", "<autre champ>": "contenu"}}}}
```
(`values` : utilise comme clés les CHAMPS DE LA FICHE PNJ listés dans le contexte
campagne s'ils y figurent — ex. "Histoire", "Apparence" — sinon omets `values`.)
```loremind-action
{{"type": "scene", "name": "Nom",
"description": "Résumé court de la scène.",
"location": "Lieu précis", "timing": "Quand elle survient",
"atmosphere": "Ambiance sensorielle (sons, odeurs, lumière…)",
"playerNarration": "Texte d'ambiance À LIRE AUX JOUEURS, immersif, à la 2e personne.",
"gmSecretNotes": "Secrets, vérités cachées, notes pour le MJ uniquement.",
"choicesConsequences": "Choix offerts aux joueurs et leurs conséquences.",
"combatDifficulty": "Difficulté du combat éventuel", "enemies": "Ennemis présents (effectifs, tactiques)"}}
``` ```
```loremind-action ```loremind-action
{{"type": "scene", "name": "Nom", "description": "Résumé", "content": "Déroulé détaillé."}} {{"type": "chapter", "name": "Nom",
"description": "Résumé du chapitre (ou de la quête).",
"playerObjectives": "Objectifs tels que les joueurs les perçoivent.",
"narrativeStakes": "Enjeux narratifs (ce qui se joue vraiment).",
"gmNotes": "Notes MJ : fils à tirer, points d'attention."}}
``` ```
```loremind-action ```loremind-action
{{"type": "chapter", "name": "Nom", "description": "Résumé du chapitre."}} {{"type": "arc", "name": "Nom", "description": "Résumé", "arcType": "LINEAR",
``` "themes": "Thèmes de l'arc", "stakes": "Enjeux",
```loremind-action "rewards": "Récompenses attendues", "resolution": "Issues possibles",
{{"type": "arc", "name": "Nom", "description": "Résumé", "arcType": "LINEAR"}} "gmNotes": "Notes MJ."}}
``` ```
(`arcType` : "LINEAR" pour des chapitres en séquence, "HUB" pour un recueil de
quêtes parallèles.)
```loremind-action ```loremind-action
{{"type": "table", "name": "Nom", "diceFormula": "1d8", "entries": [{{"minRoll":1,"maxRoll":4,"label":"...","detail":"..."}}]}} {{"type": "table", "name": "Nom", "diceFormula": "1d8", "entries": [{{"minRoll":1,"maxRoll":4,"label":"...","detail":"..."}}]}}
``` ```

View File

@@ -26,7 +26,7 @@ from app.infrastructure.ollama_model_installer import ensure_ollama_embedding_mo
app = FastAPI( app = FastAPI(
title="LoreMind Brain", title="LoreMind Brain",
description="Backend IA pour la génération de contenu narratif.", description="Backend IA pour la génération de contenu narratif.",
version="0.12.4-beta", version="0.14.0-beta",
) )
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)

View File

@@ -14,7 +14,7 @@
<groupId>com.loremind</groupId> <groupId>com.loremind</groupId>
<artifactId>loremind-core</artifactId> <artifactId>loremind-core</artifactId>
<version>0.12.4-beta</version> <version>0.14.0-beta</version>
<name>LoreMind Core</name> <name>LoreMind Core</name>
<description>Backend Core - Architecture Hexagonale</description> <description>Backend Core - Architecture Hexagonale</description>

View File

@@ -42,15 +42,17 @@ public class CampaignBriefBuilder {
if (notBlank(cc.campaignDescription())) sb.append(cc.campaignDescription()).append("\n"); if (notBlank(cc.campaignDescription())) sb.append(cc.campaignDescription()).append("\n");
sb.append("\n## Structure (arcs → chapitres → scènes)\n"); sb.append("\n## Structure (arcs → chapitres → scènes)\n");
sb.append("_Un arc HUB contient des chapitres parallèles appelés « quêtes » ; ")
.append("un arc LINEAR contient des chapitres en séquence._\n");
if (cc.arcs().isEmpty()) { if (cc.arcs().isEmpty()) {
sb.append("_(aucun arc pour le moment)_\n"); sb.append("_(aucun arc pour le moment)_\n");
} }
for (ArcSummary arc : cc.arcs()) { for (ArcSummary arc : cc.arcs()) {
sb.append("### Arc : ").append(arc.name()); sb.append(arc.hub() ? "### Arc HUB (à quêtes) : " : "### Arc : ").append(arc.name());
if (notBlank(arc.description())) sb.append("").append(arc.description()); if (notBlank(arc.description())) sb.append("").append(arc.description());
sb.append("\n"); sb.append("\n");
for (ChapterSummary ch : arc.chapters()) { for (ChapterSummary ch : arc.chapters()) {
sb.append("- Chapitre : ").append(ch.name()); sb.append(arc.hub() ? "- Quête : " : "- Chapitre : ").append(ch.name());
if (notBlank(ch.description())) sb.append("").append(ch.description()); if (notBlank(ch.description())) sb.append("").append(ch.description());
sb.append("\n"); sb.append("\n");
for (SceneSummary sc : ch.scenes()) { for (SceneSummary sc : ch.scenes()) {

View File

@@ -78,6 +78,11 @@ public class CharacterService {
characterRepository.deleteById(id); characterRepository.deleteById(id);
} }
public List<Character> searchCharacters(String query) {
if (query == null || query.isBlank()) return List.of();
return characterRepository.searchByName(query.trim());
}
private int nextOrderFor(String playthroughId) { private int nextOrderFor(String playthroughId) {
return characterRepository.findByPlaythroughId(playthroughId).stream() return characterRepository.findByPlaythroughId(playthroughId).stream()
.mapToInt(Character::getOrder) .mapToInt(Character::getOrder)

View File

@@ -0,0 +1,102 @@
package com.loremind.application.campaigncontext;
import com.loremind.domain.campaigncontext.Enemy;
import com.loremind.domain.campaigncontext.ports.EnemyRepository;
import org.springframework.stereotype.Service;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
/**
* Service d'application pour les fiches d'ennemis (bestiaire de campagne).
* Miroir de {@link NpcService} : fiche pilotée par le template ENNEMI du GameSystem.
*/
@Service
public class EnemyService {
private final EnemyRepository enemyRepository;
public EnemyService(EnemyRepository enemyRepository) {
this.enemyRepository = enemyRepository;
}
public record EnemyData(
String name,
String level,
String folder,
String portraitImageId,
String headerImageId,
Map<String, String> values,
Map<String, List<String>> imageValues,
Map<String, Map<String, String>> keyValueValues,
String campaignId,
Integer order
) {}
public Enemy createEnemy(EnemyData data) {
int order = data.order() != null ? data.order() : nextOrderFor(data.campaignId());
Enemy enemy = Enemy.builder()
.name(data.name())
.level(normalize(data.level()))
.folder(normalize(data.folder()))
.portraitImageId(data.portraitImageId())
.headerImageId(data.headerImageId())
.values(data.values() != null ? new HashMap<>(data.values()) : new HashMap<>())
.imageValues(data.imageValues() != null ? new HashMap<>(data.imageValues()) : new HashMap<>())
.keyValueValues(data.keyValueValues() != null ? new HashMap<>(data.keyValueValues()) : new HashMap<>())
.campaignId(data.campaignId())
.order(order)
.build();
return enemyRepository.save(enemy);
}
public Optional<Enemy> getEnemyById(String id) {
return enemyRepository.findById(id);
}
public List<Enemy> getEnemiesByCampaignId(String campaignId) {
return enemyRepository.findByCampaignId(campaignId);
}
public Enemy updateEnemy(String id, EnemyData data) {
Enemy existing = enemyRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("Enemy non trouvé avec l'ID: " + id));
existing.setName(data.name());
existing.setLevel(normalize(data.level()));
existing.setFolder(normalize(data.folder()));
existing.setPortraitImageId(data.portraitImageId());
existing.setHeaderImageId(data.headerImageId());
existing.setValues(data.values() != null ? new HashMap<>(data.values()) : new HashMap<>());
existing.setImageValues(data.imageValues() != null ? new HashMap<>(data.imageValues()) : new HashMap<>());
existing.setKeyValueValues(data.keyValueValues() != null ? new HashMap<>(data.keyValueValues()) : new HashMap<>());
if (data.order() != null) {
existing.setOrder(data.order());
}
return enemyRepository.save(existing);
}
public void deleteEnemy(String id) {
enemyRepository.deleteById(id);
}
public List<Enemy> searchEnemies(String query) {
if (query == null || query.isBlank()) return List.of();
return enemyRepository.searchByName(query.trim());
}
/** Trim ; chaîne vide → null (= non renseigné / non classé). */
private static String normalize(String value) {
if (value == null) return null;
String trimmed = value.trim();
return trimmed.isEmpty() ? null : trimmed;
}
private int nextOrderFor(String campaignId) {
return enemyRepository.findByCampaignId(campaignId).stream()
.mapToInt(Enemy::getOrder)
.max()
.orElse(-1) + 1;
}
}

View File

@@ -82,6 +82,11 @@ public class ItemCatalogService {
repository.deleteById(id); repository.deleteById(id);
} }
public List<ItemCatalog> searchCatalogs(String query) {
if (query == null || query.isBlank()) return List.of();
return repository.searchByName(query.trim());
}
/** Génère une PROPOSITION de catalogue (non persistée) via l'IA, contextualisée campagne. */ /** Génère une PROPOSITION de catalogue (non persistée) via l'IA, contextualisée campagne. */
public ItemCatalog generateProposal(String campaignId, String description) { public ItemCatalog generateProposal(String campaignId, String description) {
ItemCatalogGenerator.GeneratedCatalog g = generator.generate(description, buildContext(campaignId)); ItemCatalogGenerator.GeneratedCatalog g = generator.generate(description, buildContext(campaignId));

View File

@@ -22,16 +22,19 @@ public class NotebookService {
private final NotebookIndexer indexer; private final NotebookIndexer indexer;
private final CampaignRepository campaignRepository; private final CampaignRepository campaignRepository;
private final CampaignBriefBuilder briefBuilder; private final CampaignBriefBuilder briefBuilder;
private final com.loremind.domain.gamesystemcontext.ports.GameSystemRepository gameSystemRepository;
public NotebookService( public NotebookService(
NotebookRepository repository, NotebookRepository repository,
NotebookIndexer indexer, NotebookIndexer indexer,
CampaignRepository campaignRepository, CampaignRepository campaignRepository,
CampaignBriefBuilder briefBuilder) { CampaignBriefBuilder briefBuilder,
com.loremind.domain.gamesystemcontext.ports.GameSystemRepository gameSystemRepository) {
this.repository = repository; this.repository = repository;
this.indexer = indexer; this.indexer = indexer;
this.campaignRepository = campaignRepository; this.campaignRepository = campaignRepository;
this.briefBuilder = briefBuilder; this.briefBuilder = briefBuilder;
this.gameSystemRepository = gameSystemRepository;
} }
// --- Notebooks --- // --- Notebooks ---
@@ -119,6 +122,58 @@ public class NotebookService {
.notebookId(notebookId).role(role).content(content).build()); .notebookId(notebookId).role(role).content(content).build());
} }
/** « Vider la conversation » : archive le fil actif (rien n'est supprimé). */
public void clearChat(String notebookId) {
repository.archiveMessagesByNotebookId(notebookId);
}
/** Messages archivés, chronologiques — l'appelant regroupe par {@code archivedAt}. */
public List<NotebookMessage> getArchivedMessages(String notebookId) {
return repository.findArchivedMessagesByNotebookId(notebookId);
}
// Budget total (caractères ≈ tokens/4) des archives injectées en référence :
// borne le prompt même si l'utilisateur coche plusieurs longues conversations.
private static final int ARCHIVE_CONTEXT_MAX_CHARS = 16000;
/**
* Bloc de contexte construit à partir des archives COCHÉES par l'utilisateur
* (clés = {@code archivedAt.toString()}). Injecté dans le prompt du chat pour
* que l'IA puisse s'appuyer sur d'anciennes conversations. Chaîne vide si
* aucune clé valide. Chaque archive est tronquée PAR LE DÉBUT au-delà de son
* budget : la fin d'une conversation (conclusions) est la partie utile.
*/
public String buildArchiveContext(String notebookId, List<String> archivedAtKeys) {
if (archivedAtKeys == null || archivedAtKeys.isEmpty()) return "";
var wanted = new java.util.HashSet<>(archivedAtKeys);
var groups = new java.util.LinkedHashMap<java.time.LocalDateTime, List<NotebookMessage>>();
for (NotebookMessage m : repository.findArchivedMessagesByNotebookId(notebookId)) {
if (m.getArchivedAt() != null && wanted.contains(m.getArchivedAt().toString())) {
groups.computeIfAbsent(m.getArchivedAt(), k -> new java.util.ArrayList<>()).add(m);
}
}
if (groups.isEmpty()) return "";
int budgetPerArchive = Math.max(2000, ARCHIVE_CONTEXT_MAX_CHARS / groups.size());
StringBuilder out = new StringBuilder(
"--- ANCIENNES CONVERSATIONS DE CET ATELIER (références choisies par le MJ : "
+ "tu peux t'appuyer sur leurs conclusions) ---\n");
groups.forEach((archivedAt, messages) -> {
StringBuilder convo = new StringBuilder();
for (NotebookMessage m : messages) {
convo.append("user".equals(m.getRole()) ? "MJ : " : "IA : ")
.append(m.getContent()).append('\n');
}
String text = convo.toString();
if (text.length() > budgetPerArchive) {
text = "[…début tronqué…]\n" + text.substring(text.length() - budgetPerArchive);
}
out.append("[Archive du ").append(archivedAt).append("]\n").append(text).append('\n');
});
out.append("--- FIN DES ANCIENNES CONVERSATIONS ---");
return out.toString();
}
// --- Contexte campagne (oriente l'IA) --- // --- Contexte campagne (oriente l'IA) ---
/** Brief COMPLET de la campagne (structure arcs/chapitres/scènes + PNJ + lore) : /** Brief COMPLET de la campagne (structure arcs/chapitres/scènes + PNJ + lore) :
@@ -127,6 +182,25 @@ public class NotebookService {
if (campaignId == null) return ""; if (campaignId == null) return "";
Campaign campaign = campaignRepository.findById(campaignId).orElse(null); Campaign campaign = campaignRepository.findById(campaignId).orElse(null);
if (campaign == null) return ""; if (campaign == null) return "";
return briefBuilder.build(campaign); String brief = briefBuilder.build(campaign);
// Champs TEXT de la fiche PNJ du système de jeu : permet à l'IA de remplir
// `values` des actions "npc" avec les BONS noms de champs (Histoire,
// Apparence…) au lieu de tout entasser dans une description générique.
String npcFields = npcSheetFields(campaign.getGameSystemId());
return npcFields.isEmpty() ? brief : brief + "\n\n" + npcFields;
}
private String npcSheetFields(String gameSystemId) {
if (gameSystemId == null || gameSystemId.isBlank()) return "";
var gameSystem = gameSystemRepository.findById(gameSystemId).orElse(null);
if (gameSystem == null || gameSystem.getNpcTemplate() == null) return "";
var names = gameSystem.getNpcTemplate().stream()
.filter(f -> f.getType() == com.loremind.domain.shared.template.FieldType.TEXT)
.map(com.loremind.domain.shared.template.TemplateField::getName)
.filter(n -> n != null && !n.isBlank())
.toList();
if (names.isEmpty()) return "";
return "FICHE PNJ — champs texte disponibles (clés à utiliser dans `values` "
+ "d'une action npc) : " + String.join(", ", names);
} }
} }

View File

@@ -102,6 +102,11 @@ public class NpcService {
npcRepository.deleteById(id); npcRepository.deleteById(id);
} }
public List<Npc> searchNpcs(String query) {
if (query == null || query.isBlank()) return List.of();
return npcRepository.searchByName(query.trim());
}
/** Trim le dossier ; chaîne vide → null (= non classé). */ /** Trim le dossier ; chaîne vide → null (= non classé). */
private static String normalizeFolder(String folder) { private static String normalizeFolder(String folder) {
if (folder == null) return null; if (folder == null) return null;

View File

@@ -85,6 +85,11 @@ public class RandomTableService {
repository.deleteById(id); repository.deleteById(id);
} }
public List<RandomTable> searchTables(String query) {
if (query == null || query.isBlank()) return List.of();
return repository.searchByName(query.trim());
}
/** Génère une PROPOSITION de table (non persistée) via l'IA, contextualisée campagne. */ /** Génère une PROPOSITION de table (non persistée) via l'IA, contextualisée campagne. */
public RandomTable generateProposal(String campaignId, String description, String diceFormula) { public RandomTable generateProposal(String campaignId, String description, String diceFormula) {
String formula = (diceFormula == null || diceFormula.isBlank()) ? "1d20" : diceFormula; String formula = (diceFormula == null || diceFormula.isBlank()) ? "1d20" : diceFormula;

View File

@@ -57,6 +57,7 @@ public class GameSystemService {
String rulesMarkdown, String rulesMarkdown,
List<TemplateField> characterTemplate, List<TemplateField> characterTemplate,
List<TemplateField> npcTemplate, List<TemplateField> npcTemplate,
List<TemplateField> enemyTemplate,
String author, String author,
boolean isPublic boolean isPublic
) {} ) {}
@@ -71,6 +72,7 @@ public class GameSystemService {
.build(); .build();
gameSystem.replaceCharacterTemplate(data.characterTemplate()); gameSystem.replaceCharacterTemplate(data.characterTemplate());
gameSystem.replaceNpcTemplate(data.npcTemplate()); gameSystem.replaceNpcTemplate(data.npcTemplate());
gameSystem.replaceEnemyTemplate(data.enemyTemplate());
return gameSystemRepository.save(gameSystem); return gameSystemRepository.save(gameSystem);
} }
@@ -90,6 +92,7 @@ public class GameSystemService {
existing.setRulesMarkdown(data.rulesMarkdown()); existing.setRulesMarkdown(data.rulesMarkdown());
existing.replaceCharacterTemplate(data.characterTemplate()); existing.replaceCharacterTemplate(data.characterTemplate());
existing.replaceNpcTemplate(data.npcTemplate()); existing.replaceNpcTemplate(data.npcTemplate());
existing.replaceEnemyTemplate(data.enemyTemplate());
existing.setAuthor(normalize(data.author())); existing.setAuthor(normalize(data.author()));
existing.setPublic(data.isPublic()); existing.setPublic(data.isPublic());
return gameSystemRepository.save(existing); return gameSystemRepository.save(existing);

View File

@@ -1,6 +1,7 @@
package com.loremind.application.generationcontext; package com.loremind.application.generationcontext;
import com.loremind.domain.campaigncontext.Arc; import com.loremind.domain.campaigncontext.Arc;
import com.loremind.domain.campaigncontext.ArcType;
import com.loremind.domain.campaigncontext.Campaign; import com.loremind.domain.campaigncontext.Campaign;
import com.loremind.domain.campaigncontext.Chapter; import com.loremind.domain.campaigncontext.Chapter;
import com.loremind.domain.campaigncontext.Character; import com.loremind.domain.campaigncontext.Character;
@@ -10,6 +11,7 @@ import com.loremind.domain.campaigncontext.ports.ArcRepository;
import com.loremind.domain.campaigncontext.ports.CampaignRepository; import com.loremind.domain.campaigncontext.ports.CampaignRepository;
import com.loremind.domain.campaigncontext.ports.ChapterRepository; import com.loremind.domain.campaigncontext.ports.ChapterRepository;
import com.loremind.domain.campaigncontext.ports.CharacterRepository; import com.loremind.domain.campaigncontext.ports.CharacterRepository;
import com.loremind.domain.campaigncontext.ports.EnemyRepository;
import com.loremind.domain.campaigncontext.ports.NpcRepository; import com.loremind.domain.campaigncontext.ports.NpcRepository;
import com.loremind.domain.campaigncontext.ports.SceneRepository; import com.loremind.domain.campaigncontext.ports.SceneRepository;
import com.loremind.domain.generationcontext.CampaignStructuralContext; import com.loremind.domain.generationcontext.CampaignStructuralContext;
@@ -48,6 +50,7 @@ public class CampaignStructuralContextBuilder {
private final SceneRepository sceneRepository; private final SceneRepository sceneRepository;
private final CharacterRepository characterRepository; private final CharacterRepository characterRepository;
private final NpcRepository npcRepository; private final NpcRepository npcRepository;
private final EnemyRepository enemyRepository;
public CampaignStructuralContextBuilder( public CampaignStructuralContextBuilder(
CampaignRepository campaignRepository, CampaignRepository campaignRepository,
@@ -55,13 +58,15 @@ public class CampaignStructuralContextBuilder {
ChapterRepository chapterRepository, ChapterRepository chapterRepository,
SceneRepository sceneRepository, SceneRepository sceneRepository,
CharacterRepository characterRepository, CharacterRepository characterRepository,
NpcRepository npcRepository) { NpcRepository npcRepository,
EnemyRepository enemyRepository) {
this.campaignRepository = campaignRepository; this.campaignRepository = campaignRepository;
this.arcRepository = arcRepository; this.arcRepository = arcRepository;
this.chapterRepository = chapterRepository; this.chapterRepository = chapterRepository;
this.sceneRepository = sceneRepository; this.sceneRepository = sceneRepository;
this.characterRepository = characterRepository; this.characterRepository = characterRepository;
this.npcRepository = npcRepository; this.npcRepository = npcRepository;
this.enemyRepository = enemyRepository;
} }
/** Longueur max du snippet de PJ/PNJ injecté dans le contexte (coût tokens maîtrisé). */ /** Longueur max du snippet de PJ/PNJ injecté dans le contexte (coût tokens maîtrisé). */
@@ -84,9 +89,17 @@ public class CampaignStructuralContextBuilder {
.orElseThrow(() -> new IllegalArgumentException( .orElseThrow(() -> new IllegalArgumentException(
"Campagne non trouvée avec l'ID: " + campaignId)); "Campagne non trouvée avec l'ID: " + campaignId));
// Libellés du bestiaire (« Nom (niveau) ») chargés UNE fois pour résoudre
// les enemyIds des pièces sans N+1 sur le repo.
Map<String, String> enemyLabelById = enemyRepository.findByCampaignId(campaignId).stream()
.collect(Collectors.toMap(
com.loremind.domain.campaigncontext.Enemy::getId,
CampaignStructuralContextBuilder::enemyLabel,
(a, b) -> a));
List<ArcSummary> arcs = arcRepository.findByCampaignId(campaignId).stream() List<ArcSummary> arcs = arcRepository.findByCampaignId(campaignId).stream()
.sorted(Comparator.comparingInt(Arc::getOrder)) .sorted(Comparator.comparingInt(Arc::getOrder))
.map(this::toArcSummary) .map(arc -> toArcSummary(arc, enemyLabelById))
.collect(Collectors.toList()); .collect(Collectors.toList());
List<CharacterSummary> characters = (playthroughId == null || playthroughId.isBlank()) List<CharacterSummary> characters = (playthroughId == null || playthroughId.isBlank())
@@ -143,19 +156,20 @@ public class CampaignStructuralContextBuilder {
return ""; return "";
} }
private ArcSummary toArcSummary(Arc arc) { private ArcSummary toArcSummary(Arc arc, Map<String, String> enemyLabelById) {
List<ChapterSummary> chapters = chapterRepository.findByArcId(arc.getId()).stream() List<ChapterSummary> chapters = chapterRepository.findByArcId(arc.getId()).stream()
.sorted(Comparator.comparingInt(Chapter::getOrder)) .sorted(Comparator.comparingInt(Chapter::getOrder))
.map(this::toChapterSummary) .map(chapter -> toChapterSummary(chapter, enemyLabelById))
.collect(Collectors.toList()); .collect(Collectors.toList());
return new ArcSummary( return new ArcSummary(
arc.getName(), arc.getName(),
arc.getDescription(), arc.getDescription(),
arc.getType() == ArcType.HUB,
countImages(arc.getIllustrationImageIds()), countImages(arc.getIllustrationImageIds()),
chapters); chapters);
} }
private ChapterSummary toChapterSummary(Chapter chapter) { private ChapterSummary toChapterSummary(Chapter chapter, Map<String, String> enemyLabelById) {
List<Scene> scenes = sceneRepository.findByChapterId(chapter.getId()).stream() List<Scene> scenes = sceneRepository.findByChapterId(chapter.getId()).stream()
.sorted(Comparator.comparingInt(Scene::getOrder)) .sorted(Comparator.comparingInt(Scene::getOrder))
.toList(); .toList();
@@ -166,7 +180,7 @@ public class CampaignStructuralContextBuilder {
.collect(Collectors.toMap(Scene::getId, Scene::getName)); .collect(Collectors.toMap(Scene::getId, Scene::getName));
List<SceneSummary> summaries = scenes.stream() List<SceneSummary> summaries = scenes.stream()
.map(s -> toSceneSummary(s, nameById)) .map(s -> toSceneSummary(s, nameById, enemyLabelById))
.collect(Collectors.toList()); .collect(Collectors.toList());
return new ChapterSummary( return new ChapterSummary(
@@ -176,7 +190,8 @@ public class CampaignStructuralContextBuilder {
summaries); summaries);
} }
private SceneSummary toSceneSummary(Scene scene, Map<String, String> nameById) { private SceneSummary toSceneSummary(
Scene scene, Map<String, String> nameById, Map<String, String> enemyLabelById) {
List<BranchHint> hints = scene.getBranches() == null List<BranchHint> hints = scene.getBranches() == null
? List.of() ? List.of()
: scene.getBranches().stream() : scene.getBranches().stream()
@@ -186,7 +201,7 @@ public class CampaignStructuralContextBuilder {
b.condition())) b.condition()))
.collect(Collectors.toList()); .collect(Collectors.toList());
List<RoomSummary> rooms = toRoomSummaries(scene); List<RoomSummary> rooms = toRoomSummaries(scene, enemyLabelById);
return new SceneSummary( return new SceneSummary(
scene.getName(), scene.getName(),
@@ -202,7 +217,7 @@ public class CampaignStructuralContextBuilder {
* connaît la structure du lieu (nom des pièces, ennemis, sorties) — c'est * connaît la structure du lieu (nom des pièces, ennemis, sorties) — c'est
* suffisant pour proposer de la narration ou anticiper les choix. * suffisant pour proposer de la narration ou anticiper les choix.
*/ */
private List<RoomSummary> toRoomSummaries(Scene scene) { private List<RoomSummary> toRoomSummaries(Scene scene, Map<String, String> enemyLabelById) {
if (scene.getRooms() == null || scene.getRooms().isEmpty()) return List.of(); if (scene.getRooms() == null || scene.getRooms().isEmpty()) return List.of();
Map<String, String> nameById = scene.getRooms().stream() Map<String, String> nameById = scene.getRooms().stream()
.collect(Collectors.toMap( .collect(Collectors.toMap(
@@ -219,11 +234,36 @@ public class CampaignStructuralContextBuilder {
nameById.getOrDefault(b.targetRoomId(), "(pièce inconnue)"), nameById.getOrDefault(b.targetRoomId(), "(pièce inconnue)"),
b.condition())) b.condition()))
.collect(Collectors.toList()); .collect(Collectors.toList());
return new RoomSummary(r.getName(), r.getFloor(), r.getDescription(), r.getEnemies(), hints); return new RoomSummary(
r.getName(), r.getFloor(), r.getDescription(),
roomEnemiesText(r, enemyLabelById), hints);
}) })
.collect(Collectors.toList()); .collect(Collectors.toList());
} }
/**
* Texte « ennemis » d'une pièce pour le prompt : fiches du bestiaire
* référencées (libellés résolus, IDs orphelins ignorés) suivies du texte
* libre. L'un ou l'autre peut être vide.
*/
private static String roomEnemiesText(
com.loremind.domain.campaigncontext.Room room, Map<String, String> enemyLabelById) {
String linked = room.getEnemyIds() == null ? "" : room.getEnemyIds().stream()
.map(enemyLabelById::get)
.filter(l -> l != null && !l.isBlank())
.collect(Collectors.joining(", "));
String freeText = room.getEnemies() == null ? "" : room.getEnemies().strip();
if (linked.isEmpty()) return freeText;
if (freeText.isEmpty()) return linked;
return linked + "" + freeText;
}
/** Libellé court d'une fiche du bestiaire : « Nom (niveau) » ou « Nom ». */
private static String enemyLabel(com.loremind.domain.campaigncontext.Enemy enemy) {
String level = enemy.getLevel() == null ? "" : enemy.getLevel().strip();
return level.isEmpty() ? enemy.getName() : enemy.getName() + " (" + level + ")";
}
/** Helper defensif : compte les illustrations attachees (null-safe). */ /** Helper defensif : compte les illustrations attachees (null-safe). */
private static int countImages(List<String> ids) { private static int countImages(List<String> ids) {
return ids == null ? 0 : ids.size(); return ids == null ? 0 : ids.size();

View File

@@ -8,6 +8,7 @@ import com.loremind.domain.campaigncontext.Scene;
import com.loremind.domain.campaigncontext.ports.ArcRepository; import com.loremind.domain.campaigncontext.ports.ArcRepository;
import com.loremind.domain.campaigncontext.ports.ChapterRepository; import com.loremind.domain.campaigncontext.ports.ChapterRepository;
import com.loremind.domain.campaigncontext.ports.CharacterRepository; import com.loremind.domain.campaigncontext.ports.CharacterRepository;
import com.loremind.domain.campaigncontext.ports.EnemyRepository;
import com.loremind.domain.campaigncontext.ports.NpcRepository; import com.loremind.domain.campaigncontext.ports.NpcRepository;
import com.loremind.domain.campaigncontext.ports.SceneRepository; import com.loremind.domain.campaigncontext.ports.SceneRepository;
import com.loremind.domain.generationcontext.NarrativeEntityContext; import com.loremind.domain.generationcontext.NarrativeEntityContext;
@@ -32,18 +33,21 @@ public class NarrativeEntityContextBuilder {
private final SceneRepository sceneRepository; private final SceneRepository sceneRepository;
private final CharacterRepository characterRepository; private final CharacterRepository characterRepository;
private final NpcRepository npcRepository; private final NpcRepository npcRepository;
private final EnemyRepository enemyRepository;
public NarrativeEntityContextBuilder( public NarrativeEntityContextBuilder(
ArcRepository arcRepository, ArcRepository arcRepository,
ChapterRepository chapterRepository, ChapterRepository chapterRepository,
SceneRepository sceneRepository, SceneRepository sceneRepository,
CharacterRepository characterRepository, CharacterRepository characterRepository,
NpcRepository npcRepository) { NpcRepository npcRepository,
EnemyRepository enemyRepository) {
this.arcRepository = arcRepository; this.arcRepository = arcRepository;
this.chapterRepository = chapterRepository; this.chapterRepository = chapterRepository;
this.sceneRepository = sceneRepository; this.sceneRepository = sceneRepository;
this.characterRepository = characterRepository; this.characterRepository = characterRepository;
this.npcRepository = npcRepository; this.npcRepository = npcRepository;
this.enemyRepository = enemyRepository;
} }
/** /**
@@ -124,10 +128,41 @@ public class NarrativeEntityContextBuilder {
putField(fields, "choicesConsequences", s.getChoicesConsequences()); putField(fields, "choicesConsequences", s.getChoicesConsequences());
putField(fields, "combatDifficulty", s.getCombatDifficulty()); putField(fields, "combatDifficulty", s.getCombatDifficulty());
putField(fields, "enemies", s.getEnemies()); putField(fields, "enemies", s.getEnemies());
putField(fields, "linkedEnemies", resolveLinkedEnemies(s));
putField(fields, "gmSecretNotes", s.getGmSecretNotes()); putField(fields, "gmSecretNotes", s.getGmSecretNotes());
return new NarrativeEntityContext("scene", s.getName(), fields); return new NarrativeEntityContext("scene", s.getName(), fields);
} }
/**
* Résout les fiches du bestiaire référencées par la scène en une ligne par
* ennemi : « Nom (niveau) — champ: valeur ; … ». Valeurs tronquées : le
* contexte focus doit camper la rencontre, pas embarquer la fiche complète.
* Les IDs orphelins (fiche supprimée) sont ignorés silencieusement.
*/
private String resolveLinkedEnemies(Scene s) {
if (s.getEnemyIds() == null || s.getEnemyIds().isEmpty()) return "";
StringBuilder sb = new StringBuilder();
for (String enemyId : s.getEnemyIds()) {
enemyRepository.findById(enemyId).ifPresent(e -> {
if (sb.length() > 0) sb.append("\n");
sb.append("- ").append(e.getName());
if (e.getLevel() != null && !e.getLevel().isBlank()) {
sb.append(" (").append(e.getLevel().trim()).append(")");
}
String stats = e.getValues().entrySet().stream()
.filter(en -> en.getValue() != null && !en.getValue().isBlank())
.map(en -> en.getKey() + ": " + truncate(en.getValue().trim(), 100))
.collect(java.util.stream.Collectors.joining(" ; "));
if (!stats.isEmpty()) sb.append("").append(stats);
});
}
return sb.toString();
}
private static String truncate(String value, int maxLen) {
return value.length() <= maxLen ? value : value.substring(0, maxLen - 1).stripTrailing() + "";
}
private NarrativeEntityContext fromCharacter(Character c) { private NarrativeEntityContext fromCharacter(Character c) {
Map<String, String> fields = new LinkedHashMap<>(); Map<String, String> fields = new LinkedHashMap<>();
if (c.getValues() != null) { if (c.getValues() != null) {

View File

@@ -0,0 +1,70 @@
package com.loremind.domain.campaigncontext;
import lombok.Builder;
import lombok.Data;
import java.time.LocalDateTime;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* Fiche d'ennemi (monstre/créature) d'une campagne — le bestiaire du MJ.
* <p>
* Même principe de templating que {@link Npc} : champs universels hard-codés
* (nom, niveau, dossier, portrait, bandeau) + champs pilotés par le template
* ENNEMI du GameSystem ({@code GameSystem.enemyTemplate} : CA, PV, attaques…).
* Classement libre par dossier (« Démons », « Humanoïdes »…).
*/
@Data
@Builder
public class Enemy {
private String id;
private String name;
/** Niveau / FP / dangerosité — texte libre (« 5 », « FP 8 », « Boss »). Nullable. */
private String level;
/** Dossier de classement (texte libre). Null = non classé. */
private String folder;
/** ID de l'image portrait (champ universel hard-codé). Nullable. */
private String portraitImageId;
/** ID de l'image header/bannière (champ universel hard-codé). Nullable. */
private String headerImageId;
/** Valeurs TEXT/NUMBER du template ennemi. Jamais null après construction. */
private Map<String, String> values;
/** Valeurs IMAGE du template ennemi (listes d'IDs ordonnées par champ). Jamais null. */
private Map<String, List<String>> imageValues;
/** Valeurs KEY_VALUE_LIST : fieldName -> label -> value. Jamais null. */
private Map<String, Map<String, String>> keyValueValues;
/** Référence vers la Campaign parente (cross-aggregate via ID). */
private String campaignId;
/** Ordre d'affichage dans la liste. */
private int order;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
public Map<String, String> getValues() {
if (values == null) values = new HashMap<>();
return values;
}
public Map<String, List<String>> getImageValues() {
if (imageValues == null) imageValues = new HashMap<>();
return imageValues;
}
public Map<String, Map<String, String>> getKeyValueValues() {
if (keyValueValues == null) keyValueValues = new HashMap<>();
return keyValueValues;
}
}

View File

@@ -17,4 +17,6 @@ public class NotebookMessage {
private String role; private String role;
private String content; private String content;
private LocalDateTime createdAt; private LocalDateTime createdAt;
/** Null = conversation active ; sinon horodatage du « vider » (lot d'archive). */
private LocalDateTime archivedAt;
} }

View File

@@ -38,6 +38,13 @@ public class Room {
/** Énemis, créatures, boss éventuels (markdown libre). */ /** Énemis, créatures, boss éventuels (markdown libre). */
private String enemies; private String enemies;
/**
* IDs des fiches du bestiaire ({@link Enemy}) présentes dans la pièce
* (weak refs). Complète le texte libre {@code enemies}, comme sur Scene.
*/
@Builder.Default
private List<String> enemyIds = new ArrayList<>();
/** Loot / récompenses présentes dans la pièce. */ /** Loot / récompenses présentes dans la pièce. */
private String loot; private String loot;

View File

@@ -40,7 +40,15 @@ public class Scene {
// === Combat ou rencontre === // === Combat ou rencontre ===
private String combatDifficulty; // Difficulté estimée private String combatDifficulty; // Difficulté estimée
private String enemies; // Liste des ennemis et créatures private String enemies; // Liste des ennemis et créatures (texte libre)
/**
* IDs des fiches du bestiaire ({@link Enemy}) engagées dans cette rencontre
* (weak cross-aggregate references). Complète le texte libre `enemies` :
* l'utilisateur peut référencer ses fiches, ou tout écrire à la main, ou les deux.
*/
@Builder.Default
private List<String> enemyIds = new ArrayList<>();
/** /**
* IDs des pages du Lore associées à cette scène (weak cross-context references). * IDs des pages du Lore associées à cette scène (weak cross-context references).

View File

@@ -19,4 +19,7 @@ public interface CharacterRepository {
void deleteById(String id); void deleteById(String id);
boolean existsById(String id); boolean existsById(String id);
/** Recherche par nom (insensible à la casse) — alimente la recherche globale. */
List<Character> searchByName(String query);
} }

View File

@@ -0,0 +1,23 @@
package com.loremind.domain.campaigncontext.ports;
import com.loremind.domain.campaigncontext.Enemy;
import java.util.List;
import java.util.Optional;
/**
* Port de sortie pour la persistance des fiches d'ennemis (bestiaire de campagne).
*/
public interface EnemyRepository {
Enemy save(Enemy enemy);
Optional<Enemy> findById(String id);
List<Enemy> findByCampaignId(String campaignId);
void deleteById(String id);
/** Recherche par nom (insensible à la casse) — alimente la recherche globale. */
List<Enemy> searchByName(String query);
}

View File

@@ -19,4 +19,7 @@ public interface ItemCatalogRepository {
void deleteById(String id); void deleteById(String id);
boolean existsById(String id); boolean existsById(String id);
/** Recherche par nom (insensible à la casse) — alimente la recherche globale. */
List<ItemCatalog> searchByName(String query);
} }

View File

@@ -28,5 +28,10 @@ public interface NotebookRepository {
// --- Messages (conversation) --- // --- Messages (conversation) ---
NotebookMessage saveMessage(NotebookMessage message); NotebookMessage saveMessage(NotebookMessage message);
/** Messages de la conversation ACTIVE (les archives sont exclues). */
List<NotebookMessage> findMessagesByNotebookId(String notebookId); List<NotebookMessage> findMessagesByNotebookId(String notebookId);
/** « Vider » : archive le fil actif en un lot horodaté (rien n'est supprimé). */
void archiveMessagesByNotebookId(String notebookId);
/** Messages archivés, chronologiques (regroupables par {@code archivedAt}). */
List<NotebookMessage> findArchivedMessagesByNotebookId(String notebookId);
} }

View File

@@ -19,4 +19,7 @@ public interface NpcRepository {
void deleteById(String id); void deleteById(String id);
boolean existsById(String id); boolean existsById(String id);
/** Recherche par nom (insensible à la casse) — alimente la recherche globale. */
List<Npc> searchByName(String query);
} }

View File

@@ -19,4 +19,7 @@ public interface RandomTableRepository {
void deleteById(String id); void deleteById(String id);
boolean existsById(String id); boolean existsById(String id);
/** Recherche par nom (insensible à la casse) — alimente la recherche globale. */
List<RandomTable> searchByName(String query);
} }

View File

@@ -50,6 +50,14 @@ public class GameSystem {
*/ */
private List<TemplateField> npcTemplate; private List<TemplateField> npcTemplate;
/**
* Template de fiche ENNEMI (monstres/créatures du bestiaire de campagne).
* Mêmes règles que {@link #characterTemplate} — distinct du template PNJ :
* un ennemi porte des stats de combat (CA, PV, attaques…), pas une
* caractérisation narrative.
*/
private List<TemplateField> enemyTemplate;
/** Auteur déclaré — futur marketplace. Nullable. */ /** Auteur déclaré — futur marketplace. Nullable. */
private String author; private String author;
@@ -98,6 +106,10 @@ public class GameSystem {
npcTemplate = validateAndCopy(fields); npcTemplate = validateAndCopy(fields);
} }
public void replaceEnemyTemplate(List<TemplateField> fields) {
enemyTemplate = validateAndCopy(fields);
}
// --- Helpers privés ---------------------------------------------------- // --- Helpers privés ----------------------------------------------------
private static List<TemplateField> appendField(List<TemplateField> current, TemplateField field) { private static List<TemplateField> appendField(List<TemplateField> current, TemplateField field) {

View File

@@ -52,11 +52,15 @@ public record CampaignStructuralContext(
/** /**
* Résumé d'un arc : nom + description courte + ses chapitres. * Résumé d'un arc : nom + description courte + ses chapitres.
* *
* @param hub true si l'arc est de type HUB : ses chapitres sont des
* « quêtes » parallèles (vocabulaire UI). L'IA doit le savoir
* pour parler de quêtes et cibler le bon arc.
* @param illustrationCount Nombre d'illustrations attachees a cet arc (pour hint dans le prompt IA). * @param illustrationCount Nombre d'illustrations attachees a cet arc (pour hint dans le prompt IA).
*/ */
public record ArcSummary( public record ArcSummary(
String name, String name,
String description, String description,
boolean hub,
int illustrationCount, int illustrationCount,
List<ChapterSummary> chapters) { List<ChapterSummary> chapters) {
} }

View File

@@ -252,9 +252,15 @@ public class BrainChatPayloadBuilder {
ArcSummary::name, ArcSummary::name,
ArcSummary::description, ArcSummary::description,
ArcSummary::illustrationCount, ArcSummary::illustrationCount,
(map, arc) -> map.put("chapters", arc.chapters().stream() (map, arc) -> {
// Vocabulaire UI : les chapitres d'un arc HUB sont des « quêtes ».
if (arc.hub()) {
map.put("arc_type", "HUB");
}
map.put("chapters", arc.chapters().stream()
.map(this::chapterSummaryToMap) .map(this::chapterSummaryToMap)
.collect(Collectors.toList()))); .collect(Collectors.toList()));
});
} }
private Map<String, Object> chapterSummaryToMap(ChapterSummary c) { private Map<String, Object> chapterSummaryToMap(ChapterSummary c) {

View File

@@ -0,0 +1,88 @@
package com.loremind.infrastructure.persistence.entity;
import com.loremind.infrastructure.persistence.converter.StringListMapJsonConverter;
import com.loremind.infrastructure.persistence.converter.StringMapJsonConverter;
import com.loremind.infrastructure.persistence.converter.StringMapMapJsonConverter;
import jakarta.persistence.*;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* Entité JPA des fiches d'ennemis (bestiaire). Mêmes règles que NpcJpaEntity.
*/
@Entity
@Table(name = "enemies", indexes = {
@Index(name = "idx_enemies_campaign_id", columnList = "campaign_id")
})
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class EnemyJpaEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private String name;
/** Niveau / FP — texte libre. Nullable. */
@Column(name = "level")
private String level;
/** Dossier de classement (« Démons », « Humanoïdes »…). Nullable = non classé. */
@Column(name = "folder")
private String folder;
@Column(name = "portrait_image_id")
private String portraitImageId;
@Column(name = "header_image_id")
private String headerImageId;
@Convert(converter = StringMapJsonConverter.class)
@Column(name = "field_values", columnDefinition = "TEXT")
private Map<String, String> values;
@Convert(converter = StringListMapJsonConverter.class)
@Column(name = "image_values", columnDefinition = "TEXT")
private Map<String, List<String>> imageValues;
@Convert(converter = StringMapMapJsonConverter.class)
@Column(name = "key_value_values", columnDefinition = "TEXT")
private Map<String, Map<String, String>> keyValueValues;
@Column(name = "campaign_id", nullable = false)
private Long campaignId;
@Column(name = "\"order\"", nullable = false)
private int order;
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
@Column(name = "updated_at", nullable = false)
private LocalDateTime updatedAt;
@PrePersist
protected void onCreate() {
createdAt = LocalDateTime.now();
updatedAt = LocalDateTime.now();
if (values == null) values = new HashMap<>();
if (imageValues == null) imageValues = new HashMap<>();
if (keyValueValues == null) keyValueValues = new HashMap<>();
}
@PreUpdate
protected void onUpdate() {
updatedAt = LocalDateTime.now();
}
}

View File

@@ -46,6 +46,11 @@ public class GameSystemJpaEntity {
@Column(name = "npc_template", columnDefinition = "TEXT") @Column(name = "npc_template", columnDefinition = "TEXT")
private List<TemplateField> npcTemplate; private List<TemplateField> npcTemplate;
/** Template ENNEMI (bestiaire) serialise en JSON. */
@Convert(converter = TemplateFieldListJsonConverter.class)
@Column(name = "enemy_template", columnDefinition = "TEXT")
private List<TemplateField> enemyTemplate;
@Column @Column
private String author; private String author;
@@ -64,6 +69,7 @@ public class GameSystemJpaEntity {
updatedAt = LocalDateTime.now(); updatedAt = LocalDateTime.now();
if (characterTemplate == null) characterTemplate = new ArrayList<>(); if (characterTemplate == null) characterTemplate = new ArrayList<>();
if (npcTemplate == null) npcTemplate = new ArrayList<>(); if (npcTemplate == null) npcTemplate = new ArrayList<>();
if (enemyTemplate == null) enemyTemplate = new ArrayList<>();
} }
@PreUpdate @PreUpdate

View File

@@ -34,6 +34,14 @@ public class NotebookMessageJpaEntity {
@Column(name = "created_at", nullable = false, updatable = false) @Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt; private LocalDateTime createdAt;
/**
* Null = message de la conversation ACTIVE. Non-null = message archivé lors
* d'un « vider la conversation » ; tous les messages d'un même clear portent
* le même horodatage, qui sert d'identifiant de lot d'archive.
*/
@Column(name = "archived_at")
private LocalDateTime archivedAt;
@PrePersist @PrePersist
protected void onCreate() { protected void onCreate() {
if (createdAt == null) createdAt = LocalDateTime.now(); if (createdAt == null) createdAt = LocalDateTime.now();

View File

@@ -75,6 +75,12 @@ public class SceneJpaEntity {
@Column(columnDefinition = "TEXT") @Column(columnDefinition = "TEXT")
private String enemies; private String enemies;
/** IDs des fiches du bestiaire liées à la rencontre (JSON, weak refs). */
@Column(name = "enemy_ids", columnDefinition = "TEXT")
@Convert(converter = StringListJsonConverter.class)
@Builder.Default
private List<String> enemyIds = new ArrayList<>();
@Column(name = "related_page_ids", columnDefinition = "TEXT") @Column(name = "related_page_ids", columnDefinition = "TEXT")
@Convert(converter = StringListJsonConverter.class) @Convert(converter = StringListJsonConverter.class)
@Builder.Default @Builder.Default

View File

@@ -10,4 +10,7 @@ import java.util.List;
public interface CharacterJpaRepository extends JpaRepository<CharacterJpaEntity, Long> { public interface CharacterJpaRepository extends JpaRepository<CharacterJpaEntity, Long> {
List<CharacterJpaEntity> findByPlaythroughIdOrderByOrderAsc(Long playthroughId); List<CharacterJpaEntity> findByPlaythroughIdOrderByOrderAsc(Long playthroughId);
/** Recherche globale : bornée pour ne jamais inonder la palette de résultats. */
List<CharacterJpaEntity> findTop20ByNameContainingIgnoreCaseOrderByNameAsc(String name);
} }

View File

@@ -0,0 +1,16 @@
package com.loremind.infrastructure.persistence.jpa;
import com.loremind.infrastructure.persistence.entity.EnemyJpaEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.List;
@Repository
public interface EnemyJpaRepository extends JpaRepository<EnemyJpaEntity, Long> {
List<EnemyJpaEntity> findByCampaignIdOrderByOrderAsc(Long campaignId);
/** Recherche globale : bornée pour ne jamais inonder la palette de résultats. */
List<EnemyJpaEntity> findTop20ByNameContainingIgnoreCaseOrderByNameAsc(String name);
}

View File

@@ -10,4 +10,7 @@ import java.util.List;
public interface ItemCatalogJpaRepository extends JpaRepository<ItemCatalogJpaEntity, Long> { public interface ItemCatalogJpaRepository extends JpaRepository<ItemCatalogJpaEntity, Long> {
List<ItemCatalogJpaEntity> findByCampaignIdOrderByOrderAsc(Long campaignId); List<ItemCatalogJpaEntity> findByCampaignIdOrderByOrderAsc(Long campaignId);
/** Recherche globale : bornée pour ne jamais inonder la palette de résultats. */
List<ItemCatalogJpaEntity> findTop20ByNameContainingIgnoreCaseOrderByNameAsc(String name);
} }

View File

@@ -2,12 +2,27 @@ package com.loremind.infrastructure.persistence.jpa;
import com.loremind.infrastructure.persistence.entity.NotebookMessageJpaEntity; import com.loremind.infrastructure.persistence.entity.NotebookMessageJpaEntity;
import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository; import org.springframework.stereotype.Repository;
import java.time.LocalDateTime;
import java.util.List; import java.util.List;
@Repository @Repository
public interface NotebookMessageJpaRepository extends JpaRepository<NotebookMessageJpaEntity, Long> { public interface NotebookMessageJpaRepository extends JpaRepository<NotebookMessageJpaEntity, Long> {
List<NotebookMessageJpaEntity> findByNotebookIdOrderByCreatedAtAsc(Long notebookId); /** Messages de la conversation ACTIVE (les archives sont exclues). */
List<NotebookMessageJpaEntity> findByNotebookIdAndArchivedAtIsNullOrderByCreatedAtAsc(Long notebookId);
/** Messages archivés (tous lots confondus, l'appelant regroupe par archivedAt). */
List<NotebookMessageJpaEntity> findByNotebookIdAndArchivedAtIsNotNullOrderByCreatedAtAsc(Long notebookId);
void deleteByNotebookId(Long notebookId); void deleteByNotebookId(Long notebookId);
/** « Vider la conversation » : archive le fil actif en un lot horodaté. */
@Modifying
@Query("update NotebookMessageJpaEntity m set m.archivedAt = :now "
+ "where m.notebookId = :notebookId and m.archivedAt is null")
int archiveActiveMessages(@Param("notebookId") Long notebookId, @Param("now") LocalDateTime now);
} }

View File

@@ -10,4 +10,7 @@ import java.util.List;
public interface NpcJpaRepository extends JpaRepository<NpcJpaEntity, Long> { public interface NpcJpaRepository extends JpaRepository<NpcJpaEntity, Long> {
List<NpcJpaEntity> findByCampaignIdOrderByOrderAsc(Long campaignId); List<NpcJpaEntity> findByCampaignIdOrderByOrderAsc(Long campaignId);
/** Recherche globale : bornée pour ne jamais inonder la palette de résultats. */
List<NpcJpaEntity> findTop20ByNameContainingIgnoreCaseOrderByNameAsc(String name);
} }

View File

@@ -10,4 +10,7 @@ import java.util.List;
public interface RandomTableJpaRepository extends JpaRepository<RandomTableJpaEntity, Long> { public interface RandomTableJpaRepository extends JpaRepository<RandomTableJpaEntity, Long> {
List<RandomTableJpaEntity> findByCampaignIdOrderByOrderAsc(Long campaignId); List<RandomTableJpaEntity> findByCampaignIdOrderByOrderAsc(Long campaignId);
/** Recherche globale : bornée pour ne jamais inonder la palette de résultats. */
List<RandomTableJpaEntity> findTop20ByNameContainingIgnoreCaseOrderByNameAsc(String name);
} }

View File

@@ -49,6 +49,13 @@ public class PostgresCharacterRepository implements CharacterRepository {
return jpaRepository.existsById(Long.parseLong(id)); return jpaRepository.existsById(Long.parseLong(id));
} }
@Override
public List<Character> searchByName(String query) {
return jpaRepository.findTop20ByNameContainingIgnoreCaseOrderByNameAsc(query).stream()
.map(this::toDomainEntity)
.collect(Collectors.toList());
}
private Character toDomainEntity(CharacterJpaEntity e) { private Character toDomainEntity(CharacterJpaEntity e) {
return Character.builder() return Character.builder()
.id(e.getId().toString()) .id(e.getId().toString())

View File

@@ -0,0 +1,87 @@
package com.loremind.infrastructure.persistence.postgres;
import com.loremind.domain.campaigncontext.Enemy;
import com.loremind.domain.campaigncontext.ports.EnemyRepository;
import com.loremind.infrastructure.persistence.entity.EnemyJpaEntity;
import com.loremind.infrastructure.persistence.jpa.EnemyJpaRepository;
import org.springframework.stereotype.Repository;
import java.util.HashMap;
import java.util.List;
import java.util.Optional;
import java.util.stream.Collectors;
@Repository
public class PostgresEnemyRepository implements EnemyRepository {
private final EnemyJpaRepository jpaRepository;
public PostgresEnemyRepository(EnemyJpaRepository jpaRepository) {
this.jpaRepository = jpaRepository;
}
@Override
public Enemy save(Enemy enemy) {
return toDomainEntity(jpaRepository.save(toJpaEntity(enemy)));
}
@Override
public Optional<Enemy> findById(String id) {
return jpaRepository.findById(Long.parseLong(id)).map(this::toDomainEntity);
}
@Override
public List<Enemy> findByCampaignId(String campaignId) {
return jpaRepository.findByCampaignIdOrderByOrderAsc(Long.parseLong(campaignId)).stream()
.map(this::toDomainEntity)
.collect(Collectors.toList());
}
@Override
public void deleteById(String id) {
jpaRepository.deleteById(Long.parseLong(id));
}
@Override
public List<Enemy> searchByName(String query) {
return jpaRepository.findTop20ByNameContainingIgnoreCaseOrderByNameAsc(query).stream()
.map(this::toDomainEntity)
.collect(Collectors.toList());
}
private Enemy toDomainEntity(EnemyJpaEntity e) {
return Enemy.builder()
.id(e.getId().toString())
.name(e.getName())
.level(e.getLevel())
.folder(e.getFolder())
.portraitImageId(e.getPortraitImageId())
.headerImageId(e.getHeaderImageId())
.values(e.getValues() != null ? new HashMap<>(e.getValues()) : new HashMap<>())
.imageValues(e.getImageValues() != null ? new HashMap<>(e.getImageValues()) : new HashMap<>())
.keyValueValues(e.getKeyValueValues() != null ? new HashMap<>(e.getKeyValueValues()) : new HashMap<>())
.campaignId(e.getCampaignId().toString())
.order(e.getOrder())
.createdAt(e.getCreatedAt())
.updatedAt(e.getUpdatedAt())
.build();
}
private EnemyJpaEntity toJpaEntity(Enemy n) {
return EnemyJpaEntity.builder()
.id(n.getId() != null ? Long.parseLong(n.getId()) : null)
.name(n.getName())
.level(n.getLevel())
.folder(n.getFolder())
.portraitImageId(n.getPortraitImageId())
.headerImageId(n.getHeaderImageId())
.values(n.getValues() != null ? new HashMap<>(n.getValues()) : new HashMap<>())
.imageValues(n.getImageValues() != null ? new HashMap<>(n.getImageValues()) : new HashMap<>())
.keyValueValues(n.getKeyValueValues() != null ? new HashMap<>(n.getKeyValueValues()) : new HashMap<>())
.campaignId(Long.parseLong(n.getCampaignId()))
.order(n.getOrder())
.createdAt(n.getCreatedAt())
.updatedAt(n.getUpdatedAt())
.build();
}
}

View File

@@ -67,6 +67,9 @@ public class PostgresGameSystemRepository implements GameSystemRepository {
.npcTemplate(e.getNpcTemplate() != null .npcTemplate(e.getNpcTemplate() != null
? new java.util.ArrayList<>(e.getNpcTemplate()) ? new java.util.ArrayList<>(e.getNpcTemplate())
: new java.util.ArrayList<>()) : new java.util.ArrayList<>())
.enemyTemplate(e.getEnemyTemplate() != null
? new java.util.ArrayList<>(e.getEnemyTemplate())
: new java.util.ArrayList<>())
.author(e.getAuthor()) .author(e.getAuthor())
.isPublic(e.isPublic()) .isPublic(e.isPublic())
.createdAt(e.getCreatedAt()) .createdAt(e.getCreatedAt())
@@ -87,6 +90,9 @@ public class PostgresGameSystemRepository implements GameSystemRepository {
.npcTemplate(g.getNpcTemplate() != null .npcTemplate(g.getNpcTemplate() != null
? new java.util.ArrayList<>(g.getNpcTemplate()) ? new java.util.ArrayList<>(g.getNpcTemplate())
: new java.util.ArrayList<>()) : new java.util.ArrayList<>())
.enemyTemplate(g.getEnemyTemplate() != null
? new java.util.ArrayList<>(g.getEnemyTemplate())
: new java.util.ArrayList<>())
.author(g.getAuthor()) .author(g.getAuthor())
.isPublic(g.isPublic()) .isPublic(g.isPublic())
.createdAt(g.getCreatedAt()) .createdAt(g.getCreatedAt())

View File

@@ -77,6 +77,14 @@ public class PostgresItemCatalogRepository implements ItemCatalogRepository {
return jpaRepository.existsById(Long.parseLong(id)); return jpaRepository.existsById(Long.parseLong(id));
} }
@Override
@Transactional(readOnly = true)
public List<ItemCatalog> searchByName(String query) {
return jpaRepository.findTop20ByNameContainingIgnoreCaseOrderByNameAsc(query).stream()
.map(this::toDomainEntity)
.collect(Collectors.toList());
}
private ItemCatalog toDomainEntity(ItemCatalogJpaEntity e) { private ItemCatalog toDomainEntity(ItemCatalogJpaEntity e) {
List<CatalogItem> items = e.getItems().stream() List<CatalogItem> items = e.getItems().stream()
.map(c -> CatalogItem.builder() .map(c -> CatalogItem.builder()

View File

@@ -115,7 +115,19 @@ public class PostgresNotebookRepository implements NotebookRepository {
@Override @Override
public List<NotebookMessage> findMessagesByNotebookId(String notebookId) { public List<NotebookMessage> findMessagesByNotebookId(String notebookId) {
return messageJpa.findByNotebookIdOrderByCreatedAtAsc(Long.parseLong(notebookId)).stream() return messageJpa.findByNotebookIdAndArchivedAtIsNullOrderByCreatedAtAsc(Long.parseLong(notebookId)).stream()
.map(this::toMessage).collect(Collectors.toList());
}
@Override
@Transactional
public void archiveMessagesByNotebookId(String notebookId) {
messageJpa.archiveActiveMessages(Long.parseLong(notebookId), java.time.LocalDateTime.now());
}
@Override
public List<NotebookMessage> findArchivedMessagesByNotebookId(String notebookId) {
return messageJpa.findByNotebookIdAndArchivedAtIsNotNullOrderByCreatedAtAsc(Long.parseLong(notebookId)).stream()
.map(this::toMessage).collect(Collectors.toList()); .map(this::toMessage).collect(Collectors.toList());
} }
@@ -150,6 +162,7 @@ public class PostgresNotebookRepository implements NotebookRepository {
.role(e.getRole()) .role(e.getRole())
.content(e.getContent()) .content(e.getContent())
.createdAt(e.getCreatedAt()) .createdAt(e.getCreatedAt())
.archivedAt(e.getArchivedAt())
.build(); .build();
} }
} }

View File

@@ -50,6 +50,13 @@ public class PostgresNpcRepository implements NpcRepository {
return jpaRepository.existsById(Long.parseLong(id)); return jpaRepository.existsById(Long.parseLong(id));
} }
@Override
public List<Npc> searchByName(String query) {
return jpaRepository.findTop20ByNameContainingIgnoreCaseOrderByNameAsc(query).stream()
.map(this::toDomainEntity)
.collect(Collectors.toList());
}
private Npc toDomainEntity(NpcJpaEntity e) { private Npc toDomainEntity(NpcJpaEntity e) {
return Npc.builder() return Npc.builder()
.id(e.getId().toString()) .id(e.getId().toString())

View File

@@ -81,6 +81,14 @@ public class PostgresRandomTableRepository implements RandomTableRepository {
return jpaRepository.existsById(Long.parseLong(id)); return jpaRepository.existsById(Long.parseLong(id));
} }
@Override
@Transactional(readOnly = true)
public List<RandomTable> searchByName(String query) {
return jpaRepository.findTop20ByNameContainingIgnoreCaseOrderByNameAsc(query).stream()
.map(this::toDomainEntity)
.collect(Collectors.toList());
}
private RandomTable toDomainEntity(RandomTableJpaEntity e) { private RandomTable toDomainEntity(RandomTableJpaEntity e) {
List<RandomTableEntry> entries = e.getEntries().stream() List<RandomTableEntry> entries = e.getEntries().stream()
.map(c -> RandomTableEntry.builder() .map(c -> RandomTableEntry.builder()

View File

@@ -80,6 +80,9 @@ public class PostgresSceneRepository implements SceneRepository {
.choicesConsequences(jpaEntity.getChoicesConsequences()) .choicesConsequences(jpaEntity.getChoicesConsequences())
.combatDifficulty(jpaEntity.getCombatDifficulty()) .combatDifficulty(jpaEntity.getCombatDifficulty())
.enemies(jpaEntity.getEnemies()) .enemies(jpaEntity.getEnemies())
.enemyIds(jpaEntity.getEnemyIds() != null
? new ArrayList<>(jpaEntity.getEnemyIds())
: new ArrayList<>())
.relatedPageIds(jpaEntity.getRelatedPageIds() != null .relatedPageIds(jpaEntity.getRelatedPageIds() != null
? new ArrayList<>(jpaEntity.getRelatedPageIds()) ? new ArrayList<>(jpaEntity.getRelatedPageIds())
: new ArrayList<>()) : new ArrayList<>())
@@ -117,6 +120,9 @@ public class PostgresSceneRepository implements SceneRepository {
.choicesConsequences(scene.getChoicesConsequences()) .choicesConsequences(scene.getChoicesConsequences())
.combatDifficulty(scene.getCombatDifficulty()) .combatDifficulty(scene.getCombatDifficulty())
.enemies(scene.getEnemies()) .enemies(scene.getEnemies())
.enemyIds(scene.getEnemyIds() != null
? new ArrayList<>(scene.getEnemyIds())
: new ArrayList<>())
.relatedPageIds(scene.getRelatedPageIds() != null .relatedPageIds(scene.getRelatedPageIds() != null
? new ArrayList<>(scene.getRelatedPageIds()) ? new ArrayList<>(scene.getRelatedPageIds())
: new ArrayList<>()) : new ArrayList<>())

View File

@@ -11,6 +11,7 @@ import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice; import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.context.request.async.AsyncRequestNotUsableException; import org.springframework.web.context.request.async.AsyncRequestNotUsableException;
import org.springframework.web.server.ResponseStatusException;
import java.util.LinkedHashMap; import java.util.LinkedHashMap;
import java.util.Map; import java.util.Map;
@@ -71,6 +72,21 @@ public class GlobalExceptionHandler {
)); ));
} }
/**
* Statut HTTP explicitement choisi par un controller via {@link ResponseStatusException}
* (ex: {@code NotebookController} -> 404 si notebook introuvable, 502 si Brain injoignable).
* <p>
* SANS ce handler, le fallback {@code @ExceptionHandler(Throwable.class)} ci-dessous
* interceptait ces exceptions et renvoyait 500 — ecrasant le statut voulu (le
* resolver natif de Spring est court-circuite des qu'un advice gere Throwable).
*/
@ExceptionHandler(ResponseStatusException.class)
public ResponseEntity<Map<String, String>> handleResponseStatus(ResponseStatusException ex) {
String reason = ex.getReason();
return ResponseEntity.status(ex.getStatusCode())
.body(Map.of("error", reason != null ? reason : ex.getStatusCode().toString()));
}
/** /**
* Client HTTP parti pendant une reponse asynchrone (SSE) : le navigateur a ferme * Client HTTP parti pendant une reponse asynchrone (SSE) : le navigateur a ferme
* la connexion (onglet ferme, proxy coupe...), la reponse n'est plus utilisable. * la connexion (onglet ferme, proxy coupe...), la reponse n'est plus utilisable.

View File

@@ -16,10 +16,13 @@ public class CharacterController {
private final CharacterService characterService; private final CharacterService characterService;
private final CharacterMapper characterMapper; private final CharacterMapper characterMapper;
private final com.loremind.domain.playcontext.ports.PlaythroughRepository playthroughRepository;
public CharacterController(CharacterService characterService, CharacterMapper characterMapper) { public CharacterController(CharacterService characterService, CharacterMapper characterMapper,
com.loremind.domain.playcontext.ports.PlaythroughRepository playthroughRepository) {
this.characterService = characterService; this.characterService = characterService;
this.characterMapper = characterMapper; this.characterMapper = characterMapper;
this.playthroughRepository = playthroughRepository;
} }
@PostMapping @PostMapping
@@ -43,6 +46,31 @@ public class CharacterController {
return ResponseEntity.ok(dtos); return ResponseEntity.ok(dtos);
} }
/**
* Recherche par nom — alimente la recherche globale (Ctrl+K). Le résultat est
* enrichi du campaignId (résolu via le Playthrough) pour que le front puisse
* construire la route /campaigns/{c}/playthroughs/{p}/characters/{id}.
*/
@GetMapping("/search")
public ResponseEntity<List<CharacterSearchDTO>> search(@RequestParam("q") String query) {
List<CharacterSearchDTO> out = characterService.searchCharacters(query).stream()
.map(c -> new CharacterSearchDTO(
c.getId(),
c.getName(),
c.getPlaythroughId(),
c.getPlaythroughId() != null
? playthroughRepository.findById(c.getPlaythroughId())
.map(com.loremind.domain.playcontext.Playthrough::getCampaignId)
.orElse(null)
: null))
.filter(r -> r.campaignId() != null) // PJ orphelin (legacy) : non navigable → exclu
.collect(Collectors.toList());
return ResponseEntity.ok(out);
}
/** Résultat de recherche d'un PJ, enrichi pour la navigation. */
public record CharacterSearchDTO(String id, String name, String playthroughId, String campaignId) {}
@PutMapping("/{id}") @PutMapping("/{id}")
public ResponseEntity<CharacterDTO> updateCharacter(@PathVariable String id, @RequestBody CharacterDTO dto) { public ResponseEntity<CharacterDTO> updateCharacter(@PathVariable String id, @RequestBody CharacterDTO dto) {
Character updated = characterService.updateCharacter(id, toData(dto, dto.getOrder())); Character updated = characterService.updateCharacter(id, toData(dto, dto.getOrder()));

View File

@@ -0,0 +1,79 @@
package com.loremind.infrastructure.web.controller;
import com.loremind.application.campaigncontext.EnemyService;
import com.loremind.domain.campaigncontext.Enemy;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
/**
* REST Controller des fiches d'ennemis (bestiaire de campagne).
* Réponses = domaine {@link Enemy} sérialisé tel quel (Lombok @Data) ;
* requêtes = record dédié (le domaine n'a pas de constructeur no-args).
*/
@RestController
@RequestMapping("/api/enemies")
public class EnemyController {
private final EnemyService enemyService;
public EnemyController(EnemyService enemyService) {
this.enemyService = enemyService;
}
@PostMapping
public ResponseEntity<Enemy> create(@RequestBody EnemyRequest req) {
return ResponseEntity.ok(enemyService.createEnemy(toData(req)));
}
@GetMapping("/{id}")
public ResponseEntity<Enemy> getById(@PathVariable String id) {
return enemyService.getEnemyById(id)
.map(ResponseEntity::ok)
.orElse(ResponseEntity.notFound().build());
}
@GetMapping("/campaign/{campaignId}")
public ResponseEntity<List<Enemy>> getByCampaign(@PathVariable String campaignId) {
return ResponseEntity.ok(enemyService.getEnemiesByCampaignId(campaignId));
}
/** Recherche par nom — alimente la recherche globale (Ctrl+K). */
@GetMapping("/search")
public ResponseEntity<List<Enemy>> search(@RequestParam("q") String query) {
return ResponseEntity.ok(enemyService.searchEnemies(query));
}
@PutMapping("/{id}")
public ResponseEntity<Enemy> update(@PathVariable String id, @RequestBody EnemyRequest req) {
return ResponseEntity.ok(enemyService.updateEnemy(id, toData(req)));
}
@DeleteMapping("/{id}")
public ResponseEntity<Void> delete(@PathVariable String id) {
enemyService.deleteEnemy(id);
return ResponseEntity.noContent().build();
}
private EnemyService.EnemyData toData(EnemyRequest req) {
return new EnemyService.EnemyData(
req.name(), req.level(), req.folder(),
req.portraitImageId(), req.headerImageId(),
req.values(), req.imageValues(), req.keyValueValues(),
req.campaignId(), req.order());
}
public record EnemyRequest(
String name,
String level,
String folder,
String portraitImageId,
String headerImageId,
Map<String, String> values,
Map<String, List<String>> imageValues,
Map<String, Map<String, String>> keyValueValues,
String campaignId,
Integer order) {}
}

View File

@@ -263,6 +263,7 @@ public class GameSystemController {
dto.getRulesMarkdown(), dto.getRulesMarkdown(),
toDomainFields(dto.getCharacterTemplate()), toDomainFields(dto.getCharacterTemplate()),
toDomainFields(dto.getNpcTemplate()), toDomainFields(dto.getNpcTemplate()),
toDomainFields(dto.getEnemyTemplate()),
dto.getAuthor(), dto.getAuthor(),
dto.isPublic() dto.isPublic()
); );

View File

@@ -58,6 +58,14 @@ public class ItemCatalogController {
return ResponseEntity.noContent().build(); return ResponseEntity.noContent().build();
} }
/** Recherche par nom — alimente la recherche globale (Ctrl+K). */
@GetMapping("/search")
public ResponseEntity<List<ItemCatalogDTO>> search(@RequestParam("q") String query) {
return ResponseEntity.ok(service.searchCatalogs(query).stream()
.map(mapper::toDTO)
.collect(java.util.stream.Collectors.toList()));
}
/** Génère une PROPOSITION de catalogue via l'IA (non persistée) — l'UI préremplit le formulaire. */ /** Génère une PROPOSITION de catalogue via l'IA (non persistée) — l'UI préremplit le formulaire. */
@PostMapping("/generate") @PostMapping("/generate")
public ResponseEntity<ItemCatalogDTO> generate(@RequestBody GenerateRequest req) { public ResponseEntity<ItemCatalogDTO> generate(@RequestBody GenerateRequest req) {

View File

@@ -105,6 +105,43 @@ public class NotebookController {
return ResponseEntity.noContent().build(); return ResponseEntity.noContent().build();
} }
// --- Conversation : vider (= archiver) et consulter les archives ---
/**
* « Vider la conversation » : le fil actif est ARCHIVÉ en un lot horodaté,
* jamais supprimé — consultable ensuite via {@link #listArchives}.
*/
@PostMapping("/{id}/chat/clear")
public ResponseEntity<Void> clearChat(@PathVariable String id) {
if (service.getNotebook(id).isEmpty()) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Notebook introuvable");
}
service.clearChat(id);
return ResponseEntity.noContent().build();
}
/** Archives de conversation, plus récentes d'abord : [{archivedAt, messages:[…]}]. */
@GetMapping("/{id}/chat/archives")
public ResponseEntity<List<Map<String, Object>>> listArchives(@PathVariable String id) {
var grouped = new java.util.TreeMap<java.time.LocalDateTime, List<Map<String, Object>>>(
java.util.Comparator.reverseOrder());
for (var m : service.getArchivedMessages(id)) {
grouped.computeIfAbsent(m.getArchivedAt(), k -> new java.util.ArrayList<>())
.add(Map.of(
"role", m.getRole(),
"content", m.getContent(),
"createdAt", m.getCreatedAt().toString()));
}
List<Map<String, Object>> out = new java.util.ArrayList<>();
grouped.forEach((archivedAt, messages) -> {
Map<String, Object> archive = new LinkedHashMap<>();
archive.put("archivedAt", archivedAt.toString());
archive.put("messages", messages);
out.add(archive);
});
return ResponseEntity.ok(out);
}
// --- Chat ancré streamé --- // --- Chat ancré streamé ---
@PostMapping(value = "/{id}/chat/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE) @PostMapping(value = "/{id}/chat/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
@@ -124,8 +161,26 @@ public class NotebookController {
List<NotebookChatStreamer.Msg> history = service.getMessages(id).stream() List<NotebookChatStreamer.Msg> history = service.getMessages(id).stream()
.map(m -> new NotebookChatStreamer.Msg(m.getRole(), m.getContent())) .map(m -> new NotebookChatStreamer.Msg(m.getRole(), m.getContent()))
.toList(); .toList();
List<String> sourceIds = service.readySourceIds(id); // Sélection de l'UI (cases cochées) : on ne garde que les sources qui
String context = service.buildContext(nb.getCampaignId()); // appartiennent bien à CE notebook et sont prêtes — un id étranger est
// ignoré. Limite le coût (ex. analyse approfondie sur 1 PDF au lieu de 5).
// Variable finale : elle est capturée par la lambda du taskExecutor.
List<String> readyIds = service.readySourceIds(id);
final List<String> sourceIds;
if (req.sourceIds() != null) {
var wanted = new java.util.HashSet<>(req.sourceIds());
sourceIds = readyIds.stream().filter(wanted::contains).toList();
} else {
sourceIds = readyIds;
}
// Contexte = brief de campagne + archives cochées en référence (le tout
// dans une variable finale : capturée par la lambda du taskExecutor).
String campaignContext = service.buildContext(nb.getCampaignId());
String archiveContext = service.buildArchiveContext(id, req.archiveIds());
final String context = archiveContext.isEmpty()
? campaignContext
: (campaignContext.isEmpty() ? archiveContext
: campaignContext + "\n\n" + archiveContext);
boolean deep = req.deep() != null && req.deep(); boolean deep = req.deep() != null && req.deep();
taskExecutor.execute(() -> { taskExecutor.execute(() -> {
@@ -220,5 +275,14 @@ public class NotebookController {
public record CreateRequest(String campaignId, String name) {} public record CreateRequest(String campaignId, String name) {}
public record RenameRequest(String name) {} public record RenameRequest(String name) {}
public record ChatRequest(String message, Boolean deep) {} /**
* @param sourceIds Optionnel : sous-ensemble de sources à utiliser pour ce tour
* (cases cochées dans l'UI). Null = toutes les sources prêtes.
* Toujours intersecté avec les sources du notebook (sécurité).
* @param archiveIds Optionnel : archives de conversation cochées comme RÉFÉRENCE
* (clés = archivedAt). Leur contenu est injecté dans le contexte
* du prompt — toujours résolu dans CE notebook (sécurité).
*/
public record ChatRequest(String message, Boolean deep, List<String> sourceIds,
List<String> archiveIds) {}
} }

View File

@@ -43,6 +43,15 @@ public class NpcController {
return ResponseEntity.ok(dtos); return ResponseEntity.ok(dtos);
} }
/** Recherche par nom — alimente la recherche globale (Ctrl+K). */
@GetMapping("/search")
public ResponseEntity<List<NpcDTO>> search(@RequestParam("q") String query) {
List<NpcDTO> dtos = npcService.searchNpcs(query).stream()
.map(npcMapper::toDTO)
.collect(Collectors.toList());
return ResponseEntity.ok(dtos);
}
/** PNJ de toutes les campagnes liées au Lore donné — alimente le graphe du Lore. */ /** PNJ de toutes les campagnes liées au Lore donné — alimente le graphe du Lore. */
@GetMapping("/lore/{loreId}") @GetMapping("/lore/{loreId}")
public ResponseEntity<List<NpcDTO>> getNpcsByLore(@PathVariable String loreId) { public ResponseEntity<List<NpcDTO>> getNpcsByLore(@PathVariable String loreId) {

View File

@@ -59,6 +59,14 @@ public class RandomTableController {
return ResponseEntity.noContent().build(); return ResponseEntity.noContent().build();
} }
/** Recherche par nom — alimente la recherche globale (Ctrl+K). */
@GetMapping("/search")
public ResponseEntity<List<RandomTableDTO>> search(@RequestParam("q") String query) {
return ResponseEntity.ok(service.searchTables(query).stream()
.map(mapper::toDTO)
.collect(java.util.stream.Collectors.toList()));
}
/** Génère une PROPOSITION de table via l'IA (non persistée) — l'UI préremplit le formulaire. */ /** Génère une PROPOSITION de table via l'IA (non persistée) — l'UI préremplit le formulaire. */
@PostMapping("/generate") @PostMapping("/generate")
public ResponseEntity<RandomTableDTO> generate(@RequestBody GenerateRequest req) { public ResponseEntity<RandomTableDTO> generate(@RequestBody GenerateRequest req) {

View File

@@ -16,6 +16,8 @@ public class RoomDTO {
private String name; private String name;
private String description; private String description;
private String enemies; private String enemies;
/** IDs des fiches du bestiaire présentes dans la pièce (weak refs). */
private List<String> enemyIds = new ArrayList<>();
private String loot; private String loot;
private String traps; private String traps;
private String gmNotes; private String gmNotes;

View File

@@ -30,6 +30,9 @@ public class SceneDTO {
private String combatDifficulty; private String combatDifficulty;
private String enemies; private String enemies;
/** IDs des fiches du bestiaire engagées dans la rencontre (weak refs). */
private List<String> enemyIds = new ArrayList<>();
/** IDs des pages du Lore liées (weak cross-context references). */ /** IDs des pages du Lore liées (weak cross-context references). */
private List<String> relatedPageIds = new ArrayList<>(); private List<String> relatedPageIds = new ArrayList<>();

View File

@@ -19,6 +19,7 @@ public class GameSystemDTO {
private String rulesMarkdown; private String rulesMarkdown;
private List<TemplateFieldDTO> characterTemplate = new ArrayList<>(); private List<TemplateFieldDTO> characterTemplate = new ArrayList<>();
private List<TemplateFieldDTO> npcTemplate = new ArrayList<>(); private List<TemplateFieldDTO> npcTemplate = new ArrayList<>();
private List<TemplateFieldDTO> enemyTemplate = new ArrayList<>();
private String author; private String author;
private boolean isPublic; private boolean isPublic;
} }

View File

@@ -27,6 +27,7 @@ public class GameSystemMapper {
dto.setRulesMarkdown(g.getRulesMarkdown()); dto.setRulesMarkdown(g.getRulesMarkdown());
dto.setCharacterTemplate(toDTOList(g.getCharacterTemplate())); dto.setCharacterTemplate(toDTOList(g.getCharacterTemplate()));
dto.setNpcTemplate(toDTOList(g.getNpcTemplate())); dto.setNpcTemplate(toDTOList(g.getNpcTemplate()));
dto.setEnemyTemplate(toDTOList(g.getEnemyTemplate()));
dto.setAuthor(g.getAuthor()); dto.setAuthor(g.getAuthor());
dto.setPublic(g.isPublic()); dto.setPublic(g.isPublic());
return dto; return dto;
@@ -41,6 +42,7 @@ public class GameSystemMapper {
.rulesMarkdown(dto.getRulesMarkdown()) .rulesMarkdown(dto.getRulesMarkdown())
.characterTemplate(toDomainList(dto.getCharacterTemplate())) .characterTemplate(toDomainList(dto.getCharacterTemplate()))
.npcTemplate(toDomainList(dto.getNpcTemplate())) .npcTemplate(toDomainList(dto.getNpcTemplate()))
.enemyTemplate(toDomainList(dto.getEnemyTemplate()))
.author(dto.getAuthor()) .author(dto.getAuthor())
.isPublic(dto.isPublic()) .isPublic(dto.isPublic())
.build(); .build();

View File

@@ -40,6 +40,9 @@ public class SceneMapper {
dto.setChoicesConsequences(scene.getChoicesConsequences()); dto.setChoicesConsequences(scene.getChoicesConsequences());
dto.setCombatDifficulty(scene.getCombatDifficulty()); dto.setCombatDifficulty(scene.getCombatDifficulty());
dto.setEnemies(scene.getEnemies()); dto.setEnemies(scene.getEnemies());
dto.setEnemyIds(scene.getEnemyIds() != null
? new ArrayList<>(scene.getEnemyIds())
: new ArrayList<>());
dto.setRelatedPageIds(scene.getRelatedPageIds() != null dto.setRelatedPageIds(scene.getRelatedPageIds() != null
? new ArrayList<>(scene.getRelatedPageIds()) ? new ArrayList<>(scene.getRelatedPageIds())
: new ArrayList<>()); : new ArrayList<>());
@@ -74,6 +77,9 @@ public class SceneMapper {
.choicesConsequences(dto.getChoicesConsequences()) .choicesConsequences(dto.getChoicesConsequences())
.combatDifficulty(dto.getCombatDifficulty()) .combatDifficulty(dto.getCombatDifficulty())
.enemies(dto.getEnemies()) .enemies(dto.getEnemies())
.enemyIds(dto.getEnemyIds() != null
? new ArrayList<>(dto.getEnemyIds())
: new ArrayList<>())
.relatedPageIds(dto.getRelatedPageIds() != null .relatedPageIds(dto.getRelatedPageIds() != null
? new ArrayList<>(dto.getRelatedPageIds()) ? new ArrayList<>(dto.getRelatedPageIds())
: new ArrayList<>()) : new ArrayList<>())
@@ -117,6 +123,9 @@ public class SceneMapper {
dto.setName(r.getName()); dto.setName(r.getName());
dto.setDescription(r.getDescription()); dto.setDescription(r.getDescription());
dto.setEnemies(r.getEnemies()); dto.setEnemies(r.getEnemies());
dto.setEnemyIds(r.getEnemyIds() != null
? new ArrayList<>(r.getEnemyIds())
: new ArrayList<>());
dto.setLoot(r.getLoot()); dto.setLoot(r.getLoot());
dto.setTraps(r.getTraps()); dto.setTraps(r.getTraps());
dto.setGmNotes(r.getGmNotes()); dto.setGmNotes(r.getGmNotes());
@@ -145,6 +154,9 @@ public class SceneMapper {
.name(d.getName()) .name(d.getName())
.description(d.getDescription()) .description(d.getDescription())
.enemies(d.getEnemies()) .enemies(d.getEnemies())
.enemyIds(d.getEnemyIds() != null
? new ArrayList<>(d.getEnemyIds())
: new ArrayList<>())
.loot(d.getLoot()) .loot(d.getLoot())
.traps(d.getTraps()) .traps(d.getTraps())
.gmNotes(d.getGmNotes()) .gmNotes(d.getGmNotes())

View File

@@ -11,6 +11,7 @@ import com.loremind.domain.campaigncontext.ports.ArcRepository;
import com.loremind.domain.campaigncontext.ports.CampaignRepository; import com.loremind.domain.campaigncontext.ports.CampaignRepository;
import com.loremind.domain.campaigncontext.ports.ChapterRepository; import com.loremind.domain.campaigncontext.ports.ChapterRepository;
import com.loremind.domain.campaigncontext.ports.CharacterRepository; import com.loremind.domain.campaigncontext.ports.CharacterRepository;
import com.loremind.domain.campaigncontext.ports.EnemyRepository;
import com.loremind.domain.campaigncontext.ports.NpcRepository; import com.loremind.domain.campaigncontext.ports.NpcRepository;
import com.loremind.domain.campaigncontext.ports.SceneRepository; import com.loremind.domain.campaigncontext.ports.SceneRepository;
import com.loremind.domain.generationcontext.CampaignStructuralContext; import com.loremind.domain.generationcontext.CampaignStructuralContext;
@@ -48,6 +49,8 @@ public class CampaignStructuralContextBuilderTest {
private CharacterRepository characterRepository; private CharacterRepository characterRepository;
@Mock @Mock
private NpcRepository npcRepository; private NpcRepository npcRepository;
@Mock
private EnemyRepository enemyRepository;
@InjectMocks @InjectMocks
private CampaignStructuralContextBuilder builder; private CampaignStructuralContextBuilder builder;

View File

@@ -37,6 +37,7 @@ class CampaignStructuralContextTest {
ArcSummary arc = new ArcSummary( ArcSummary arc = new ArcSummary(
"Acte I", "Acte I",
"Mise en place", "Mise en place",
false,
1, 1,
List.of(chapter)); List.of(chapter));
@@ -76,7 +77,7 @@ class CampaignStructuralContextTest {
@Test @Test
void illustrationCount_defaultsToZero_onAllSummaryTypes() { void illustrationCount_defaultsToZero_onAllSummaryTypes() {
ArcSummary arc = new ArcSummary("X", null, 0, List.of()); ArcSummary arc = new ArcSummary("X", null, false, 0, List.of());
ChapterSummary chapter = new ChapterSummary("X", null, 0, List.of()); ChapterSummary chapter = new ChapterSummary("X", null, 0, List.of());
SceneSummary scene = new SceneSummary("X", null, 0, List.of(), List.of()); SceneSummary scene = new SceneSummary("X", null, 0, List.of(), List.of());
@@ -92,6 +93,7 @@ class CampaignStructuralContextTest {
ArcSummary arc = new ArcSummary( ArcSummary arc = new ArcSummary(
"Acte I", "Acte I",
null, null,
false,
0, 0,
List.of( List.of(
new ChapterSummary("Ch1", null, 0, List.of()), new ChapterSummary("Ch1", null, 0, List.of()),

View File

@@ -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();
}
}

View File

@@ -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());
}
}

View File

@@ -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());
}
}

View File

@@ -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());
}
}

View File

@@ -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;
@@ -165,7 +174,7 @@ class BrainChatPayloadBuilderTest {
BranchHint branch = new BranchHint("fuite", "La poursuite", "HP < 50%"); BranchHint branch = new BranchHint("fuite", "La poursuite", "HP < 50%");
SceneSummary scene = new SceneSummary("L'auberge", "Rencontre tendue", 3, List.of(branch), List.of()); SceneSummary scene = new SceneSummary("L'auberge", "Rencontre tendue", 3, List.of(branch), List.of());
ChapterSummary chapter = new ChapterSummary("L'arrivee", "...", 0, List.of(scene)); ChapterSummary chapter = new ChapterSummary("L'arrivee", "...", 0, List.of(scene));
ArcSummary arc = new ArcSummary("Acte I", "Mise en place", 1, List.of(chapter)); ArcSummary arc = new ArcSummary("Acte I", "Mise en place", false, 1, List.of(chapter));
CampaignStructuralContext camp = new CampaignStructuralContext( CampaignStructuralContext camp = new CampaignStructuralContext(
"Les Ombres", "dark fantasy", List.of(arc), List.of(), List.of()); "Les Ombres", "dark fantasy", List.of(arc), List.of(), List.of());
ChatRequest req = ChatRequest.builder().messages(sampleMessages).campaignContext(camp).build(); ChatRequest req = ChatRequest.builder().messages(sampleMessages).campaignContext(camp).build();
@@ -198,7 +207,7 @@ class BrainChatPayloadBuilderTest {
@Test @Test
@SuppressWarnings("unchecked") @SuppressWarnings("unchecked")
void build_arcSummary_omitsIllustrationCount_whenZero() { void build_arcSummary_omitsIllustrationCount_whenZero() {
ArcSummary arc = new ArcSummary("A", "", 0, List.of()); ArcSummary arc = new ArcSummary("A", "", false, 0, List.of());
CampaignStructuralContext camp = new CampaignStructuralContext( CampaignStructuralContext camp = new CampaignStructuralContext(
"X", "", List.of(arc), List.of(), List.of()); "X", "", List.of(arc), List.of(), List.of());
ChatRequest req = ChatRequest.builder().messages(sampleMessages).campaignContext(camp).build(); ChatRequest req = ChatRequest.builder().messages(sampleMessages).campaignContext(camp).build();
@@ -215,7 +224,7 @@ class BrainChatPayloadBuilderTest {
void build_sceneSummary_omitsBranches_whenEmpty() { void build_sceneSummary_omitsBranches_whenEmpty() {
SceneSummary scene = new SceneSummary("S", "", 0, List.of(), List.of()); SceneSummary scene = new SceneSummary("S", "", 0, List.of(), List.of());
ChapterSummary chapter = new ChapterSummary("Ch", "", 0, List.of(scene)); ChapterSummary chapter = new ChapterSummary("Ch", "", 0, List.of(scene));
ArcSummary arc = new ArcSummary("A", "", 0, List.of(chapter)); ArcSummary arc = new ArcSummary("A", "", false, 0, List.of(chapter));
CampaignStructuralContext camp = new CampaignStructuralContext( CampaignStructuralContext camp = new CampaignStructuralContext(
"X", "", List.of(arc), List.of(), List.of()); "X", "", List.of(arc), List.of(), List.of());
ChatRequest req = ChatRequest.builder().messages(sampleMessages).campaignContext(camp).build(); ChatRequest req = ChatRequest.builder().messages(sampleMessages).campaignContext(camp).build();
@@ -234,7 +243,7 @@ class BrainChatPayloadBuilderTest {
BranchHint branch = new BranchHint("X", "Y", " "); BranchHint branch = new BranchHint("X", "Y", " ");
SceneSummary scene = new SceneSummary("S", "", 0, List.of(branch), List.of()); SceneSummary scene = new SceneSummary("S", "", 0, List.of(branch), List.of());
ChapterSummary chapter = new ChapterSummary("Ch", "", 0, List.of(scene)); ChapterSummary chapter = new ChapterSummary("Ch", "", 0, List.of(scene));
ArcSummary arc = new ArcSummary("A", "", 0, List.of(chapter)); ArcSummary arc = new ArcSummary("A", "", false, 0, List.of(chapter));
CampaignStructuralContext camp = new CampaignStructuralContext( CampaignStructuralContext camp = new CampaignStructuralContext(
"X", "", List.of(arc), List.of(), List.of()); "X", "", List.of(arc), List.of(), List.of());
ChatRequest req = ChatRequest.builder().messages(sampleMessages).campaignContext(camp).build(); ChatRequest req = ChatRequest.builder().messages(sampleMessages).campaignContext(camp).build();
@@ -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"));
}
} }

View File

@@ -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);
}
}

View File

@@ -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"));
}
}

View File

@@ -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());
}
}

View File

@@ -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 -> { });
}
}

View File

@@ -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());
}
}

View File

@@ -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");
}
}

View File

@@ -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"));
}
}

View File

@@ -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");
}
}

View File

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

View File

@@ -0,0 +1,130 @@
package com.loremind.infrastructure.web.controller;
import com.loremind.application.campaigncontext.CampaignAdaptService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.core.task.TaskExecutor;
import org.springframework.mock.web.MockMultipartFile;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.MvcResult;
import java.nio.charset.StandardCharsets;
import java.util.function.Consumer;
import static org.hamcrest.Matchers.containsString;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.doThrow;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.asyncDispatch;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.request;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
/**
* Tests d'integration pour {@link CampaignAdaptController} (conseil PDF -> campagne, SSE).
* <p>
* Le {@link CampaignAdaptService} est mocke : il appelle le Brain (indisponible en
* test). Le {@code TaskExecutor} ("applicationTaskExecutor") est mocke pour executer
* la tache de streaming EN LIGNE -> events SSE deterministes.
* <p>
* Signature de {@code adviseStreaming(campaignId, pdfBytes, filename, messagesJson,
* onToken, onComplete, onError)} : indices des callbacks -> onToken=4, onComplete=5,
* onError=6.
*/
@SpringBootTest
@AutoConfigureMockMvc
class CampaignAdaptControllerTest {
@Autowired private MockMvc mockMvc;
@MockBean private CampaignAdaptService campaignAdaptService;
@MockBean(name = "applicationTaskExecutor") private TaskExecutor taskExecutor;
@BeforeEach
void setUp() {
// Tache de streaming executee en ligne -> events SSE deterministes.
doAnswer(inv -> { ((Runnable) inv.getArgument(0)).run(); return null; })
.when(taskExecutor).execute(any(Runnable.class));
}
private MockMultipartFile pdf(byte[] content) {
return new MockMultipartFile("file", "aventure.pdf", "application/pdf", content);
}
private MvcResult perform(MockMultipartFile file) throws Exception {
return mockMvc.perform(multipart("/api/campaigns/{id}/adapt-pdf/stream", "camp-1")
.file(file)
.param("messages", "[]"))
.andExpect(request().asyncStarted())
.andReturn();
}
@Test
void adaptStream_streamsTokenThenDone() throws Exception {
// Le service mocke joue : 1 token -> fin.
doAnswer(inv -> {
Consumer<String> onToken = inv.getArgument(4);
Runnable onComplete = inv.getArgument(5);
onToken.accept("Conseil markdown");
onComplete.run();
return null;
}).when(campaignAdaptService).adviseStreaming(any(), any(), any(), any(), any(), any(), any());
MvcResult result = perform(pdf("%PDF-1.4 fake".getBytes(StandardCharsets.UTF_8)));
mockMvc.perform(asyncDispatch(result))
.andExpect(status().isOk())
.andExpect(content().string(containsString("Conseil markdown")))
.andExpect(content().string(containsString("done")));
}
@Test
void adaptStream_serviceInvokesError_emitsError() throws Exception {
doAnswer(inv -> {
Consumer<Throwable> onError = inv.getArgument(6);
onError.accept(new RuntimeException("brain HS"));
return null;
}).when(campaignAdaptService).adviseStreaming(any(), any(), any(), any(), any(), any(), any());
MvcResult result = perform(pdf("%PDF-1.4 fake".getBytes(StandardCharsets.UTF_8)));
mockMvc.perform(asyncDispatch(result))
.andExpect(status().isOk())
.andExpect(content().string(containsString("error")))
.andExpect(content().string(containsString("brain HS")));
}
@Test
void adaptStream_campaignMissing_emitsError() throws Exception {
// Le service leve IllegalArgumentException -> le controller catch et envoie "Campagne introuvable.".
doThrow(new IllegalArgumentException("Campagne introuvable : camp-1"))
.when(campaignAdaptService).adviseStreaming(any(), any(), any(), any(), any(), any(), any());
MvcResult result = perform(pdf("%PDF-1.4 fake".getBytes(StandardCharsets.UTF_8)));
mockMvc.perform(asyncDispatch(result))
.andExpect(status().isOk())
.andExpect(content().string(containsString("error")))
.andExpect(content().string(containsString("Campagne introuvable")));
}
@Test
void adaptStream_emptyFile_emitsError_withoutCallingService() throws Exception {
// Fichier vide : branche court-circuit, le service n'est jamais appele.
MvcResult result = mockMvc.perform(
multipart("/api/campaigns/{id}/adapt-pdf/stream", "camp-1")
.file(new MockMultipartFile("file", "vide.pdf", "application/pdf", new byte[0])))
.andExpect(request().asyncStarted())
.andReturn();
mockMvc.perform(asyncDispatch(result))
.andExpect(status().isOk())
.andExpect(content().string(containsString("error")))
.andExpect(content().string(containsString("Fichier PDF vide")));
}
}

View File

@@ -0,0 +1,91 @@
package com.loremind.infrastructure.web.controller;
import com.loremind.domain.campaigncontext.Arc;
import com.loremind.domain.campaigncontext.Campaign;
import com.loremind.domain.campaigncontext.Chapter;
import com.loremind.domain.campaigncontext.Prerequisite;
import com.loremind.domain.campaigncontext.ports.ArcRepository;
import com.loremind.domain.campaigncontext.ports.CampaignRepository;
import com.loremind.domain.campaigncontext.ports.ChapterRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
/**
* Tests d'intégration de {@link CampaignFlagController}.
* Unique endpoint : list (GET) qui déduplique les noms de faits (Prerequisite.FlagSet)
* référencés dans les prérequis des chapitres de la campagne.
* Fixtures : campaign -> arc -> chapters avec prérequis FlagSet.
*/
@SpringBootTest
@AutoConfigureMockMvc
@Transactional
class CampaignFlagControllerTest {
@Autowired private MockMvc mockMvc;
@Autowired private CampaignRepository campaignRepository;
@Autowired private ArcRepository arcRepository;
@Autowired private ChapterRepository chapterRepository;
private String campaignId;
private String arcId;
@BeforeEach
void setUp() {
// Chaîne de fixtures : campaign -> arc
campaignId = campaignRepository.save(Campaign.builder().name("C").description("").build()).getId();
arcId = arcRepository.save(Arc.builder().campaignId(campaignId).name("A").order(0).build()).getId();
}
@Test
void list_returnsEmptyArray_whenNoFlagPrerequisites() throws Exception {
// Chapitre sans prérequis FlagSet => aucun fait référencé
chapterRepository.save(Chapter.builder().arcId(arcId).name("Ch").order(0).build());
mockMvc.perform(get("/api/campaigns/{cid}/flags", campaignId))
.andExpect(status().isOk())
.andExpect(jsonPath("$").isArray())
.andExpect(jsonPath("$.length()").value(0));
}
@Test
void list_returnsDeduplicatedSortedFlagNames() throws Exception {
// Deux chapitres référençant des FlagSet, avec un doublon "alpha"
chapterRepository.save(Chapter.builder().arcId(arcId).name("Ch1").order(0)
.prerequisites(List.of(
new Prerequisite.FlagSet("zeta"),
new Prerequisite.FlagSet("alpha")))
.build());
chapterRepository.save(Chapter.builder().arcId(arcId).name("Ch2").order(1)
.prerequisites(List.of(
new Prerequisite.FlagSet("alpha"), // doublon
new Prerequisite.QuestCompleted("q-1"))) // ignoré (pas un FlagSet)
.build());
mockMvc.perform(get("/api/campaigns/{cid}/flags", campaignId))
.andExpect(status().isOk())
.andExpect(jsonPath("$").isArray())
.andExpect(jsonPath("$.length()").value(2))
// TreeSet => tri alphabétique : alpha avant zeta
.andExpect(jsonPath("$[0]").value("alpha"))
.andExpect(jsonPath("$[1]").value("zeta"));
}
@Test
void list_returnsEmptyArray_forUnknownCampaign() throws Exception {
// Campagne sans arcs => liste vide (pas d'erreur)
mockMvc.perform(get("/api/campaigns/{cid}/flags", "999999999"))
.andExpect(status().isOk())
.andExpect(jsonPath("$").isArray())
.andExpect(jsonPath("$.length()").value(0));
}
}

View File

@@ -0,0 +1,281 @@
package com.loremind.infrastructure.web.controller;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.loremind.application.campaigncontext.CampaignImportService;
import com.loremind.application.campaigncontext.CampaignImportService.ApplyResult;
import com.loremind.domain.campaigncontext.CampaignImportProgress;
import com.loremind.domain.campaigncontext.CampaignImportProposal;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.core.task.TaskExecutor;
import org.springframework.http.MediaType;
import org.springframework.mock.web.MockMultipartFile;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.MvcResult;
import java.util.List;
import java.util.function.Consumer;
import static org.hamcrest.Matchers.containsString;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.asyncDispatch;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.request;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
/**
* Tests d'integration pour {@link CampaignImportController} (import PDF -> arbre).
* <p>
* Le {@link CampaignImportService} est mocke : son {@code importStructureStreaming}
* delegue sinon au Brain Python (indisponible en test) et {@code applyStructure}
* persiste en base. On controle ici les callbacks (progress / done / error) du
* streaming et le mapping HTTP du apply.
* <p>
* Le {@code TaskExecutor} ("applicationTaskExecutor") est mocke pour executer la
* tache d'import EN LIGNE (synchrone) : tous les events SSE sont ecrits avant le
* retour du controleur, rendant les assertions sur le flux deterministes.
* <p>
* Indices des callbacks de
* {@link CampaignImportService#importStructureStreaming} :
* (0) pdfBytes, (1) filename, (2) onProgress, (3) onHeartbeat, (4) onStatus,
* (5) onDone, (6) onError.
*/
@SpringBootTest
@AutoConfigureMockMvc
class CampaignImportControllerTest {
@Autowired private MockMvc mockMvc;
@Autowired private ObjectMapper objectMapper;
@MockBean private CampaignImportService campaignImportService;
@MockBean(name = "applicationTaskExecutor") private TaskExecutor taskExecutor;
private static final String CAMPAIGN_ID = "camp-1";
@BeforeEach
void setUp() {
// Tache d'import executee en ligne -> events SSE deterministes.
doAnswer(inv -> { ((Runnable) inv.getArgument(0)).run(); return null; })
.when(taskExecutor).execute(any(Runnable.class));
}
private MockMultipartFile pdf(byte[] bytes) {
return new MockMultipartFile("file", "campagne.pdf", "application/pdf", bytes);
}
// --- POST /stream (SSE) ------------------------------------------------
@Test
void importStream_emptyFile_emitsError() throws Exception {
MockMultipartFile empty = pdf(new byte[0]);
MvcResult result = mockMvc.perform(
multipart("/api/campaigns/{id}/import-structure/stream", CAMPAIGN_ID).file(empty))
.andExpect(request().asyncStarted())
.andReturn();
mockMvc.perform(asyncDispatch(result))
.andExpect(status().isOk())
.andExpect(content().string(containsString("error")))
.andExpect(content().string(containsString("vide")));
}
@Test
void importStream_happyPath_streamsDone() throws Exception {
// Le service mocke joue : onDone avec une proposition vide.
doAnswer(inv -> {
Consumer<CampaignImportProposal> onDone = inv.getArgument(5);
onDone.accept(new CampaignImportProposal(List.of(), List.of()));
return null;
}).when(campaignImportService).importStructureStreaming(
any(), any(), any(), any(), any(), any(), any());
MvcResult result = mockMvc.perform(
multipart("/api/campaigns/{id}/import-structure/stream", CAMPAIGN_ID)
.file(pdf(new byte[]{1, 2, 3})))
.andExpect(request().asyncStarted())
.andReturn();
mockMvc.perform(asyncDispatch(result))
.andExpect(status().isOk())
.andExpect(content().string(containsString("done")));
}
@Test
void importStream_serviceError_emitsError() throws Exception {
// Le service mocke joue : onError avec un message.
doAnswer(inv -> {
Consumer<Throwable> onError = inv.getArgument(6);
onError.accept(new RuntimeException("Brain injoignable"));
return null;
}).when(campaignImportService).importStructureStreaming(
any(), any(), any(), any(), any(), any(), any());
MvcResult result = mockMvc.perform(
multipart("/api/campaigns/{id}/import-structure/stream", CAMPAIGN_ID)
.file(pdf(new byte[]{1, 2, 3})))
.andExpect(request().asyncStarted())
.andReturn();
mockMvc.perform(asyncDispatch(result))
.andExpect(status().isOk())
.andExpect(content().string(containsString("error")))
.andExpect(content().string(containsString("Brain injoignable")));
}
@Test
void importStream_thrownException_emitsError() throws Exception {
// Le service leve directement (catch general du controleur).
doAnswer(inv -> { throw new RuntimeException("boom extraction"); })
.when(campaignImportService).importStructureStreaming(
any(), any(), any(), any(), any(), any(), any());
MvcResult result = mockMvc.perform(
multipart("/api/campaigns/{id}/import-structure/stream", CAMPAIGN_ID)
.file(pdf(new byte[]{1, 2, 3})))
.andExpect(request().asyncStarted())
.andReturn();
mockMvc.perform(asyncDispatch(result))
.andExpect(status().isOk())
.andExpect(content().string(containsString("boom extraction")));
}
@Test
void importStream_progressAndStatus_streamedBeforeDone() throws Exception {
// Joue la sequence complete des callbacks intermediaires (progress / status /
// heartbeat) puis onDone -> couvre sendEvent("progress"), sendEvent("status")
// et sendHeartbeat (helpers SSE non touches par les tests precedents).
doAnswer(inv -> {
Consumer<CampaignImportProgress> onProgress = inv.getArgument(2);
Runnable onHeartbeat = inv.getArgument(3);
Consumer<String> onStatus = inv.getArgument(4);
Consumer<CampaignImportProposal> onDone = inv.getArgument(5);
onProgress.accept(new CampaignImportProgress(2, 10, 5, 1, 1, 2, 3, 4));
onStatus.accept("Analyse en cours");
onHeartbeat.run();
onDone.accept(new CampaignImportProposal(List.of(), List.of()));
return null;
}).when(campaignImportService).importStructureStreaming(
any(), any(), any(), any(), any(), any(), any());
MvcResult result = mockMvc.perform(
multipart("/api/campaigns/{id}/import-structure/stream", CAMPAIGN_ID)
.file(pdf(new byte[]{1, 2, 3})))
.andExpect(request().asyncStarted())
.andReturn();
mockMvc.perform(asyncDispatch(result))
.andExpect(status().isOk())
.andExpect(content().string(containsString("progress")))
.andExpect(content().string(containsString("status")))
.andExpect(content().string(containsString("Analyse en cours")))
.andExpect(content().string(containsString("keepalive")))
.andExpect(content().string(containsString("done")));
}
@Test
void importStream_nullStatus_emitsEmptyMessage() throws Exception {
// onStatus(null) -> branche "status != null ? status : \"\"" du controleur.
doAnswer(inv -> {
Consumer<String> onStatus = inv.getArgument(4);
Consumer<CampaignImportProposal> onDone = inv.getArgument(5);
onStatus.accept(null);
onDone.accept(new CampaignImportProposal(List.of(), List.of()));
return null;
}).when(campaignImportService).importStructureStreaming(
any(), any(), any(), any(), any(), any(), any());
MvcResult result = mockMvc.perform(
multipart("/api/campaigns/{id}/import-structure/stream", CAMPAIGN_ID)
.file(pdf(new byte[]{1, 2, 3})))
.andExpect(request().asyncStarted())
.andReturn();
mockMvc.perform(asyncDispatch(result))
.andExpect(status().isOk())
.andExpect(content().string(containsString("status")))
.andExpect(content().string(containsString("done")));
}
@Test
void importStream_nullErrorMessage_emitsFallbackMessage() throws Exception {
// onError avec un message null -> sendError utilise "Erreur inconnue.".
doAnswer(inv -> {
Consumer<Throwable> onError = inv.getArgument(6);
onError.accept(new RuntimeException()); // getMessage() == null
return null;
}).when(campaignImportService).importStructureStreaming(
any(), any(), any(), any(), any(), any(), any());
MvcResult result = mockMvc.perform(
multipart("/api/campaigns/{id}/import-structure/stream", CAMPAIGN_ID)
.file(pdf(new byte[]{1, 2, 3})))
.andExpect(request().asyncStarted())
.andReturn();
mockMvc.perform(asyncDispatch(result))
.andExpect(status().isOk())
.andExpect(content().string(containsString("Erreur inconnue")));
}
@Test
void importStream_serviceThrows_emitsError() throws Exception {
// Le service LEVE (au lieu d'invoquer onError) : le catch(Exception) du
// controleur relaie le message via sendError.
doThrow(new RuntimeException("panne interne"))
.when(campaignImportService).importStructureStreaming(
any(), any(), any(), any(), any(), any(), any());
MvcResult result = mockMvc.perform(
multipart("/api/campaigns/{id}/import-structure/stream", CAMPAIGN_ID)
.file(pdf(new byte[]{1, 2, 3})))
.andExpect(request().asyncStarted())
.andReturn();
mockMvc.perform(asyncDispatch(result))
.andExpect(status().isOk())
.andExpect(content().string(containsString("error")))
.andExpect(content().string(containsString("panne interne")));
}
// --- POST /apply -------------------------------------------------------
@Test
void apply_returns200_withSummary() throws Exception {
when(campaignImportService.applyStructure(eq(CAMPAIGN_ID), any()))
.thenReturn(new ApplyResult(1, 2, 3, 4));
CampaignImportProposal proposal = new CampaignImportProposal(List.of(), List.of());
mockMvc.perform(post("/api/campaigns/{id}/import-structure/apply", CAMPAIGN_ID)
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(proposal)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.arcsCreated").value(1))
.andExpect(jsonPath("$.chaptersCreated").value(2))
.andExpect(jsonPath("$.scenesCreated").value(3))
.andExpect(jsonPath("$.npcsCreated").value(4));
}
@Test
void apply_returns404_whenCampaignMissing() throws Exception {
when(campaignImportService.applyStructure(any(), any()))
.thenThrow(new IllegalArgumentException("Campagne introuvable"));
CampaignImportProposal proposal = new CampaignImportProposal(List.of(), List.of());
mockMvc.perform(post("/api/campaigns/{id}/import-structure/apply", CAMPAIGN_ID)
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(proposal)))
.andExpect(status().isNotFound());
}
}

View File

@@ -0,0 +1,79 @@
package com.loremind.infrastructure.web.controller;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.loremind.application.campaigncontext.CampaignImportService;
import com.loremind.domain.campaigncontext.CampaignImportProgress;
import com.loremind.domain.campaigncontext.CampaignImportProposal;
import org.junit.jupiter.api.Test;
import org.springframework.core.task.TaskExecutor;
import org.springframework.mock.web.MockMultipartFile;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import java.util.List;
import java.util.function.Consumer;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
/**
* Test UNITAIRE pur (sans Spring) de {@link CampaignImportController} dédié au code
* défensif de déconnexion du navigateur, INTESTABLE via MockMvc : là-bas, un envoi
* SSE après {@code complete()} met l'emitter dans l'état « already completed » qui
* remonte en {@code ServletException} (échec de test) au lieu d'exécuter la branche.
* <p>
* En instanciant le controller à la main (exécuteur en ligne, vrai ObjectMapper,
* service mocké), on pilote directement la séquence : envoi initial → {@code complete()}
* → un envoi ultérieur échoue et bascule {@code clientGone=true} → les callbacks
* suivants empruntent alors les branches {@code ClientGoneException} / early-return.
*/
class CampaignImportControllerUnitTest {
private final CampaignImportService service = mock(CampaignImportService.class);
/** Exécuteur synchrone : la tâche d'import tourne dans le thread du test. */
private final TaskExecutor inlineExecutor = Runnable::run;
private final CampaignImportController controller =
new CampaignImportController(service, inlineExecutor, new ObjectMapper());
private static CampaignImportProgress progress() {
return new CampaignImportProgress(1, 1, 0, 0, 0, 0, 0, 0);
}
@Test
void importStream_clientDisconnect_exercisesDefensiveBranches() throws Exception {
// Scénario de déconnexion : on termine le flux puis on continue d'émettre.
// Le 1er envoi post-complete échoue -> clientGone=true ; les callbacks suivants
// court-circuitent (ClientGoneException sur sendEvent/sendHeartbeat, early-return
// sur le callback d'erreur). Chaque étape est isolée car ClientGoneException
// (privée) se propage hors des callbacks — comme en prod où elle remonte au
// pipeline amont pour stopper le Brain.
doAnswer(inv -> {
Consumer<CampaignImportProgress> onProgress = inv.getArgument(2);
Runnable onHeartbeat = inv.getArgument(3);
Consumer<CampaignImportProposal> onDone = inv.getArgument(5);
Consumer<Throwable> onError = inv.getArgument(6);
// 1) Termine proprement le flux (event "done" + complete()).
onDone.accept(new CampaignImportProposal(List.of(), List.of()));
// 2) Envoi post-complete : send échoue -> catch -> clientGone=true.
try { onProgress.accept(progress()); } catch (RuntimeException ignored) { }
// 3) clientGone=true -> sendHeartbeat lève ClientGoneException (branche garde).
try { onHeartbeat.run(); } catch (RuntimeException ignored) { }
// 4) clientGone=true -> sendEvent lève ClientGoneException (branche garde).
try { onProgress.accept(progress()); } catch (RuntimeException ignored) { }
// 5) clientGone=true -> le callback d'erreur prend l'early-return (pas d'envoi).
onError.accept(new RuntimeException("tardif"));
return null;
}).when(service).importStructureStreaming(any(), any(), any(), any(), any(), any(), any());
MockMultipartFile file = new MockMultipartFile(
"file", "campagne.pdf", "application/pdf", new byte[]{1, 2, 3});
SseEmitter emitter = controller.importStream("camp-1", file);
assertNotNull(emitter);
verify(service).importStructureStreaming(any(), any(), any(), any(), any(), any(), any());
}
}

View File

@@ -7,6 +7,8 @@ import com.loremind.domain.campaigncontext.Chapter;
import com.loremind.domain.campaigncontext.ports.ArcRepository; import com.loremind.domain.campaigncontext.ports.ArcRepository;
import com.loremind.domain.campaigncontext.ports.CampaignRepository; import com.loremind.domain.campaigncontext.ports.CampaignRepository;
import com.loremind.domain.campaigncontext.ports.ChapterRepository; import com.loremind.domain.campaigncontext.ports.ChapterRepository;
import com.loremind.domain.playcontext.Playthrough;
import com.loremind.domain.playcontext.ports.PlaythroughRepository;
import com.loremind.infrastructure.web.dto.campaigncontext.ChapterDTO; import com.loremind.infrastructure.web.dto.campaigncontext.ChapterDTO;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
@@ -34,13 +36,18 @@ class ChapterControllerTest {
@Autowired private CampaignRepository campaignRepository; @Autowired private CampaignRepository campaignRepository;
@Autowired private ArcRepository arcRepository; @Autowired private ArcRepository arcRepository;
@Autowired private ChapterRepository chapterRepository; @Autowired private ChapterRepository chapterRepository;
@Autowired private PlaythroughRepository playthroughRepository;
private String arcId; private String arcId;
private String playthroughId;
@BeforeEach @BeforeEach
void setUp() { void setUp() {
String campaignId = campaignRepository.save(Campaign.builder().name("C").description("").build()).getId(); String campaignId = campaignRepository.save(Campaign.builder().name("C").description("").build()).getId();
arcId = arcRepository.save(Arc.builder().campaignId(campaignId).name("A").order(0).build()).getId(); arcId = arcRepository.save(Arc.builder().campaignId(campaignId).name("A").order(0).build()).getId();
// playthroughId REEL (id numerique) : l'enrichissement du statut le parse.
playthroughId = playthroughRepository.save(
Playthrough.builder().campaignId(campaignId).name("Table").description("").build()).getId();
} }
@Test @Test
@@ -106,4 +113,52 @@ class ChapterControllerTest {
mockMvc.perform(delete("/api/chapters/{id}", saved.getId())) mockMvc.perform(delete("/api/chapters/{id}", saved.getId()))
.andExpect(status().isNoContent()); .andExpect(status().isNoContent());
} }
// getById avec ?playthroughId= : branche d'enrichissement du statut.
// Le playthrough est inexistant -> snapshot vide -> statut NOT_STARTED, mais la branche est couverte.
@Test
void getById_withPlaythroughId_enrichesStatus() throws Exception {
Chapter saved = chapterRepository.save(Chapter.builder().arcId(arcId).name("Ch").order(0).build());
mockMvc.perform(get("/api/chapters/{id}", saved.getId()).param("playthroughId", playthroughId))
.andExpect(status().isOk())
.andExpect(jsonPath("$.progressionStatus").value("NOT_STARTED"));
}
// getAll avec ?playthroughId= : branche d'enrichissement sur la liste.
@Test
void getAll_withPlaythroughId_enrichesStatus() throws Exception {
chapterRepository.save(Chapter.builder().arcId(arcId).name("A").order(0).build());
mockMvc.perform(get("/api/chapters").param("playthroughId", playthroughId))
.andExpect(status().isOk())
.andExpect(jsonPath("$[0].progressionStatus").value("NOT_STARTED"));
}
// deletion-impact : chapitre existant -> 200 avec le compte de scènes (0 ici).
@Test
void deletionImpact_returns200_whenExists() throws Exception {
Chapter saved = chapterRepository.save(Chapter.builder().arcId(arcId).name("Ch").order(0).build());
mockMvc.perform(get("/api/chapters/{id}/deletion-impact", saved.getId()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.scenes").value(0));
}
// deletion-impact : chapitre inexistant -> 404.
@Test
void deletionImpact_returns404_whenMissing() throws Exception {
mockMvc.perform(get("/api/chapters/{id}/deletion-impact", "999999999"))
.andExpect(status().isNotFound());
}
// update sur id inexistant : le service lève IllegalArgumentException -> 400.
@Test
void update_returns400_whenMissing() throws Exception {
ChapterDTO dto = new ChapterDTO();
dto.setName("new");
dto.setArcId(arcId);
dto.setOrder(0);
mockMvc.perform(put("/api/chapters/{id}", "999999999")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(dto)))
.andExpect(status().isBadRequest());
}
} }

View File

@@ -0,0 +1,128 @@
package com.loremind.infrastructure.web.controller;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.loremind.domain.campaigncontext.Campaign;
import com.loremind.domain.campaigncontext.Character;
import com.loremind.domain.campaigncontext.ports.CampaignRepository;
import com.loremind.domain.campaigncontext.ports.CharacterRepository;
import com.loremind.domain.playcontext.Playthrough;
import com.loremind.domain.playcontext.ports.PlaythroughRepository;
import com.loremind.infrastructure.web.dto.campaigncontext.CharacterDTO;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.transaction.annotation.Transactional;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
/** Tests d'intégration CRUD du CharacterController (PJ liés à un Playthrough). */
@SpringBootTest
@AutoConfigureMockMvc
@Transactional
class CharacterControllerTest {
@Autowired private MockMvc mockMvc;
@Autowired private ObjectMapper objectMapper;
@Autowired private CampaignRepository campaignRepository;
@Autowired private PlaythroughRepository playthroughRepository;
@Autowired private CharacterRepository characterRepository;
private String playthroughId;
@BeforeEach
void setUp() {
String campId = campaignRepository.save(Campaign.builder().name("C").description("").build()).getId();
playthroughId = playthroughRepository.save(
Playthrough.builder().campaignId(campId).name("Table").build()).getId();
}
@Test
void create_returns200() throws Exception {
CharacterDTO dto = new CharacterDTO();
dto.setName("Aragorn");
dto.setPlaythroughId(playthroughId);
mockMvc.perform(post("/api/characters")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(dto)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.name").value("Aragorn"));
}
@Test
void getById_returns200() throws Exception {
Character saved = characterRepository.save(
Character.builder().playthroughId(playthroughId).name("PJ").order(0).build());
mockMvc.perform(get("/api/characters/{id}", saved.getId()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.name").value("PJ"));
}
@Test
void getById_returns404_whenMissing() throws Exception {
mockMvc.perform(get("/api/characters/{id}", "999999999"))
.andExpect(status().isNotFound());
}
@Test
void getByPlaythrough_returnsArray() throws Exception {
characterRepository.save(Character.builder().playthroughId(playthroughId).name("A").order(0).build());
mockMvc.perform(get("/api/characters/playthrough/{playthroughId}", playthroughId))
.andExpect(status().isOk())
.andExpect(jsonPath("$").isArray());
}
/** Recherche enrichie : le campaignId est résolu via le Playthrough. */
@Test
void search_returnsEnrichedResult() throws Exception {
characterRepository.save(Character.builder().playthroughId(playthroughId).name("Legolas").order(0).build());
mockMvc.perform(get("/api/characters/search").param("q", "Legolas"))
.andExpect(status().isOk())
.andExpect(jsonPath("$").isArray())
.andExpect(jsonPath("$[0].name").value("Legolas"))
.andExpect(jsonPath("$[0].campaignId").exists());
}
@Test
void update_returns200() throws Exception {
Character saved = characterRepository.save(
Character.builder().playthroughId(playthroughId).name("old").order(0).build());
CharacterDTO dto = new CharacterDTO();
dto.setName("new");
dto.setPlaythroughId(playthroughId);
dto.setOrder(0);
mockMvc.perform(put("/api/characters/{id}", saved.getId())
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(dto)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.name").value("new"));
}
@Test
void update_returns400_whenMissing() throws Exception {
CharacterDTO dto = new CharacterDTO();
dto.setName("x");
dto.setPlaythroughId(playthroughId);
dto.setOrder(0);
mockMvc.perform(put("/api/characters/{id}", "999999999")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(dto)))
.andExpect(status().isBadRequest());
}
@Test
void delete_returns204() throws Exception {
Character saved = characterRepository.save(
Character.builder().playthroughId(playthroughId).name("X").order(0).build());
mockMvc.perform(delete("/api/characters/{id}", saved.getId()))
.andExpect(status().isNoContent());
}
}

View File

@@ -0,0 +1,46 @@
package com.loremind.infrastructure.web.controller;
import com.loremind.infrastructure.updates.UpdateCheckService;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.test.web.servlet.MockMvc;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
/**
* Tests d'integration pour {@link ConfigController}.
* <p>
* GET /api/config renvoie {demoMode, updateCheckEnabled}. Le service de mise a
* jour est mocke pour piloter la valeur de updateCheckEnabled sans dependre de
* la configuration reelle (Watchtower/registry indisponibles en test).
*/
@SpringBootTest
@AutoConfigureMockMvc
class ConfigControllerTest {
@Autowired private MockMvc mockMvc;
@MockBean private UpdateCheckService updates;
@Test
void getPublicConfig_returns200_updateCheckEnabledTrue() throws Exception {
when(updates.isEnabled()).thenReturn(true);
mockMvc.perform(get("/api/config"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.updateCheckEnabled").value(true))
.andExpect(jsonPath("$.demoMode").value(false));
}
@Test
void getPublicConfig_returns200_updateCheckEnabledFalse() throws Exception {
when(updates.isEnabled()).thenReturn(false);
mockMvc.perform(get("/api/config"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.updateCheckEnabled").value(false));
}
}

View File

@@ -0,0 +1,117 @@
package com.loremind.infrastructure.web.controller;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.loremind.domain.campaigncontext.Campaign;
import com.loremind.domain.campaigncontext.Enemy;
import com.loremind.domain.campaigncontext.ports.CampaignRepository;
import com.loremind.domain.campaigncontext.ports.EnemyRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.transaction.annotation.Transactional;
import java.util.Map;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
/** Tests d'intégration CRUD du EnemyController (bestiaire de campagne). */
@SpringBootTest
@AutoConfigureMockMvc
@Transactional
class EnemyControllerTest {
@Autowired private MockMvc mockMvc;
@Autowired private ObjectMapper objectMapper;
@Autowired private CampaignRepository campaignRepository;
@Autowired private EnemyRepository enemyRepository;
private String campaignId;
@BeforeEach
void setUp() {
campaignId = campaignRepository.save(Campaign.builder().name("C").description("").build()).getId();
}
/** Requête = record EnemyRequest(name, level, folder, ..., campaignId, order). */
@Test
void create_returns200() throws Exception {
EnemyController.EnemyRequest req = new EnemyController.EnemyRequest(
"Gobelin", "FP 1", "Humanoïdes", null, null,
Map.of(), Map.of(), Map.of(), campaignId, null);
mockMvc.perform(post("/api/enemies")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(req)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.name").value("Gobelin"));
}
@Test
void getById_returns200() throws Exception {
Enemy saved = enemyRepository.save(Enemy.builder().campaignId(campaignId).name("E").order(0).build());
mockMvc.perform(get("/api/enemies/{id}", saved.getId()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.name").value("E"));
}
@Test
void getById_returns404_whenMissing() throws Exception {
mockMvc.perform(get("/api/enemies/{id}", "999999999"))
.andExpect(status().isNotFound());
}
@Test
void getByCampaign_returnsArray() throws Exception {
enemyRepository.save(Enemy.builder().campaignId(campaignId).name("A").order(0).build());
mockMvc.perform(get("/api/enemies/campaign/{campaignId}", campaignId))
.andExpect(status().isOk())
.andExpect(jsonPath("$").isArray());
}
@Test
void search_returnsArray() throws Exception {
enemyRepository.save(Enemy.builder().campaignId(campaignId).name("Dragon").order(0).build());
mockMvc.perform(get("/api/enemies/search").param("q", "Dragon"))
.andExpect(status().isOk())
.andExpect(jsonPath("$").isArray());
}
@Test
void update_returns200() throws Exception {
Enemy saved = enemyRepository.save(Enemy.builder().campaignId(campaignId).name("old").order(0).build());
EnemyController.EnemyRequest req = new EnemyController.EnemyRequest(
"new", null, null, null, null,
Map.of(), Map.of(), Map.of(), campaignId, 0);
mockMvc.perform(put("/api/enemies/{id}", saved.getId())
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(req)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.name").value("new"));
}
@Test
void update_returns400_whenMissing() throws Exception {
EnemyController.EnemyRequest req = new EnemyController.EnemyRequest(
"x", null, null, null, null,
Map.of(), Map.of(), Map.of(), campaignId, 0);
mockMvc.perform(put("/api/enemies/{id}", "999999999")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(req)))
.andExpect(status().isBadRequest());
}
@Test
void delete_returns204() throws Exception {
Enemy saved = enemyRepository.save(Enemy.builder().campaignId(campaignId).name("X").order(0).build());
mockMvc.perform(delete("/api/enemies/{id}", saved.getId()))
.andExpect(status().isNoContent());
}
}

View File

@@ -1,23 +1,43 @@
package com.loremind.infrastructure.web.controller; package com.loremind.infrastructure.web.controller;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import com.loremind.domain.gamesystemcontext.RulesImportProgress;
import com.loremind.domain.gamesystemcontext.RulesImportResult;
import com.loremind.domain.gamesystemcontext.ports.RulesImportException;
import com.loremind.domain.gamesystemcontext.ports.RulesPdfImporter;
import com.loremind.infrastructure.web.dto.gamesystemcontext.GameSystemDTO; import com.loremind.infrastructure.web.dto.gamesystemcontext.GameSystemDTO;
import com.loremind.infrastructure.web.dto.shared.TemplateFieldDTO; import com.loremind.infrastructure.web.dto.shared.TemplateFieldDTO;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.core.task.TaskExecutor;
import org.springframework.http.MediaType; import org.springframework.http.MediaType;
import org.springframework.mock.web.MockMultipartFile;
import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.MvcResult; import org.springframework.test.web.servlet.MvcResult;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import java.util.List; import java.util.List;
import java.util.Map;
import java.util.function.Consumer;
import static org.hamcrest.Matchers.containsString;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.asyncDispatch;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.request;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
/** /**
@@ -32,6 +52,29 @@ class GameSystemControllerTest {
@Autowired private MockMvc mockMvc; @Autowired private MockMvc mockMvc;
@Autowired private ObjectMapper objectMapper; @Autowired private ObjectMapper objectMapper;
@MockBean private RulesPdfImporter rulesPdfImporter;
@MockBean(name = "applicationTaskExecutor") private TaskExecutor taskExecutor;
@BeforeEach
void setUp() {
// Tache de l'import streame executee en ligne -> events SSE deterministes.
doAnswer(inv -> { ((Runnable) inv.getArgument(0)).run(); return null; })
.when(taskExecutor).execute(any(Runnable.class));
}
/** Cree un GameSystem minimal via l'API et renvoie son id. */
private String createGameSystem(String name) throws Exception {
GameSystemDTO dto = new GameSystemDTO();
dto.setName(name);
MvcResult posted = mockMvc.perform(post("/api/game-systems")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(dto)))
.andExpect(status().isOk())
.andReturn();
return objectMapper.readValue(
posted.getResponse().getContentAsString(), GameSystemDTO.class).getId();
}
@Test @Test
void create_persistsCharacterAndNpcTemplates() throws Exception { void create_persistsCharacterAndNpcTemplates() throws Exception {
GameSystemDTO dto = new GameSystemDTO(); GameSystemDTO dto = new GameSystemDTO();
@@ -105,4 +148,228 @@ class GameSystemControllerTest {
.content(objectMapper.writeValueAsString(dto))) .content(objectMapper.writeValueAsString(dto)))
.andExpect(status().is4xxClientError()); .andExpect(status().is4xxClientError());
} }
@Test
void create_persistsEnemyTemplate() throws Exception {
GameSystemDTO dto = new GameSystemDTO();
dto.setName("Bestiaire");
dto.setEnemyTemplate(List.of(
new TemplateFieldDTO("Niveau", "NUMBER", null),
new TemplateFieldDTO("Tactique", "TEXT", null)));
mockMvc.perform(post("/api/game-systems")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(dto)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.enemyTemplate.length()").value(2))
.andExpect(jsonPath("$.enemyTemplate[0].name").value("Niveau"))
.andExpect(jsonPath("$.enemyTemplate[1].type").value("TEXT"));
}
// --- CRUD : lecture / recherche / suppression ---------------------------
@Test
void getById_returns200() throws Exception {
String id = createGameSystem("Lisible");
mockMvc.perform(get("/api/game-systems/{id}", id))
.andExpect(status().isOk())
.andExpect(jsonPath("$.id").value(id))
.andExpect(jsonPath("$.name").value("Lisible"));
}
@Test
void getById_returns404_whenMissing() throws Exception {
mockMvc.perform(get("/api/game-systems/{id}", "999999999"))
.andExpect(status().isNotFound());
}
@Test
void getAll_returnsArray() throws Exception {
createGameSystem("Systeme A");
mockMvc.perform(get("/api/game-systems"))
.andExpect(status().isOk())
.andExpect(jsonPath("$").isArray());
}
@Test
void search_returnsMatching() throws Exception {
createGameSystem("Dragonbane Unique");
mockMvc.perform(get("/api/game-systems/search").param("q", "Dragonbane"))
.andExpect(status().isOk())
.andExpect(jsonPath("$").isArray())
.andExpect(jsonPath("$[?(@.name == 'Dragonbane Unique')]").exists());
}
@Test
void delete_returns204_thenGone() throws Exception {
String id = createGameSystem("A supprimer");
mockMvc.perform(delete("/api/game-systems/{id}", id))
.andExpect(status().isNoContent());
mockMvc.perform(get("/api/game-systems/{id}", id))
.andExpect(status().isNotFound());
}
// --- Import de regles (PDF) : multipart ---------------------------------
@Test
void importRules_returns200_withSections() throws Exception {
when(rulesPdfImporter.importRules(any(), any()))
.thenReturn(new RulesImportResult(Map.of("Combat", "## Combat\n- d20"), 5, 1));
MockMultipartFile file = new MockMultipartFile(
"file", "regles.pdf", "application/pdf", new byte[]{1, 2, 3});
mockMvc.perform(multipart("/api/game-systems/import-rules").file(file))
.andExpect(status().isOk())
.andExpect(jsonPath("$.pageCount").value(5))
.andExpect(jsonPath("$.ocrPageCount").value(1))
.andExpect(jsonPath("$.sections.Combat").value("## Combat\n- d20"));
}
@Test
void importRules_returns400_whenFileEmpty() throws Exception {
MockMultipartFile empty = new MockMultipartFile(
"file", "regles.pdf", "application/pdf", new byte[0]);
mockMvc.perform(multipart("/api/game-systems/import-rules").file(empty))
.andExpect(status().isBadRequest());
}
@Test
void importRules_returns502_whenBrainFails() throws Exception {
when(rulesPdfImporter.importRules(any(), any()))
.thenThrow(new RulesImportException("Brain injoignable"));
MockMultipartFile file = new MockMultipartFile(
"file", "regles.pdf", "application/pdf", new byte[]{1, 2, 3});
mockMvc.perform(multipart("/api/game-systems/import-rules").file(file))
.andExpect(status().isBadGateway());
}
// --- Import de regles streame (SSE) -------------------------------------
@Test
void importRulesStream_emitsProgressThenDone() throws Exception {
doAnswer(inv -> {
Consumer<RulesImportProgress> onProgress = inv.getArgument(2);
Consumer<RulesImportResult> onDone = inv.getArgument(5);
onProgress.accept(new RulesImportProgress(1, 2, 5, 0, List.of("Combat")));
onDone.accept(new RulesImportResult(Map.of("Combat", "## Combat"), 5, 0));
return null;
}).when(rulesPdfImporter).importRulesStreaming(
any(), any(), any(), any(), any(), any(), any());
MockMultipartFile file = new MockMultipartFile(
"file", "regles.pdf", "application/pdf", new byte[]{1, 2, 3});
MvcResult result = mockMvc.perform(
multipart("/api/game-systems/import-rules/stream").file(file))
.andExpect(request().asyncStarted())
.andReturn();
mockMvc.perform(asyncDispatch(result))
.andExpect(status().isOk())
.andExpect(content().string(containsString("progress")))
.andExpect(content().string(containsString("done")))
.andExpect(content().string(containsString("Combat")));
}
@Test
void importRulesStream_emptyFile_emitsError() throws Exception {
MockMultipartFile empty = new MockMultipartFile(
"file", "regles.pdf", "application/pdf", new byte[0]);
MvcResult result = mockMvc.perform(
multipart("/api/game-systems/import-rules/stream").file(empty))
.andExpect(request().asyncStarted())
.andReturn();
mockMvc.perform(asyncDispatch(result))
.andExpect(status().isOk())
.andExpect(content().string(containsString("error")))
.andExpect(content().string(containsString("vide")));
}
@Test
void importRulesStream_brainError_emitsError() throws Exception {
doAnswer(inv -> {
Consumer<Throwable> onError = inv.getArgument(6);
onError.accept(new RuntimeException("structuration KO"));
return null;
}).when(rulesPdfImporter).importRulesStreaming(
any(), any(), any(), any(), any(), any(), any());
MockMultipartFile file = new MockMultipartFile(
"file", "regles.pdf", "application/pdf", new byte[]{1, 2, 3});
MvcResult result = mockMvc.perform(
multipart("/api/game-systems/import-rules/stream").file(file))
.andExpect(request().asyncStarted())
.andReturn();
mockMvc.perform(asyncDispatch(result))
.andExpect(status().isOk())
.andExpect(content().string(containsString("error")))
.andExpect(content().string(containsString("structuration KO")));
}
/**
* Couvre {@code sendImportHeartbeat} (callback onHeartbeat, arg 3) ET la branche
* "status" de {@code sendImportEvent} (callback onStatus, arg 4) : un import qui
* envoie un keepalive et un message de statut avant de produire son resultat.
*/
@Test
void importRulesStream_emitsHeartbeatAndStatus_thenDone() throws Exception {
doAnswer(inv -> {
Runnable onHeartbeat = inv.getArgument(3);
Consumer<String> onStatus = inv.getArgument(4);
Consumer<RulesImportResult> onDone = inv.getArgument(5);
// Keepalive (commentaire SSE, ignore par le front) -> sendImportHeartbeat.
onHeartbeat.run();
// Message de statut lisible -> sendImportEvent(..., "status", ...).
onStatus.accept("Fournisseur sature, nouvelle tentative...");
onDone.accept(new RulesImportResult(Map.of("Combat", "## Combat"), 5, 0));
return null;
}).when(rulesPdfImporter).importRulesStreaming(
any(), any(), any(), any(), any(), any(), any());
MockMultipartFile file = new MockMultipartFile(
"file", "regles.pdf", "application/pdf", new byte[]{1, 2, 3});
MvcResult result = mockMvc.perform(
multipart("/api/game-systems/import-rules/stream").file(file))
.andExpect(request().asyncStarted())
.andReturn();
mockMvc.perform(asyncDispatch(result))
.andExpect(status().isOk())
// Le commentaire keepalive est present dans le flux brut.
.andExpect(content().string(containsString("keepalive")))
// L'event "status" et son message sont serialises.
.andExpect(content().string(containsString("status")))
.andExpect(content().string(containsString("Fournisseur sature")))
.andExpect(content().string(containsString("done")));
}
/**
* Couvre la branche onStatus avec un message null : le controleur substitue une
* chaine vide ({@code status != null ? status : ""}) sans planter.
*/
@Test
void importRulesStream_nullStatus_emitsEmptyStatus() throws Exception {
doAnswer(inv -> {
Consumer<String> onStatus = inv.getArgument(4);
Consumer<RulesImportResult> onDone = inv.getArgument(5);
onStatus.accept(null);
onDone.accept(new RulesImportResult(Map.of("Combat", "## Combat"), 5, 0));
return null;
}).when(rulesPdfImporter).importRulesStreaming(
any(), any(), any(), any(), any(), any(), any());
MockMultipartFile file = new MockMultipartFile(
"file", "regles.pdf", "application/pdf", new byte[]{1, 2, 3});
MvcResult result = mockMvc.perform(
multipart("/api/game-systems/import-rules/stream").file(file))
.andExpect(request().asyncStarted())
.andReturn();
mockMvc.perform(asyncDispatch(result))
.andExpect(status().isOk())
.andExpect(content().string(containsString("status")))
.andExpect(content().string(containsString("done")));
}
} }

View File

@@ -0,0 +1,73 @@
package com.loremind.infrastructure.web.controller;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.loremind.application.gamesystemcontext.GameSystemService;
import com.loremind.domain.gamesystemcontext.RulesImportProgress;
import com.loremind.domain.gamesystemcontext.RulesImportResult;
import com.loremind.infrastructure.web.mapper.GameSystemMapper;
import com.loremind.infrastructure.web.mapper.TemplateFieldMapper;
import org.junit.jupiter.api.Test;
import org.springframework.core.task.TaskExecutor;
import org.springframework.mock.web.MockMultipartFile;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import java.util.List;
import java.util.Map;
import java.util.function.Consumer;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
/**
* Test UNITAIRE pur (sans Spring) de {@link GameSystemController} dédié au code
* défensif de déconnexion du navigateur de l'import streamé, INTESTABLE via MockMvc
* (un envoi SSE après {@code complete()} y remonte en ServletException au lieu
* d'exécuter la branche). Voir {@link CampaignImportControllerUnitTest} pour le détail
* de l'approche — même séquence : complete -> send échoue -> {@code clientGone=true}
* -> les callbacks suivants empruntent les branches {@code ClientGoneException}.
*/
class GameSystemControllerUnitTest {
private final GameSystemService service = mock(GameSystemService.class);
private final TaskExecutor inlineExecutor = Runnable::run;
private final GameSystemController controller = new GameSystemController(
service, mock(GameSystemMapper.class), mock(TemplateFieldMapper.class),
inlineExecutor, new ObjectMapper());
private static RulesImportProgress progress() {
return new RulesImportProgress(1, 1, 0, 0, List.of());
}
@Test
void importRulesStream_clientDisconnect_exercisesDefensiveBranches() throws Exception {
doAnswer(inv -> {
Consumer<RulesImportProgress> onProgress = inv.getArgument(2);
Runnable onHeartbeat = inv.getArgument(3);
Consumer<RulesImportResult> onDone = inv.getArgument(5);
Consumer<Throwable> onError = inv.getArgument(6);
// 1) Termine le flux (event "done" + complete()).
onDone.accept(new RulesImportResult(Map.of(), 0, 0));
// 2) Envoi post-complete : send échoue -> catch -> clientGone=true.
try { onProgress.accept(progress()); } catch (RuntimeException ignored) { }
// 3) clientGone=true -> sendImportHeartbeat lève ClientGoneException.
try { onHeartbeat.run(); } catch (RuntimeException ignored) { }
// 4) clientGone=true -> sendImportEvent lève ClientGoneException.
try { onProgress.accept(progress()); } catch (RuntimeException ignored) { }
// 5) clientGone=true -> callback d'erreur en early-return.
onError.accept(new RuntimeException("tardif"));
return null;
}).when(service).importRulesFromPdfStreaming(any(), any(), any(), any(), any(), any(), any());
MockMultipartFile file = new MockMultipartFile(
"file", "regles.pdf", "application/pdf", new byte[]{1, 2, 3});
SseEmitter emitter = controller.importRulesStream(file);
assertNotNull(emitter);
verify(service).importRulesFromPdfStreaming(any(), any(), any(), any(), any(), any(), any());
}
}

View File

@@ -0,0 +1,158 @@
package com.loremind.infrastructure.web.controller;
import com.loremind.application.images.ImageService;
import com.loremind.domain.images.Image;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.mock.web.MockMultipartFile;
import org.springframework.test.web.servlet.MockMvc;
import java.io.ByteArrayInputStream;
import java.io.InputStream;
import java.time.LocalDateTime;
import java.util.Optional;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
/**
* Tests d'integration pour {@link ImageController} (Shared Kernel images).
* <p>
* Le {@link ImageService} est mocke : il orchestre sinon MinIO (binaire) +
* Postgres (metadonnees) via ses deux ports. On evite ainsi tout acces a MinIO,
* indisponible en test, tout en couvrant le mapping HTTP du controleur
* (upload / metadata / content streaming / delete + cas 400 / 404).
*/
@SpringBootTest
@AutoConfigureMockMvc
class ImageControllerTest {
@Autowired private MockMvc mockMvc;
@MockBean private ImageService imageService;
private Image sampleImage() {
return Image.builder()
.id("img-1")
.filename("portrait.png")
.contentType("image/png")
.sizeBytes(3)
.storageKey("images/img-1.png")
.uploadedAt(LocalDateTime.of(2026, 1, 1, 12, 0))
.build();
}
private MockMultipartFile pngFile(byte[] bytes) {
return new MockMultipartFile("file", "portrait.png", "image/png", bytes);
}
// --- POST /api/images --------------------------------------------------
@Test
void upload_returns200_withMetadata() throws Exception {
when(imageService.upload(eq("portrait.png"), eq("image/png"), any(InputStream.class), anyLong()))
.thenReturn(sampleImage());
mockMvc.perform(multipart("/api/images").file(pngFile(new byte[]{1, 2, 3})))
.andExpect(status().isOk())
.andExpect(jsonPath("$.id").value("img-1"))
.andExpect(jsonPath("$.filename").value("portrait.png"))
.andExpect(jsonPath("$.contentType").value("image/png"))
.andExpect(jsonPath("$.sizeBytes").value(3))
.andExpect(jsonPath("$.url").value("/api/images/img-1/content"));
}
@Test
void upload_returns400_whenEmpty() throws Exception {
mockMvc.perform(multipart("/api/images").file(pngFile(new byte[0])))
.andExpect(status().isBadRequest());
}
@Test
void upload_returns400_whenServiceRejects() throws Exception {
// Validation metier (MIME non autorise, taille...) -> IllegalArgumentException.
when(imageService.upload(any(), any(), any(InputStream.class), anyLong()))
.thenThrow(new IllegalArgumentException("Type de fichier non supporte."));
mockMvc.perform(multipart("/api/images")
.file(new MockMultipartFile("file", "evil.exe", "application/octet-stream",
new byte[]{1, 2, 3})))
.andExpect(status().isBadRequest());
}
// --- GET /api/images/{id} ----------------------------------------------
@Test
void getMetadata_returns200() throws Exception {
when(imageService.getById("img-1")).thenReturn(Optional.of(sampleImage()));
mockMvc.perform(get("/api/images/{id}", "img-1"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.id").value("img-1"))
.andExpect(jsonPath("$.url").value("/api/images/img-1/content"));
}
@Test
void getMetadata_returns404_whenMissing() throws Exception {
when(imageService.getById("nope")).thenReturn(Optional.empty());
mockMvc.perform(get("/api/images/{id}", "nope"))
.andExpect(status().isNotFound());
}
// --- GET /api/images/{id}/content --------------------------------------
@Test
void getContent_returns200_withBinary() throws Exception {
byte[] data = {10, 20, 30};
when(imageService.getById("img-1")).thenReturn(Optional.of(sampleImage()));
when(imageService.downloadById("img-1"))
.thenReturn(Optional.of(new ByteArrayInputStream(data)));
mockMvc.perform(get("/api/images/{id}/content", "img-1"))
.andExpect(status().isOk())
.andExpect(header().string("Content-Type", "image/png"))
.andExpect(header().string("Cross-Origin-Resource-Policy", "cross-origin"))
.andExpect(content().bytes(data));
}
@Test
void getContent_returns404_whenMetadataMissing() throws Exception {
when(imageService.getById("nope")).thenReturn(Optional.empty());
mockMvc.perform(get("/api/images/{id}/content", "nope"))
.andExpect(status().isNotFound());
}
@Test
void getContent_returns404_whenBinaryLost() throws Exception {
// Metadonnees presentes mais binaire absent (incoherence) -> 404.
when(imageService.getById("img-1")).thenReturn(Optional.of(sampleImage()));
when(imageService.downloadById("img-1")).thenReturn(Optional.empty());
mockMvc.perform(get("/api/images/{id}/content", "img-1"))
.andExpect(status().isNotFound());
}
// --- DELETE /api/images/{id} -------------------------------------------
@Test
void delete_returns204() throws Exception {
mockMvc.perform(delete("/api/images/{id}", "img-1"))
.andExpect(status().isNoContent());
verify(imageService).deleteById("img-1");
}
}

View File

@@ -0,0 +1,186 @@
package com.loremind.infrastructure.web.controller;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.loremind.domain.campaigncontext.Campaign;
import com.loremind.domain.campaigncontext.CatalogItem;
import com.loremind.domain.campaigncontext.ItemCatalog;
import com.loremind.domain.campaigncontext.ports.CampaignRepository;
import com.loremind.domain.campaigncontext.ports.ItemCatalogGenerationException;
import com.loremind.domain.campaigncontext.ports.ItemCatalogGenerator;
import com.loremind.domain.campaigncontext.ports.ItemCatalogRepository;
import com.loremind.infrastructure.web.controller.ItemCatalogController.GenerateRequest;
import com.loremind.infrastructure.web.dto.campaigncontext.ItemCatalogDTO;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
/**
* Tests d'integration pour {@link ItemCatalogController} (CRUD + recherche +
* generation IA d'un catalogue d'objets).
* <p>
* Le port {@link ItemCatalogGenerator} (client du Brain) est mocke : sinon
* l'endpoint /generate ferait un vrai appel reseau au service IA (indisponible
* en test). Les repos reels servent aux fixtures.
*/
@SpringBootTest
@AutoConfigureMockMvc
@Transactional
class ItemCatalogControllerTest {
@Autowired private MockMvc mockMvc;
@Autowired private ObjectMapper objectMapper;
@Autowired private ItemCatalogRepository catalogRepository;
@Autowired private CampaignRepository campaignRepository;
@MockBean private ItemCatalogGenerator generator;
private String campaignId;
@BeforeEach
void setUp() {
campaignId = campaignRepository.save(
Campaign.builder().name("Camp").description("desc").build()).getId();
}
private ItemCatalogDTO dto(String name) {
ItemCatalogDTO dto = new ItemCatalogDTO();
dto.setName(name);
dto.setDescription("Une boutique");
dto.setIcon("store");
dto.setCampaignId(campaignId);
dto.setOrder(0);
return dto;
}
// --- POST / -------------------------------------------------------------
@Test
void create_returns200() throws Exception {
mockMvc.perform(post("/api/item-catalogs")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(dto("Echoppe"))))
.andExpect(status().isOk())
.andExpect(jsonPath("$.name").value("Echoppe"))
.andExpect(jsonPath("$.campaignId").value(campaignId));
}
// --- GET /{id} ----------------------------------------------------------
@Test
void getById_returns200() throws Exception {
ItemCatalog saved = catalogRepository.save(ItemCatalog.builder()
.name("Tresor").campaignId(campaignId).order(0).build());
mockMvc.perform(get("/api/item-catalogs/{id}", saved.getId()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.name").value("Tresor"));
}
@Test
void getById_returns404_whenMissing() throws Exception {
mockMvc.perform(get("/api/item-catalogs/{id}", "999999999"))
.andExpect(status().isNotFound());
}
// --- GET /campaign/{campaignId} -----------------------------------------
@Test
void getByCampaign_returnsArray() throws Exception {
catalogRepository.save(ItemCatalog.builder()
.name("A").campaignId(campaignId).order(0).build());
mockMvc.perform(get("/api/item-catalogs/campaign/{campaignId}", campaignId))
.andExpect(status().isOk())
.andExpect(jsonPath("$").isArray())
.andExpect(jsonPath("$[0].name").value("A"));
}
// --- PUT /{id} ----------------------------------------------------------
@Test
void update_returns200() throws Exception {
ItemCatalog saved = catalogRepository.save(ItemCatalog.builder()
.name("old").campaignId(campaignId).order(0).build());
ItemCatalogDTO dto = dto("new");
mockMvc.perform(put("/api/item-catalogs/{id}", saved.getId())
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(dto)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.name").value("new"));
}
@Test
void update_returns400_whenMissing() throws Exception {
// Le service leve IllegalArgumentException -> GlobalExceptionHandler -> 400.
mockMvc.perform(put("/api/item-catalogs/{id}", "999999999")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(dto("x"))))
.andExpect(status().isBadRequest());
}
// --- DELETE /{id} -------------------------------------------------------
@Test
void delete_returns204() throws Exception {
ItemCatalog saved = catalogRepository.save(ItemCatalog.builder()
.name("X").campaignId(campaignId).order(0).build());
mockMvc.perform(delete("/api/item-catalogs/{id}", saved.getId()))
.andExpect(status().isNoContent());
}
// --- GET /search --------------------------------------------------------
@Test
void search_returnsArray() throws Exception {
catalogRepository.save(ItemCatalog.builder()
.name("Forge de Naheulbeuk").campaignId(campaignId).order(0).build());
mockMvc.perform(get("/api/item-catalogs/search").param("q", "Forge"))
.andExpect(status().isOk())
.andExpect(jsonPath("$").isArray());
}
// --- POST /generate -----------------------------------------------------
@Test
void generate_returns200() throws Exception {
when(generator.generate(any(), any())).thenReturn(
new ItemCatalogGenerator.GeneratedCatalog(
"Boutique magique",
"Objets enchantes",
List.of(CatalogItem.builder().name("Potion").price("50 po").build())));
GenerateRequest req = new GenerateRequest(campaignId, "une boutique de magie");
mockMvc.perform(post("/api/item-catalogs/generate")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(req)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.name").value("Boutique magique"));
}
@Test
void generate_returns502_whenBrainUnreachable() throws Exception {
when(generator.generate(any(), any()))
.thenThrow(new ItemCatalogGenerationException("Brain injoignable"));
GenerateRequest req = new GenerateRequest(campaignId, "boutique");
mockMvc.perform(post("/api/item-catalogs/generate")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(req)))
.andExpect(status().isBadGateway());
}
}

View File

@@ -0,0 +1,261 @@
package com.loremind.infrastructure.web.controller;
import com.loremind.application.licensing.ChannelSwitcherService;
import com.loremind.application.licensing.LicenseService;
import com.loremind.application.licensing.LicenseService.InstallException;
import com.loremind.domain.licensing.LicenseSnapshot;
import com.loremind.domain.licensing.LicenseStatus;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.util.Base64;
import static org.mockito.ArgumentMatchers.anyBoolean;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
/**
* Tests d'integration pour {@link LicenseController} (gestion licence Patreon).
* <p>
* {@code /api/license/**} exige le role ADMIN (HTTP Basic) : chaque requete porte
* l'entete d'auth construite a partir des identifiants de test (cf.
* src/test/resources/application.properties). Sans cet entete, la securite
* renverrait 401.
* <p>
* Les deux services applicatifs sont mockes : {@link LicenseService} (qui appelle
* sinon le relais OAuth distant + verification JWT) et {@link ChannelSwitcherService}
* (qui ecrit sinon dans un volume partage avec le sidecar). Aucun acces reseau ni
* fichier reel.
*/
@SpringBootTest
@AutoConfigureMockMvc
class LicenseControllerTest {
/** Identifiants definis dans src/test/resources/application.properties. */
private static final String ADMIN_AUTH = "Basic " + Base64.getEncoder()
.encodeToString("test-admin:test-admin-password".getBytes(StandardCharsets.UTF_8));
@Autowired private MockMvc mockMvc;
@MockBean private LicenseService licenseService;
@MockBean private ChannelSwitcherService channelSwitcher;
private LicenseSnapshot validSnapshot() {
return new LicenseSnapshot(
LicenseStatus.VALID, "user-42", "tier-1", "li-xyz",
Instant.now().plusSeconds(3600), Instant.now(), true, true);
}
@BeforeEach
void setUp() {
// Valeurs par defaut neutres ; chaque test surcharge ce dont il a besoin.
when(licenseService.isLicensingEnabled()).thenReturn(true);
when(licenseService.getCurrentSnapshot()).thenReturn(validSnapshot());
}
// --- Securite ----------------------------------------------------------
@Test
void getStatus_returns401_withoutAuth() throws Exception {
mockMvc.perform(get("/api/license"))
.andExpect(status().isUnauthorized());
}
// --- GET /api/license --------------------------------------------------
@Test
void getStatus_returns200() throws Exception {
mockMvc.perform(get("/api/license").header(HttpHeaders.AUTHORIZATION, ADMIN_AUTH))
.andExpect(status().isOk())
.andExpect(jsonPath("$.enabled").value(true))
.andExpect(jsonPath("$.status").value("VALID"))
.andExpect(jsonPath("$.patreonUserId").value("user-42"));
}
// --- GET /api/license/connect-url --------------------------------------
@Test
void getConnectUrl_returns200() throws Exception {
when(licenseService.buildConnectUrl()).thenReturn("https://relay/oauth?x=1");
mockMvc.perform(get("/api/license/connect-url").header(HttpHeaders.AUTHORIZATION, ADMIN_AUTH))
.andExpect(status().isOk())
.andExpect(jsonPath("$.url").value("https://relay/oauth?x=1"));
}
// --- POST /api/license/install -----------------------------------------
@Test
void install_returns200_onValidJwt() throws Exception {
when(licenseService.installToken("good-jwt")).thenReturn(validSnapshot());
mockMvc.perform(post("/api/license/install")
.header(HttpHeaders.AUTHORIZATION, ADMIN_AUTH)
.contentType(MediaType.APPLICATION_JSON)
.content("{\"jwt\":\"good-jwt\"}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.status").value("VALID"));
}
@Test
void install_returns400_whenJwtMissing() throws Exception {
mockMvc.perform(post("/api/license/install")
.header(HttpHeaders.AUTHORIZATION, ADMIN_AUTH)
.contentType(MediaType.APPLICATION_JSON)
.content("{\"jwt\":\"\"}"))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.error").value("missing jwt"));
}
@Test
void install_returns400_whenInstallFails() throws Exception {
when(licenseService.installToken("bad-jwt"))
.thenThrow(new InstallException("Invalid JWT: signature"));
mockMvc.perform(post("/api/license/install")
.header(HttpHeaders.AUTHORIZATION, ADMIN_AUTH)
.contentType(MediaType.APPLICATION_JSON)
.content("{\"jwt\":\"bad-jwt\"}"))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.error").value("Invalid JWT: signature"));
}
// --- DELETE /api/license -----------------------------------------------
@Test
void disconnect_returns204() throws Exception {
mockMvc.perform(delete("/api/license").header(HttpHeaders.AUTHORIZATION, ADMIN_AUTH))
.andExpect(status().isNoContent());
}
// --- POST /api/license/refresh -----------------------------------------
@Test
void refresh_returns200() throws Exception {
mockMvc.perform(post("/api/license/refresh").header(HttpHeaders.AUTHORIZATION, ADMIN_AUTH))
.andExpect(status().isOk())
.andExpect(jsonPath("$.status").value("VALID"));
}
// --- PUT /api/license/beta-channel -------------------------------------
@Test
void setBetaChannel_returns200() throws Exception {
when(licenseService.setBetaChannelEnabled(eq(false))).thenReturn(validSnapshot());
mockMvc.perform(put("/api/license/beta-channel")
.header(HttpHeaders.AUTHORIZATION, ADMIN_AUTH)
.contentType(MediaType.APPLICATION_JSON)
.content("{\"enabled\":false}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.status").value("VALID"));
}
@Test
void setBetaChannel_returns409_whenNoLicense() throws Exception {
when(licenseService.setBetaChannelEnabled(anyBoolean()))
.thenThrow(new IllegalStateException("No license installed"));
mockMvc.perform(put("/api/license/beta-channel")
.header(HttpHeaders.AUTHORIZATION, ADMIN_AUTH)
.contentType(MediaType.APPLICATION_JSON)
.content("{\"enabled\":true}"))
.andExpect(status().isConflict())
.andExpect(jsonPath("$.error").value("No license installed"));
}
// --- GET /api/license/channel ------------------------------------------
@Test
void getChannel_returns200() throws Exception {
when(channelSwitcher.getCurrentChannel()).thenReturn(ChannelSwitcherService.Channel.STABLE);
when(channelSwitcher.isSwitcherAvailable()).thenReturn(true);
when(channelSwitcher.getLastResult()).thenReturn(null);
mockMvc.perform(get("/api/license/channel").header(HttpHeaders.AUTHORIZATION, ADMIN_AUTH))
.andExpect(status().isOk())
.andExpect(jsonPath("$.currentChannel").value("stable"))
.andExpect(jsonPath("$.switcherAvailable").value(true));
}
// --- POST /api/license/channel/switch ----------------------------------
@Test
void switchChannel_returns400_whenChannelMissing() throws Exception {
mockMvc.perform(post("/api/license/channel/switch")
.header(HttpHeaders.AUTHORIZATION, ADMIN_AUTH)
.contentType(MediaType.APPLICATION_JSON)
.content("{}"))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.error").value("missing channel"));
}
@Test
void switchChannel_returns400_whenChannelInvalid() throws Exception {
mockMvc.perform(post("/api/license/channel/switch")
.header(HttpHeaders.AUTHORIZATION, ADMIN_AUTH)
.contentType(MediaType.APPLICATION_JSON)
.content("{\"channel\":\"nightly\"}"))
.andExpect(status().isBadRequest());
}
@Test
void switchChannel_returns403_whenBetaWithoutLicense() throws Exception {
// Snapshot EXPIRED -> pas d'acces beta.
when(licenseService.getCurrentSnapshot()).thenReturn(new LicenseSnapshot(
LicenseStatus.EXPIRED, null, null, null, null, null, false, false));
mockMvc.perform(post("/api/license/channel/switch")
.header(HttpHeaders.AUTHORIZATION, ADMIN_AUTH)
.contentType(MediaType.APPLICATION_JSON)
.content("{\"channel\":\"beta\"}"))
.andExpect(status().isForbidden());
}
@Test
void switchChannel_returns503_whenSwitcherUnavailable() throws Exception {
// Licence VALID (autorise beta) mais sidecar absent.
when(channelSwitcher.isSwitcherAvailable()).thenReturn(false);
mockMvc.perform(post("/api/license/channel/switch")
.header(HttpHeaders.AUTHORIZATION, ADMIN_AUTH)
.contentType(MediaType.APPLICATION_JSON)
.content("{\"channel\":\"beta\"}"))
.andExpect(status().isServiceUnavailable());
}
@Test
void switchChannel_returns202_onSuccess() throws Exception {
when(channelSwitcher.isSwitcherAvailable()).thenReturn(true);
when(channelSwitcher.requestSwitch(ChannelSwitcherService.Channel.STABLE))
.thenReturn("cmd-1");
mockMvc.perform(post("/api/license/channel/switch")
.header(HttpHeaders.AUTHORIZATION, ADMIN_AUTH)
.contentType(MediaType.APPLICATION_JSON)
.content("{\"channel\":\"stable\"}"))
.andExpect(status().isAccepted())
.andExpect(jsonPath("$.id").value("cmd-1"))
.andExpect(jsonPath("$.channel").value("stable"));
}
@Test
void switchChannel_returns500_whenWriteFails() throws Exception {
when(channelSwitcher.isSwitcherAvailable()).thenReturn(true);
doThrow(new IOException("disk full"))
.when(channelSwitcher).requestSwitch(ChannelSwitcherService.Channel.STABLE);
mockMvc.perform(post("/api/license/channel/switch")
.header(HttpHeaders.AUTHORIZATION, ADMIN_AUTH)
.contentType(MediaType.APPLICATION_JSON)
.content("{\"channel\":\"stable\"}"))
.andExpect(status().isInternalServerError());
}
}

View File

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

View File

@@ -0,0 +1,124 @@
package com.loremind.infrastructure.web.controller;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.loremind.domain.campaigncontext.Campaign;
import com.loremind.domain.campaigncontext.Npc;
import com.loremind.domain.campaigncontext.ports.CampaignRepository;
import com.loremind.domain.campaigncontext.ports.NpcRepository;
import com.loremind.infrastructure.web.dto.campaigncontext.NpcDTO;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.transaction.annotation.Transactional;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
/** Tests d'intégration CRUD du NpcController. */
@SpringBootTest
@AutoConfigureMockMvc
@Transactional
class NpcControllerTest {
@Autowired private MockMvc mockMvc;
@Autowired private ObjectMapper objectMapper;
@Autowired private CampaignRepository campaignRepository;
@Autowired private NpcRepository npcRepository;
private String campaignId;
@BeforeEach
void setUp() {
campaignId = campaignRepository.save(Campaign.builder().name("C").description("").build()).getId();
}
@Test
void create_returns200() throws Exception {
NpcDTO dto = new NpcDTO();
dto.setName("Gandalf");
dto.setCampaignId(campaignId);
mockMvc.perform(post("/api/npcs")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(dto)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.name").value("Gandalf"));
}
@Test
void getById_returns200() throws Exception {
Npc saved = npcRepository.save(Npc.builder().campaignId(campaignId).name("N").order(0).build());
mockMvc.perform(get("/api/npcs/{id}", saved.getId()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.name").value("N"));
}
@Test
void getById_returns404_whenMissing() throws Exception {
mockMvc.perform(get("/api/npcs/{id}", "999999999"))
.andExpect(status().isNotFound());
}
@Test
void getByCampaign_returnsArray() throws Exception {
npcRepository.save(Npc.builder().campaignId(campaignId).name("A").order(0).build());
mockMvc.perform(get("/api/npcs/campaign/{campaignId}", campaignId))
.andExpect(status().isOk())
.andExpect(jsonPath("$").isArray());
}
@Test
void search_returnsArray() throws Exception {
npcRepository.save(Npc.builder().campaignId(campaignId).name("Frodon").order(0).build());
mockMvc.perform(get("/api/npcs/search").param("q", "Frodon"))
.andExpect(status().isOk())
.andExpect(jsonPath("$").isArray());
}
@Test
void getByLore_returnsArray() throws Exception {
mockMvc.perform(get("/api/npcs/lore/{loreId}", "lore-1"))
.andExpect(status().isOk())
.andExpect(jsonPath("$").isArray());
}
@Test
void update_returns200() throws Exception {
Npc saved = npcRepository.save(Npc.builder().campaignId(campaignId).name("old").order(0).build());
NpcDTO dto = new NpcDTO();
dto.setName("new");
dto.setCampaignId(campaignId);
dto.setOrder(0);
mockMvc.perform(put("/api/npcs/{id}", saved.getId())
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(dto)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.name").value("new"));
}
@Test
void update_returns400_whenMissing() throws Exception {
NpcDTO dto = new NpcDTO();
dto.setName("x");
dto.setCampaignId(campaignId);
dto.setOrder(0);
mockMvc.perform(put("/api/npcs/{id}", "999999999")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(dto)))
.andExpect(status().isBadRequest());
}
@Test
void delete_returns204() throws Exception {
Npc saved = npcRepository.save(Npc.builder().campaignId(campaignId).name("X").order(0).build());
mockMvc.perform(delete("/api/npcs/{id}", saved.getId()))
.andExpect(status().isNoContent());
}
}

View File

@@ -0,0 +1,87 @@
package com.loremind.infrastructure.web.controller;
import com.loremind.application.generationcontext.GeneratePageValuesUseCase;
import com.loremind.domain.generationcontext.ports.AiProviderException;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.test.web.servlet.MockMvc;
import java.util.Map;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
/**
* Tests d'integration pour {@link PageGenerationController} (generation IA de Page).
* <p>
* Le use case {@link GeneratePageValuesUseCase} est mocke : il orchestre l'appel
* au Brain (AiProvider), indisponible en test. On verifie le mapping des
* exceptions du use case vers les codes HTTP :
* <ul>
* <li>OK -> 200 + {@code {values:{...}}}</li>
* <li>IllegalArgumentException (page introuvable) -> 404</li>
* <li>AiProviderException (Brain HS) -> 502</li>
* <li>IllegalStateException "aucun champ" -> 422</li>
* <li>IllegalStateException autre (incoherence BDD) -> 500</li>
* </ul>
*/
@SpringBootTest
@AutoConfigureMockMvc
class PageGenerationControllerTest {
@Autowired private MockMvc mockMvc;
@MockBean private GeneratePageValuesUseCase generatePageValuesUseCase;
@Test
void generate_returns200_withSuggestions() throws Exception {
when(generatePageValuesUseCase.execute(eq("p1")))
.thenReturn(Map.of("nom", "Aldric", "race", "Elfe"));
mockMvc.perform(post("/api/pages/{id}/generate", "p1"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.values.nom").value("Aldric"))
.andExpect(jsonPath("$.values.race").value("Elfe"));
}
@Test
void generate_returns404_whenPageMissing() throws Exception {
when(generatePageValuesUseCase.execute(eq("missing")))
.thenThrow(new IllegalArgumentException("Page non trouvée"));
mockMvc.perform(post("/api/pages/{id}/generate", "missing"))
.andExpect(status().isNotFound());
}
@Test
void generate_returns502_whenBrainDown() throws Exception {
when(generatePageValuesUseCase.execute(eq("p1")))
.thenThrow(new AiProviderException("Brain unreachable"));
mockMvc.perform(post("/api/pages/{id}/generate", "p1"))
.andExpect(status().isBadGateway());
}
@Test
void generate_returns422_whenTemplateHasNoFields() throws Exception {
when(generatePageValuesUseCase.execute(eq("p1")))
.thenThrow(new IllegalStateException("Le template 'X' n'a aucun champ texte à générer."));
mockMvc.perform(post("/api/pages/{id}/generate", "p1"))
.andExpect(status().isUnprocessableEntity());
}
@Test
void generate_returns500_whenBddInconsistent() throws Exception {
when(generatePageValuesUseCase.execute(eq("p1")))
.thenThrow(new IllegalStateException("Template introuvable (id=t1)"));
mockMvc.perform(post("/api/pages/{id}/generate", "p1"))
.andExpect(status().isInternalServerError());
}
}

View File

@@ -0,0 +1,160 @@
package com.loremind.infrastructure.web.controller;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.loremind.domain.campaigncontext.Campaign;
import com.loremind.domain.campaigncontext.ports.CampaignRepository;
import com.loremind.domain.playcontext.Playthrough;
import com.loremind.domain.playcontext.Session;
import com.loremind.domain.playcontext.ports.PlaythroughRepository;
import com.loremind.domain.playcontext.ports.SessionRepository;
import com.loremind.infrastructure.web.dto.playcontext.PlaythroughDTO;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.transaction.annotation.Transactional;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
/**
* Tests d'intégration du PlaythroughController.
* Fixture parente : Campaign (un Playthrough référence une campagne).
*/
@SpringBootTest
@AutoConfigureMockMvc
@Transactional
class PlaythroughControllerTest {
@Autowired private MockMvc mockMvc;
@Autowired private ObjectMapper objectMapper;
@Autowired private CampaignRepository campaignRepository;
@Autowired private PlaythroughRepository playthroughRepository;
@Autowired private SessionRepository sessionRepository;
private String campaignId;
@BeforeEach
void setUp() {
campaignId = campaignRepository.save(Campaign.builder().name("C").description("").build()).getId();
}
private Playthrough savePlaythrough() {
return playthroughRepository.save(
Playthrough.builder().campaignId(campaignId).name("Partie").description("d").build());
}
@Test
void create_returns200() throws Exception {
PlaythroughDTO dto = new PlaythroughDTO();
dto.setCampaignId(campaignId);
dto.setName("Table du vendredi");
dto.setDescription("desc");
mockMvc.perform(post("/api/playthroughs")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(dto)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.name").value("Table du vendredi"));
}
@Test
void create_returns400_whenCampaignMissing() throws Exception {
PlaythroughDTO dto = new PlaythroughDTO();
dto.setCampaignId("999999999");
dto.setName("X");
mockMvc.perform(post("/api/playthroughs")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(dto)))
.andExpect(status().isBadRequest());
}
@Test
void getById_returns200() throws Exception {
Playthrough p = savePlaythrough();
mockMvc.perform(get("/api/playthroughs/{id}", p.getId()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.name").value("Partie"));
}
@Test
void getById_returns404_whenMissing() throws Exception {
mockMvc.perform(get("/api/playthroughs/{id}", "999999999"))
.andExpect(status().isNotFound());
}
@Test
void list_byCampaign_returnsArray() throws Exception {
savePlaythrough();
mockMvc.perform(get("/api/playthroughs").param("campaignId", campaignId))
.andExpect(status().isOk())
.andExpect(jsonPath("$").isArray());
}
@Test
void list_withoutCampaign_returnsEmptyArray() throws Exception {
savePlaythrough();
mockMvc.perform(get("/api/playthroughs"))
.andExpect(status().isOk())
.andExpect(jsonPath("$").isArray());
}
@Test
void update_returns200() throws Exception {
Playthrough p = savePlaythrough();
PlaythroughDTO dto = new PlaythroughDTO();
dto.setName("Renommée");
dto.setDescription("nouvelle desc");
mockMvc.perform(put("/api/playthroughs/{id}", p.getId())
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(dto)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.name").value("Renommée"));
}
@Test
void update_returns400_whenMissing() throws Exception {
PlaythroughDTO dto = new PlaythroughDTO();
dto.setName("X");
mockMvc.perform(put("/api/playthroughs/{id}", "999999999")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(dto)))
.andExpect(status().isBadRequest());
}
@Test
void delete_returns204() throws Exception {
Playthrough p = savePlaythrough();
mockMvc.perform(delete("/api/playthroughs/{id}", p.getId()))
.andExpect(status().isNoContent());
}
@Test
void delete_returns400_whenMissing() throws Exception {
mockMvc.perform(delete("/api/playthroughs/{id}", "999999999"))
.andExpect(status().isBadRequest());
}
@Test
void deletionImpact_returns200() throws Exception {
Playthrough p = savePlaythrough();
sessionRepository.save(Session.builder()
.name("S").playthroughId(p.getId())
.startedAt(java.time.LocalDateTime.now()).build());
mockMvc.perform(get("/api/playthroughs/{id}/deletion-impact", p.getId()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.sessions").value(1));
}
@Test
void deletionImpact_returns404_whenMissing() throws Exception {
mockMvc.perform(get("/api/playthroughs/{id}/deletion-impact", "999999999"))
.andExpect(status().isNotFound());
}
}

View File

@@ -0,0 +1,103 @@
package com.loremind.infrastructure.web.controller;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.loremind.domain.campaigncontext.Campaign;
import com.loremind.domain.campaigncontext.ports.CampaignRepository;
import com.loremind.domain.playcontext.Playthrough;
import com.loremind.domain.playcontext.ports.PlaythroughFlagRepository;
import com.loremind.domain.playcontext.ports.PlaythroughRepository;
import com.loremind.infrastructure.web.dto.playcontext.PlaythroughFlagDTO;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.transaction.annotation.Transactional;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
/**
* Tests d'intégration de {@link PlaythroughFlagController}.
* Couvre les 3 endpoints : list (GET), setFlag (PUT), deleteFlag (DELETE).
* Les flags sont indexés par playthroughId : on crée une campagne puis un playthrough en fixture.
*/
@SpringBootTest
@AutoConfigureMockMvc
@Transactional
class PlaythroughFlagControllerTest {
@Autowired private MockMvc mockMvc;
@Autowired private ObjectMapper objectMapper;
@Autowired private CampaignRepository campaignRepository;
@Autowired private PlaythroughRepository playthroughRepository;
@Autowired private PlaythroughFlagRepository flagRepository;
private String playthroughId;
@BeforeEach
void setUp() {
// Chaîne de fixtures : campaign -> playthrough
String campId = campaignRepository.save(Campaign.builder().name("C").description("").build()).getId();
playthroughId = playthroughRepository.save(
Playthrough.builder().campaignId(campId).name("Table").description("").build()).getId();
}
@Test
void list_returnsEmptyArray_whenNoFlags() throws Exception {
mockMvc.perform(get("/api/playthroughs/{pid}/flags", playthroughId))
.andExpect(status().isOk())
.andExpect(jsonPath("$").isArray());
}
@Test
void list_returnsExistingFlags() throws Exception {
// Pré-positionne un flag via le repo réel
flagRepository.setFlag(playthroughId, "porte_ouverte", true);
mockMvc.perform(get("/api/playthroughs/{pid}/flags", playthroughId))
.andExpect(status().isOk())
.andExpect(jsonPath("$").isArray())
.andExpect(jsonPath("$[0].name").value("porte_ouverte"))
.andExpect(jsonPath("$[0].value").value(true));
}
@Test
void setFlag_returns200_andEcho() throws Exception {
PlaythroughFlagDTO body = new PlaythroughFlagDTO("dragon_vaincu", true);
mockMvc.perform(put("/api/playthroughs/{pid}/flags/{name}", playthroughId, "dragon_vaincu")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(body)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.name").value("dragon_vaincu"))
.andExpect(jsonPath("$.value").value(true));
}
@Test
void setFlag_false_returns200() throws Exception {
PlaythroughFlagDTO body = new PlaythroughFlagDTO("ignore", false);
mockMvc.perform(put("/api/playthroughs/{pid}/flags/{name}", playthroughId, "ignore")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(body)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.value").value(false));
}
@Test
void deleteFlag_returns204() throws Exception {
flagRepository.setFlag(playthroughId, "a_supprimer", true);
mockMvc.perform(delete("/api/playthroughs/{pid}/flags/{name}", playthroughId, "a_supprimer"))
.andExpect(status().isNoContent());
}
@Test
void deleteFlag_returns204_whenAbsent() throws Exception {
// deleteFlag est idempotent : pas d'erreur même si le flag n'existe pas
mockMvc.perform(delete("/api/playthroughs/{pid}/flags/{name}", playthroughId, "inexistant"))
.andExpect(status().isNoContent());
}
}

View File

@@ -0,0 +1,127 @@
package com.loremind.infrastructure.web.controller;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.loremind.domain.campaigncontext.Arc;
import com.loremind.domain.campaigncontext.Campaign;
import com.loremind.domain.campaigncontext.Chapter;
import com.loremind.domain.campaigncontext.ProgressionStatus;
import com.loremind.domain.campaigncontext.ports.ArcRepository;
import com.loremind.domain.campaigncontext.ports.CampaignRepository;
import com.loremind.domain.campaigncontext.ports.ChapterRepository;
import com.loremind.domain.playcontext.Playthrough;
import com.loremind.domain.playcontext.ports.PlaythroughRepository;
import com.loremind.domain.playcontext.ports.QuestProgressionRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.transaction.annotation.Transactional;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
/**
* Tests d'intégration de {@link QuestProgressionController}.
* Couvre list (GET, map chapterId->status) et setStatus (PUT) avec :
* - statut valide IN_PROGRESS / COMPLETED
* - statut vide/null => NOT_STARTED (suppression de ligne)
* - statut invalide => 400 (badRequest renvoyé directement par le contrôleur)
* <p>
* Le {@code chapterId} doit être un id de Chapter REEL (clé numérique) : on crée
* donc la chaîne campaign -> arc -> chapter en fixture.
*/
@SpringBootTest
@AutoConfigureMockMvc
@Transactional
class QuestProgressionControllerTest {
@Autowired private MockMvc mockMvc;
@Autowired private ObjectMapper objectMapper;
@Autowired private CampaignRepository campaignRepository;
@Autowired private ArcRepository arcRepository;
@Autowired private ChapterRepository chapterRepository;
@Autowired private PlaythroughRepository playthroughRepository;
@Autowired private QuestProgressionRepository repo;
private String playthroughId;
private String chapterId;
@BeforeEach
void setUp() {
// Chaîne de fixtures : campaign -> playthrough (+ arc -> chapter pour un chapterId reel).
String campId = campaignRepository.save(Campaign.builder().name("C").description("").build()).getId();
String arcId = arcRepository.save(Arc.builder().campaignId(campId).name("A").order(0).build()).getId();
chapterId = chapterRepository.save(Chapter.builder().arcId(arcId).name("Ch").order(0).build()).getId();
playthroughId = playthroughRepository.save(
Playthrough.builder().campaignId(campId).name("Table").description("").build()).getId();
}
@Test
void list_returnsEmptyMap_whenNoProgressions() throws Exception {
mockMvc.perform(get("/api/playthroughs/{pid}/quest-progressions", playthroughId))
.andExpect(status().isOk())
.andExpect(jsonPath("$").isMap());
}
@Test
void list_returnsStatusMap() throws Exception {
// Pré-positionne une progression explicite via le repo réel.
repo.setStatus(playthroughId, chapterId, ProgressionStatus.IN_PROGRESS);
mockMvc.perform(get("/api/playthroughs/{pid}/quest-progressions", playthroughId))
.andExpect(status().isOk())
.andExpect(jsonPath("$['" + chapterId + "']").value("IN_PROGRESS"));
}
@Test
void setStatus_inProgress_returns204() throws Exception {
mockMvc.perform(put("/api/playthroughs/{pid}/quest-progressions/{cid}", playthroughId, chapterId)
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(
new QuestProgressionController.SetStatusRequest("IN_PROGRESS"))))
.andExpect(status().isNoContent());
}
@Test
void setStatus_completed_returns204() throws Exception {
mockMvc.perform(put("/api/playthroughs/{pid}/quest-progressions/{cid}", playthroughId, chapterId)
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(
new QuestProgressionController.SetStatusRequest("COMPLETED"))))
.andExpect(status().isNoContent());
}
@Test
void setStatus_nullStatus_returns204_asNotStarted() throws Exception {
// status null => NOT_STARTED (branche : pas de parsing)
mockMvc.perform(put("/api/playthroughs/{pid}/quest-progressions/{cid}", playthroughId, chapterId)
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(
new QuestProgressionController.SetStatusRequest(null))))
.andExpect(status().isNoContent());
}
@Test
void setStatus_blankStatus_returns204_asNotStarted() throws Exception {
// status vide => NOT_STARTED (branche isBlank)
mockMvc.perform(put("/api/playthroughs/{pid}/quest-progressions/{cid}", playthroughId, chapterId)
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(
new QuestProgressionController.SetStatusRequest(" "))))
.andExpect(status().isNoContent());
}
@Test
void setStatus_invalidStatus_returns400() throws Exception {
// valeur d'enum inconnue => IllegalArgumentException attrapée => badRequest direct
mockMvc.perform(put("/api/playthroughs/{pid}/quest-progressions/{cid}", playthroughId, chapterId)
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(
new QuestProgressionController.SetStatusRequest("NOPE"))))
.andExpect(status().isBadRequest());
}
}

View File

@@ -0,0 +1,218 @@
package com.loremind.infrastructure.web.controller;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.loremind.domain.campaigncontext.Campaign;
import com.loremind.domain.campaigncontext.RandomTable;
import com.loremind.domain.campaigncontext.RandomTableEntry;
import com.loremind.domain.campaigncontext.ports.CampaignRepository;
import com.loremind.domain.campaigncontext.ports.RandomTableGenerationException;
import com.loremind.domain.campaigncontext.ports.RandomTableGenerator;
import com.loremind.domain.campaigncontext.ports.RandomTableRepository;
import com.loremind.infrastructure.web.controller.RandomTableController.GenerateRequest;
import com.loremind.infrastructure.web.controller.RandomTableController.ImproviseRequest;
import com.loremind.infrastructure.web.dto.campaigncontext.RandomTableDTO;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
/**
* Tests d'integration pour {@link RandomTableController} (CRUD + recherche +
* generation IA d'une table + improvisation narrative).
* <p>
* Le port {@link RandomTableGenerator} (client du Brain) est mocke : sinon les
* endpoints /generate et /improvise feraient un vrai appel reseau au service IA
* (indisponible en test). Les repos reels servent aux fixtures.
*/
@SpringBootTest
@AutoConfigureMockMvc
@Transactional
class RandomTableControllerTest {
@Autowired private MockMvc mockMvc;
@Autowired private ObjectMapper objectMapper;
@Autowired private RandomTableRepository tableRepository;
@Autowired private CampaignRepository campaignRepository;
@MockBean private RandomTableGenerator generator;
private String campaignId;
@BeforeEach
void setUp() {
campaignId = campaignRepository.save(
Campaign.builder().name("Camp").description("desc").build()).getId();
}
private RandomTableDTO dto(String name) {
RandomTableDTO dto = new RandomTableDTO();
dto.setName(name);
dto.setDescription("Rencontres aleatoires");
dto.setDiceFormula("1d20");
dto.setIcon("dice");
dto.setCampaignId(campaignId);
dto.setOrder(0);
return dto;
}
// --- POST / -------------------------------------------------------------
@Test
void create_returns200() throws Exception {
mockMvc.perform(post("/api/random-tables")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(dto("Rencontres"))))
.andExpect(status().isOk())
.andExpect(jsonPath("$.name").value("Rencontres"))
.andExpect(jsonPath("$.diceFormula").value("1d20"));
}
// --- GET /{id} ----------------------------------------------------------
@Test
void getById_returns200() throws Exception {
RandomTable saved = tableRepository.save(RandomTable.builder()
.name("Butin").diceFormula("1d6").campaignId(campaignId).order(0).build());
mockMvc.perform(get("/api/random-tables/{id}", saved.getId()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.name").value("Butin"));
}
@Test
void getById_returns404_whenMissing() throws Exception {
mockMvc.perform(get("/api/random-tables/{id}", "999999999"))
.andExpect(status().isNotFound());
}
// --- GET /campaign/{campaignId} -----------------------------------------
@Test
void getByCampaign_returnsArray() throws Exception {
tableRepository.save(RandomTable.builder()
.name("A").diceFormula("1d20").campaignId(campaignId).order(0).build());
mockMvc.perform(get("/api/random-tables/campaign/{campaignId}", campaignId))
.andExpect(status().isOk())
.andExpect(jsonPath("$").isArray())
.andExpect(jsonPath("$[0].name").value("A"));
}
// --- PUT /{id} ----------------------------------------------------------
@Test
void update_returns200() throws Exception {
RandomTable saved = tableRepository.save(RandomTable.builder()
.name("old").diceFormula("1d20").campaignId(campaignId).order(0).build());
mockMvc.perform(put("/api/random-tables/{id}", saved.getId())
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(dto("new"))))
.andExpect(status().isOk())
.andExpect(jsonPath("$.name").value("new"));
}
@Test
void update_returns400_whenMissing() throws Exception {
// Le service leve IllegalArgumentException -> GlobalExceptionHandler -> 400.
mockMvc.perform(put("/api/random-tables/{id}", "999999999")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(dto("x"))))
.andExpect(status().isBadRequest());
}
// --- DELETE /{id} -------------------------------------------------------
@Test
void delete_returns204() throws Exception {
RandomTable saved = tableRepository.save(RandomTable.builder()
.name("X").diceFormula("1d20").campaignId(campaignId).order(0).build());
mockMvc.perform(delete("/api/random-tables/{id}", saved.getId()))
.andExpect(status().isNoContent());
}
// --- GET /search --------------------------------------------------------
@Test
void search_returnsArray() throws Exception {
tableRepository.save(RandomTable.builder()
.name("Complications urbaines").diceFormula("1d20")
.campaignId(campaignId).order(0).build());
mockMvc.perform(get("/api/random-tables/search").param("q", "Complications"))
.andExpect(status().isOk())
.andExpect(jsonPath("$").isArray());
}
// --- POST /generate -----------------------------------------------------
@Test
void generate_returns200() throws Exception {
when(generator.generate(any(), any(), any())).thenReturn(
new RandomTableGenerator.GeneratedTable(
"Rencontres en foret",
"Que croisez-vous ?",
List.of(RandomTableEntry.builder()
.minRoll(1).maxRoll(10).label("Gobelins").build())));
GenerateRequest req = new GenerateRequest(campaignId, "rencontres en foret", "1d20");
mockMvc.perform(post("/api/random-tables/generate")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(req)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.name").value("Rencontres en foret"));
}
@Test
void generate_returns502_whenBrainUnreachable() throws Exception {
when(generator.generate(any(), any(), any()))
.thenThrow(new RandomTableGenerationException("Brain injoignable"));
GenerateRequest req = new GenerateRequest(campaignId, "rencontres", "1d20");
mockMvc.perform(post("/api/random-tables/generate")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(req)))
.andExpect(status().isBadGateway());
}
// --- POST /improvise ----------------------------------------------------
@Test
void improvise_returns200() throws Exception {
when(generator.improvise(any(), any(), any(), any()))
.thenReturn("Les gobelins surgissent des fourres...");
ImproviseRequest req = new ImproviseRequest(
campaignId, "Rencontres", "Gobelins", "3 gobelins armes");
mockMvc.perform(post("/api/random-tables/improvise")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(req)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.narration").value("Les gobelins surgissent des fourres..."));
}
@Test
void improvise_returns502_whenBrainUnreachable() throws Exception {
when(generator.improvise(any(), any(), any(), any()))
.thenThrow(new RandomTableGenerationException("Brain injoignable"));
ImproviseRequest req = new ImproviseRequest(
campaignId, "Rencontres", "Gobelins", "3 gobelins");
mockMvc.perform(post("/api/random-tables/improvise")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(req)))
.andExpect(status().isBadGateway());
}
}

View File

@@ -0,0 +1,188 @@
package com.loremind.infrastructure.web.controller;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.loremind.domain.campaigncontext.Campaign;
import com.loremind.domain.campaigncontext.ports.CampaignRepository;
import com.loremind.domain.playcontext.Playthrough;
import com.loremind.domain.playcontext.Session;
import com.loremind.domain.playcontext.ports.PlaythroughRepository;
import com.loremind.domain.playcontext.ports.SessionRepository;
import com.loremind.infrastructure.web.controller.SessionController.RenameSessionRequest;
import com.loremind.infrastructure.web.controller.SessionController.StartSessionRequest;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
/**
* Tests d'intégration du SessionController.
* Chaîne de fixtures : Campaign -> Playthrough (une session appartient à une partie).
*/
@SpringBootTest
@AutoConfigureMockMvc
@Transactional
class SessionControllerTest {
@Autowired private MockMvc mockMvc;
@Autowired private ObjectMapper objectMapper;
@Autowired private CampaignRepository campaignRepository;
@Autowired private PlaythroughRepository playthroughRepository;
@Autowired private SessionRepository sessionRepository;
private String playthroughId;
@BeforeEach
void setUp() {
String campaignId = campaignRepository.save(
Campaign.builder().name("C").description("").build()).getId();
playthroughId = playthroughRepository.save(
Playthrough.builder().campaignId(campaignId).name("Partie").build()).getId();
}
private Session saveSession() {
return sessionRepository.save(Session.builder()
.name("Session test")
.playthroughId(playthroughId)
.startedAt(LocalDateTime.now())
.build());
}
@Test
void startSession_returns200() throws Exception {
StartSessionRequest req = new StartSessionRequest(playthroughId);
mockMvc.perform(post("/api/sessions")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(req)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.playthroughId").value(playthroughId))
.andExpect(jsonPath("$.active").value(true));
}
@Test
void startSession_returns400_whenPlaythroughMissing() throws Exception {
StartSessionRequest req = new StartSessionRequest("999999999");
mockMvc.perform(post("/api/sessions")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(req)))
.andExpect(status().isBadRequest());
}
@Test
void getSessions_all_returnsArray() throws Exception {
saveSession();
mockMvc.perform(get("/api/sessions"))
.andExpect(status().isOk())
.andExpect(jsonPath("$").isArray());
}
@Test
void getSessions_byPlaythrough_returnsArray() throws Exception {
saveSession();
mockMvc.perform(get("/api/sessions").param("playthroughId", playthroughId))
.andExpect(status().isOk())
.andExpect(jsonPath("$").isArray());
}
@Test
void getActive_global_returnsActive() throws Exception {
saveSession();
mockMvc.perform(get("/api/sessions/active"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.active").value(true));
}
@Test
void getActive_byPlaythrough_returnsActive() throws Exception {
saveSession();
mockMvc.perform(get("/api/sessions/active").param("playthroughId", playthroughId))
.andExpect(status().isOk())
.andExpect(jsonPath("$.playthroughId").value(playthroughId));
}
@Test
void getActive_returns204_whenNone() throws Exception {
// Session terminée -> aucune active
sessionRepository.save(Session.builder()
.name("ended").playthroughId(playthroughId)
.startedAt(LocalDateTime.now().minusHours(2))
.endedAt(LocalDateTime.now().minusHours(1))
.build());
mockMvc.perform(get("/api/sessions/active").param("playthroughId", playthroughId))
.andExpect(status().isNoContent());
}
@Test
void getById_returns200() throws Exception {
Session s = saveSession();
mockMvc.perform(get("/api/sessions/{id}", s.getId()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.name").value("Session test"));
}
@Test
void getById_returns404_whenMissing() throws Exception {
mockMvc.perform(get("/api/sessions/{id}", "999999999"))
.andExpect(status().isNotFound());
}
@Test
void endSession_returns200() throws Exception {
Session s = saveSession();
mockMvc.perform(post("/api/sessions/{id}/end", s.getId()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.active").value(false));
}
@Test
void endSession_returns400_whenMissing() throws Exception {
mockMvc.perform(post("/api/sessions/{id}/end", "999999999"))
.andExpect(status().isBadRequest());
}
@Test
void renameSession_returns200() throws Exception {
Session s = saveSession();
RenameSessionRequest req = new RenameSessionRequest("Nouveau nom");
mockMvc.perform(patch("/api/sessions/{id}", s.getId())
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(req)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.name").value("Nouveau nom"));
}
@Test
void renameSession_returns400_whenBlankName() throws Exception {
Session s = saveSession();
RenameSessionRequest req = new RenameSessionRequest(" ");
mockMvc.perform(patch("/api/sessions/{id}", s.getId())
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(req)))
.andExpect(status().isBadRequest());
}
@Test
void deleteSession_returns204() throws Exception {
Session s = saveSession();
mockMvc.perform(delete("/api/sessions/{id}", s.getId()))
.andExpect(status().isNoContent());
}
@Test
void deleteSession_returns400_whenMissing() throws Exception {
mockMvc.perform(delete("/api/sessions/{id}", "999999999"))
.andExpect(status().isBadRequest());
}
}

View File

@@ -0,0 +1,152 @@
package com.loremind.infrastructure.web.controller;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.loremind.domain.campaigncontext.Campaign;
import com.loremind.domain.campaigncontext.ports.CampaignRepository;
import com.loremind.domain.playcontext.EntryType;
import com.loremind.domain.playcontext.Playthrough;
import com.loremind.domain.playcontext.Session;
import com.loremind.domain.playcontext.SessionEntry;
import com.loremind.domain.playcontext.ports.PlaythroughRepository;
import com.loremind.domain.playcontext.ports.SessionEntryRepository;
import com.loremind.domain.playcontext.ports.SessionRepository;
import com.loremind.infrastructure.web.controller.SessionEntryController.EntryRequest;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
/**
* Tests d'intégration du SessionEntryController (endpoints imbriqués sous une Session).
* Chaîne de fixtures : Campaign -> Playthrough -> Session.
*/
@SpringBootTest
@AutoConfigureMockMvc
@Transactional
class SessionEntryControllerTest {
@Autowired private MockMvc mockMvc;
@Autowired private ObjectMapper objectMapper;
@Autowired private CampaignRepository campaignRepository;
@Autowired private PlaythroughRepository playthroughRepository;
@Autowired private SessionRepository sessionRepository;
@Autowired private SessionEntryRepository entryRepository;
private String sessionId;
@BeforeEach
void setUp() {
String campaignId = campaignRepository.save(
Campaign.builder().name("C").description("").build()).getId();
String playthroughId = playthroughRepository.save(
Playthrough.builder().campaignId(campaignId).name("Partie").build()).getId();
sessionId = sessionRepository.save(Session.builder()
.name("Session")
.playthroughId(playthroughId)
.startedAt(LocalDateTime.now())
.build()).getId();
}
private SessionEntry saveEntry() {
return entryRepository.save(SessionEntry.builder()
.sessionId(sessionId)
.type(EntryType.NOTE)
.content("Contenu initial")
.occurredAt(LocalDateTime.now())
.build());
}
@Test
void getEntries_returnsArray() throws Exception {
saveEntry();
mockMvc.perform(get("/api/sessions/{sessionId}/entries", sessionId))
.andExpect(status().isOk())
.andExpect(jsonPath("$").isArray());
}
@Test
void createEntry_returns200() throws Exception {
EntryRequest req = new EntryRequest(EntryType.EVENT, "Combat gagné", LocalDateTime.now());
mockMvc.perform(post("/api/sessions/{sessionId}/entries", sessionId)
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(req)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.content").value("Combat gagné"))
.andExpect(jsonPath("$.type").value("EVENT"));
}
@Test
void createEntry_defaultsTypeToNote_whenNull() throws Exception {
EntryRequest req = new EntryRequest(null, "Note sans type", null);
mockMvc.perform(post("/api/sessions/{sessionId}/entries", sessionId)
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(req)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.type").value("NOTE"));
}
@Test
void createEntry_returns400_whenSessionMissing() throws Exception {
EntryRequest req = new EntryRequest(EntryType.NOTE, "x", null);
mockMvc.perform(post("/api/sessions/{sessionId}/entries", "999999999")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(req)))
.andExpect(status().isBadRequest());
}
@Test
void createEntry_returns400_whenContentBlank() throws Exception {
EntryRequest req = new EntryRequest(EntryType.NOTE, " ", null);
mockMvc.perform(post("/api/sessions/{sessionId}/entries", sessionId)
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(req)))
.andExpect(status().isBadRequest());
}
@Test
void updateEntry_returns200() throws Exception {
SessionEntry e = saveEntry();
EntryRequest req = new EntryRequest(EntryType.DICE_ROLL, "Jet modifié", LocalDateTime.now());
mockMvc.perform(put("/api/sessions/{sessionId}/entries/{entryId}", sessionId, e.getId())
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(req)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.content").value("Jet modifié"))
.andExpect(jsonPath("$.type").value("DICE_ROLL"));
}
@Test
void updateEntry_returns400_whenMissing() throws Exception {
EntryRequest req = new EntryRequest(EntryType.NOTE, "x", null);
mockMvc.perform(put("/api/sessions/{sessionId}/entries/{entryId}", sessionId, "999999999")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(req)))
.andExpect(status().isBadRequest());
}
@Test
void deleteEntry_returns204() throws Exception {
SessionEntry e = saveEntry();
mockMvc.perform(delete("/api/sessions/{sessionId}/entries/{entryId}", sessionId, e.getId()))
.andExpect(status().isNoContent());
}
@Test
void deleteEntry_returns400_whenMissing() throws Exception {
mockMvc.perform(delete("/api/sessions/{sessionId}/entries/{entryId}", sessionId, "999999999"))
.andExpect(status().isBadRequest());
}
}

View File

@@ -0,0 +1,71 @@
package com.loremind.infrastructure.web.controller;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.test.context.TestPropertySource;
import org.springframework.test.web.servlet.MockMvc;
import java.nio.charset.StandardCharsets;
import java.util.Base64;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
/**
* Verifie le blocage des endpoints sensibles de {@link SettingsController} quand
* {@code app.demo-mode=true} : lecture/ecriture des settings et gestion des modeles
* Ollama (pull/delete) doivent renvoyer 403, sans jamais toucher le Brain.
* <p>
* Les requetes sont authentifiees en ADMIN (sinon la securite renverrait 401 avant
* d'atteindre le garde demo). Ce test valide donc aussi que le
* {@code GlobalExceptionHandler} propage le statut declare par le
* {@link org.springframework.web.server.ResponseStatusException} (403), sans l'ecraser en 500.
*/
@SpringBootTest
@AutoConfigureMockMvc
@TestPropertySource(properties = "app.demo-mode=true")
class SettingsControllerDemoModeTest {
private static final String ADMIN_AUTH = "Basic " + Base64.getEncoder()
.encodeToString("test-admin:test-admin-password".getBytes(StandardCharsets.UTF_8));
@Autowired private MockMvc mockMvc;
@Test
void getSettings_returns403() throws Exception {
mockMvc.perform(get("/api/settings").header(HttpHeaders.AUTHORIZATION, ADMIN_AUTH))
.andExpect(status().isForbidden());
}
@Test
void updateSettings_returns403() throws Exception {
mockMvc.perform(put("/api/settings")
.header(HttpHeaders.AUTHORIZATION, ADMIN_AUTH)
.contentType(MediaType.APPLICATION_JSON)
.content("{\"theme\":\"light\"}"))
.andExpect(status().isForbidden());
}
@Test
void pullOllamaModel_returns403() throws Exception {
mockMvc.perform(post("/api/settings/models/ollama/pull")
.header(HttpHeaders.AUTHORIZATION, ADMIN_AUTH)
.contentType(MediaType.APPLICATION_JSON)
.content("{\"name\":\"llama3\"}"))
.andExpect(status().isForbidden());
}
@Test
void deleteOllamaModel_returns403() throws Exception {
mockMvc.perform(delete("/api/settings/models/ollama/{name}", "llama3")
.header(HttpHeaders.AUTHORIZATION, ADMIN_AUTH))
.andExpect(status().isForbidden());
}
}

View File

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

View File

@@ -0,0 +1,172 @@
package com.loremind.infrastructure.web.controller;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.MediaType;
import org.springframework.test.web.client.MockRestServiceServer;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.web.client.RestTemplate;
import java.nio.charset.StandardCharsets;
import java.util.Base64;
import static org.hamcrest.Matchers.endsWith;
import static org.springframework.test.web.client.match.MockRestRequestMatchers.content;
import static org.springframework.test.web.client.match.MockRestRequestMatchers.method;
import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo;
import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
/**
* Tests d'integration pour {@link SettingsController} (proxy fin vers le Brain).
* <p>
* Le {@link RestTemplate} (@Primary {@code brainRestTemplate}, celui injecte dans
* le controller) est instrumente avec {@link MockRestServiceServer} : on intercepte
* les appels sortants vers le Brain et on renvoie des reponses canned, sans reseau.
* Cela verifie le contrat de proxy (methode, chemin, body relaye, reponse propagee).
* <p>
* {@code /api/settings/**} exige le role ADMIN (HTTP Basic) : chaque requete porte
* donc l'entete d'auth construite a partir des identifiants de test. Le blocage
* {@code app.demo-mode} est couvert par {@link SettingsControllerDemoModeTest}.
*/
@SpringBootTest
@AutoConfigureMockMvc
class SettingsControllerTest {
/** Identifiants definis dans src/test/resources/application.properties. */
private static final String ADMIN_AUTH = "Basic " + Base64.getEncoder()
.encodeToString("test-admin:test-admin-password".getBytes(StandardCharsets.UTF_8));
@Autowired private MockMvc mockMvc;
@Autowired private RestTemplate restTemplate;
private MockRestServiceServer brain;
@BeforeEach
void setUp() {
brain = MockRestServiceServer.createServer(restTemplate);
}
@Test
void getSettings_forwardsToBrain_andReturnsBody() throws Exception {
brain.expect(requestTo(endsWith("/settings")))
.andExpect(method(HttpMethod.GET))
.andRespond(withSuccess("{\"theme\":\"dark\"}", MediaType.APPLICATION_JSON));
mockMvc.perform(get("/api/settings").header(HttpHeaders.AUTHORIZATION, ADMIN_AUTH))
.andExpect(status().isOk())
.andExpect(jsonPath("$.theme").value("dark"));
brain.verify();
}
@Test
void updateSettings_forwardsPatchBody() throws Exception {
brain.expect(requestTo(endsWith("/settings")))
.andExpect(method(HttpMethod.PUT))
.andExpect(content().json("{\"theme\":\"light\"}"))
.andRespond(withSuccess("{\"theme\":\"light\"}", MediaType.APPLICATION_JSON));
mockMvc.perform(put("/api/settings")
.header(HttpHeaders.AUTHORIZATION, ADMIN_AUTH)
.contentType(MediaType.APPLICATION_JSON)
.content("{\"theme\":\"light\"}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.theme").value("light"));
brain.verify();
}
@Test
void listOllamaModels_forwards() throws Exception {
brain.expect(requestTo(endsWith("/models/ollama")))
.andExpect(method(HttpMethod.GET))
.andRespond(withSuccess("{\"models\":[]}", MediaType.APPLICATION_JSON));
mockMvc.perform(get("/api/settings/models/ollama").header(HttpHeaders.AUTHORIZATION, ADMIN_AUTH))
.andExpect(status().isOk())
.andExpect(jsonPath("$.models").isArray());
brain.verify();
}
@Test
void getOllamaModelInfo_forwardsPostBody() throws Exception {
brain.expect(requestTo(endsWith("/models/ollama/info")))
.andExpect(method(HttpMethod.POST))
.andExpect(content().json("{\"name\":\"llama3\"}"))
.andRespond(withSuccess("{\"size\":123}", MediaType.APPLICATION_JSON));
mockMvc.perform(post("/api/settings/models/ollama/info")
.header(HttpHeaders.AUTHORIZATION, ADMIN_AUTH)
.contentType(MediaType.APPLICATION_JSON)
.content("{\"name\":\"llama3\"}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.size").value(123));
brain.verify();
}
@Test
void deleteOllamaModel_forwardsWithNameInPath() throws Exception {
brain.expect(requestTo(endsWith("/models/ollama/llama3")))
.andExpect(method(HttpMethod.DELETE))
.andRespond(withSuccess("{\"deleted\":true}", MediaType.APPLICATION_JSON));
mockMvc.perform(delete("/api/settings/models/ollama/{name}", "llama3")
.header(HttpHeaders.AUTHORIZATION, ADMIN_AUTH))
.andExpect(status().isOk())
.andExpect(jsonPath("$.deleted").value(true));
brain.verify();
}
@Test
void listOneMinModels_forwards() throws Exception {
brain.expect(requestTo(endsWith("/models/onemin")))
.andExpect(method(HttpMethod.GET))
.andRespond(withSuccess("{\"models\":[]}", MediaType.APPLICATION_JSON));
mockMvc.perform(get("/api/settings/models/onemin").header(HttpHeaders.AUTHORIZATION, ADMIN_AUTH))
.andExpect(status().isOk());
brain.verify();
}
@Test
void listOpenRouterModels_forwards() throws Exception {
brain.expect(requestTo(endsWith("/models/openrouter")))
.andExpect(method(HttpMethod.GET))
.andRespond(withSuccess("{\"models\":[]}", MediaType.APPLICATION_JSON));
mockMvc.perform(get("/api/settings/models/openrouter").header(HttpHeaders.AUTHORIZATION, ADMIN_AUTH))
.andExpect(status().isOk());
brain.verify();
}
@Test
void listMistralModels_forwards() throws Exception {
brain.expect(requestTo(endsWith("/models/mistral")))
.andExpect(method(HttpMethod.GET))
.andRespond(withSuccess("{\"models\":[]}", MediaType.APPLICATION_JSON));
mockMvc.perform(get("/api/settings/models/mistral").header(HttpHeaders.AUTHORIZATION, ADMIN_AUTH))
.andExpect(status().isOk());
brain.verify();
}
@Test
void listGeminiModels_forwards() throws Exception {
brain.expect(requestTo(endsWith("/models/gemini")))
.andExpect(method(HttpMethod.GET))
.andRespond(withSuccess("{\"models\":[]}", MediaType.APPLICATION_JSON));
mockMvc.perform(get("/api/settings/models/gemini").header(HttpHeaders.AUTHORIZATION, ADMIN_AUTH))
.andExpect(status().isOk());
brain.verify();
}
}

View File

@@ -0,0 +1,58 @@
package com.loremind.infrastructure.web.controller;
import com.loremind.infrastructure.updates.UpdateCheckService;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.http.HttpHeaders;
import org.springframework.test.context.TestPropertySource;
import org.springframework.test.web.servlet.MockMvc;
import java.nio.charset.StandardCharsets;
import java.util.Base64;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
/**
* Verifie le verrouillage de {@link UpdatesController} quand {@code app.demo-mode=true} :
* check / check-beta / apply doivent renvoyer 403 (garde demo), jamais 401/200.
* <p>
* Les requetes sont authentifiees en ADMIN (sinon la securite renverrait 401 avant
* d'atteindre le garde demo). Le {@code GlobalExceptionHandler} doit propager le
* statut du {@link org.springframework.web.server.ResponseStatusException} (403).
* Contexte distinct (fichier separe) car la valeur demo-mode est injectee a la
* construction du controleur — meme convention que SettingsControllerDemoModeTest.
*/
@SpringBootTest
@AutoConfigureMockMvc
@TestPropertySource(properties = "app.demo-mode=true")
class UpdatesControllerDemoModeTest {
private static final String ADMIN_AUTH = "Basic " + Base64.getEncoder()
.encodeToString("test-admin:test-admin-password".getBytes(StandardCharsets.UTF_8));
@Autowired private MockMvc mockMvc;
@MockBean private UpdateCheckService updates;
@Test
void check_returns403_inDemoMode() throws Exception {
mockMvc.perform(get("/api/admin/updates/check").header(HttpHeaders.AUTHORIZATION, ADMIN_AUTH))
.andExpect(status().isForbidden());
}
@Test
void checkBeta_returns403_inDemoMode() throws Exception {
mockMvc.perform(get("/api/admin/updates/check-beta").header(HttpHeaders.AUTHORIZATION, ADMIN_AUTH))
.andExpect(status().isForbidden());
}
@Test
void apply_returns403_inDemoMode() throws Exception {
mockMvc.perform(post("/api/admin/updates/apply").header(HttpHeaders.AUTHORIZATION, ADMIN_AUTH))
.andExpect(status().isForbidden());
}
}

View File

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

Some files were not shown because too many files have changed in this diff Show More