2 Commits

13 changed files with 2082 additions and 51 deletions

View File

@@ -1,75 +1,311 @@
# Installation de LoreMindMJ
## Prerequis
Ce document decrit la procedure d'installation de LoreMindMJ. Temps estime :
5 a 10 minutes selon la qualite de la connexion reseau.
- **Docker Desktop** ([Windows](https://www.docker.com/products/docker-desktop/) / [Mac](https://www.docker.com/products/docker-desktop/))
ou **Docker Engine + Compose v2** (Linux).
- (Optionnel) **[Ollama](https://ollama.com/)** si tu veux un LLM local.
Sinon, une cle API [1min.ai](https://1min.ai) suffit.
## 1. Prerequis
## Installation (5 minutes)
- **Docker Desktop** ([Windows](https://www.docker.com/products/docker-desktop/) /
[Mac](https://www.docker.com/products/docker-desktop/)) ou
**Docker Engine + Compose v2** (Linux). Verification :
```
docker --version
docker compose version
```
Compose v2 est requis : la commande est `docker compose`, non `docker-compose`.
1. Telecharge `docker-compose.yml` et `.env.example` depuis la [derniere release](https://git.igmlcreation.fr/ietm64/LoreMindMJ/releases) dans un dossier a toi.
- **Un fournisseur LLM**, au choix :
- **[Ollama](https://ollama.com/)** installe sur la machine hote (gratuit,
local, necessite environ 6 Go de RAM libre pour les modeles recommandes).
- **Une cle API [1min.ai](https://1min.ai)** (hebergement cloud, facturation
a l'usage, aucune installation supplementaire requise).
2. Renomme `.env.example` en `.env` et ouvre-le dans un editeur texte. Trois variables sont **obligatoires** :
- `POSTGRES_PASSWORD` : mot de passe de la base (choisis-en un).
- `ADMIN_PASSWORD` : protege l'ecran Parametres de l'appli. Tu le taperas dans une popup du navigateur.
- `BRAIN_INTERNAL_SECRET` : secret interne partage entre les services. Genere une valeur aleatoire :
```
openssl rand -hex 32
```
(Sous Windows sans openssl : utilise un generateur en ligne type "random hex string 64 chars".)
- Environ **2 Go d'espace disque** pour les images Docker, auxquels s'ajoute
la taille des modeles Ollama si l'option locale est retenue.
Sans ces trois variables, `docker compose up` refusera de demarrer — c'est volontaire pour eviter un deploiement non-securise par defaut.
## 2. Recuperation des fichiers
3. Dans un terminal, place-toi dans le dossier et lance :
```
docker compose up -d
```
Le premier demarrage telecharge les images (~500 Mo) et initialise la base. Compte 1-2 minutes.
Telecharger les deux fichiers suivants depuis la
[derniere release](https://git.igmlcreation.fr/ietm64/LoreMindMJ/releases) et
les placer dans un dossier dedie (par exemple `~/loremind/` ou
`C:\Programs\loremind\`) :
4. Ouvre http://localhost:8081 dans ton navigateur. Bon jeu !
- `docker-compose.yml`
- `.env.example`
## Mise a jour
Le code source n'est pas necessaire : les images sont pre-construites et
publiees sur le registry Gitea `git.igmlcreation.fr` (non Docker Hub). Le
premier `docker compose pull` les telechargera automatiquement.
## 3. Configuration du fichier `.env`
Renommer `.env.example` en `.env` et l'ouvrir dans un editeur de texte. **Trois
variables sont obligatoires** ; sans elles, `docker compose up` refusera de
demarrer. Ce comportement est volontaire afin d'eviter tout deploiement
non-securise par defaut.
### `POSTGRES_PASSWORD`
Mot de passe de la base de donnees PostgreSQL. Choisir une valeur robuste.
Seuls les conteneurs utilisent cette valeur : il n'est pas necessaire de la
memoriser au-dela du fichier `.env`.
### `ADMIN_PASSWORD`
Protege l'ecran **Parametres** de l'application via HTTP Basic. Cette valeur
sera demandee par le navigateur lors de toute modification de la configuration
(changement de modele LLM, saisie de cle API, etc.). Le nom d'utilisateur par
defaut est `admin`, modifiable via la variable `ADMIN_USERNAME`.
### `BRAIN_INTERNAL_SECRET`
Secret partage entre le service Java (`core`) et le service Python (`brain`).
Empeche toute requete externe d'atteindre directement le service Brain.
Generer une valeur aleatoire de 64 caracteres hexadecimaux :
```
openssl rand -hex 32
```
Sous Windows sans `openssl`, utiliser PowerShell :
```powershell
-join ((48..57) + (97..102) | Get-Random -Count 64 | % {[char]$_})
```
### Variables optionnelles
- `WEB_PORT` (defaut `8081`) : port d'ecoute de l'interface web.
- `ADMIN_USERNAME` (defaut `admin`) : identifiant de la popup Parametres.
- `LLM_PROVIDER` (defaut `ollama`) : choix du fournisseur LLM (voir
section 5).
Les autres variables (`MINIO_USER`/`MINIO_PASSWORD`, `POSTGRES_DB`,
`POSTGRES_USER`) disposent de valeurs par defaut adaptees a un deploiement
personnel et peuvent etre conservees en l'etat.
## 4. Lancement de la stack
Depuis le dossier contenant `docker-compose.yml` et `.env` :
```
docker compose up -d
```
Le premier demarrage telecharge les images (environ 1 a 2 Go au total) et
initialise la base. Compter 2 a 5 minutes selon la qualite de la connexion.
La progression peut etre suivie via :
```
docker compose logs -f
```
(`Ctrl+C` pour quitter l'affichage ; les services continuent de fonctionner
en arriere-plan.)
Une fois les services en etat `healthy`, ouvrir **http://localhost:8081**
dans un navigateur.
### Verification du fonctionnement
```
docker compose ps
```
Cinq conteneurs doivent apparaitre en etat `Up` ou `healthy` :
`loremind-postgres`, `loremind-minio`, `loremind-core`, `loremind-brain`,
`loremind-web`. Le conteneur `loremind-minio-init` s'arrete automatiquement
apres creation du bucket d'images : ce comportement est normal.
## 5. Configuration du fournisseur LLM
### Ollama (local, gratuit)
Installer Ollama sur la machine hote (pas dans Docker), puis telecharger un
modele :
```
ollama pull gemma4:26b
```
Dans `.env` :
```
LLM_PROVIDER=ollama
LLM_MODEL=gemma4:26b
OLLAMA_BASE_URL=http://host.docker.internal:11434
```
L'adresse `host.docker.internal` permet au conteneur `brain` d'atteindre
Ollama sur la machine hote. Cette resolution est native sous Docker Desktop
(Mac / Windows). Sous Linux, le fichier `docker-compose.yml` declare un
`extra_hosts` equivalent.
### 1min.ai (cloud, paye)
Dans `.env` :
```
LLM_PROVIDER=onemin
ONEMIN_API_KEY=sk-...
ONEMIN_MODEL=gpt-4o-mini
```
### Modification a chaud
Le fournisseur, le modele et la cle API peuvent etre modifies a chaud depuis
l'ecran **Parametres** de l'application. Les modifications sont persistees
dans un volume Docker et survivent aux redemarrages. Les variables d'env du
fichier `.env` sont uniquement utilisees comme valeurs initiales au premier
demarrage.
## 6. Mise a jour
```
docker compose pull
docker compose up -d
```
Les donnees (base Postgres, images MinIO, settings Brain) sont dans des volumes Docker et survivent aux mises a jour.
Les donnees (base PostgreSQL, images MinIO, configuration Brain) sont
stockees dans des volumes Docker et survivent aux mises a jour.
## LLM : Ollama ou 1min.ai ?
## 7. Sauvegarde
**Ollama (local, gratuit)** — Edite `.env` :
```
LLM_PROVIDER=ollama
LLM_MODEL=gemma4:26b
```
Telecharge le modele au prealable : `ollama pull gemma4:26b`.
Les donnees sont reparties dans trois volumes Docker :
**1min.ai (cloud, paye)** — Edite `.env` :
```
LLM_PROVIDER=onemin
ONEMIN_API_KEY=sk-...
ONEMIN_MODEL=open-mistral-nemo
```
- `loremindmj_postgres-data` — ensemble des donnees applicatives (lores,
campagnes, pages, templates, branches narratives, etc.).
- `loremindmj_minio-data` — images uploadees.
- `loremindmj_brain-data` — parametres IA (fournisseur courant, cle API
1min.ai).
Tu peux aussi changer tout ca a chaud depuis l'ecran Parametres de l'appli.
### Export SQL de la base
## Problemes frequents
- **Port 8081 deja pris** : change `WEB_PORT=8082` (ou autre) dans `.env`.
- **Ollama injoignable** : verifie qu'Ollama tourne (`ollama serve`) et que le modele est bien telecharge.
- **"set ADMIN_PASSWORD in .env" / "set BRAIN_INTERNAL_SECRET in .env"** au lancement : tu as oublie une des variables obligatoires de l'etape 2.
- **Popup "Ce site vous demande de vous connecter" sur l'ecran Parametres** : c'est normal. Utilise `admin` (ou ce que tu as mis dans `ADMIN_USERNAME`) et ton `ADMIN_PASSWORD`.
- **Tout casser et repartir de zero** : `docker compose down -v` supprime les volumes (attention, perte de donnees).
## Sauvegarde
Les donnees sont dans les volumes Docker : `loremindmj_postgres-data`, `loremindmj_minio-data`, `loremindmj_brain-data`.
Sauvegarde rapide de la base :
```
docker compose exec postgres pg_dump -U loremind loremind > backup.sql
```
### Sauvegarde complete des volumes
Arreter la stack au prealable afin de garantir la coherence des donnees :
```
docker compose stop
docker run --rm -v loremindmj_postgres-data:/data -v $(pwd):/backup alpine tar czf /backup/postgres-data.tar.gz -C /data .
docker run --rm -v loremindmj_minio-data:/data -v $(pwd):/backup alpine tar czf /backup/minio-data.tar.gz -C /data .
docker compose start
```
Sous Windows PowerShell, remplacer `$(pwd)` par `${PWD}`.
## 8. Resolution des problemes
### Port 8081 deja utilise
Modifier `WEB_PORT=8082` (ou toute autre valeur libre) dans `.env`, puis
relancer :
```
docker compose up -d
```
### Erreur "set POSTGRES_PASSWORD in .env" (ou variable equivalente) au lancement
Une des trois variables obligatoires de l'etape 3 est manquante. Verifier le
contenu du fichier `.env`.
### Popup "Ce site vous demande de vous connecter" sur l'ecran Parametres
Comportement attendu : il s'agit de l'authentification HTTP Basic. Utiliser
la valeur de `ADMIN_USERNAME` (par defaut `admin`) et celle de
`ADMIN_PASSWORD`.
### Erreurs `password authentication failed` en boucle dans les logs Postgres
Si la variable `POSTGRES_PASSWORD` a ete modifiee apres un premier lancement,
le volume Postgres conserve l'ancien mot de passe (initialise une seule fois).
Deux options :
- **Redemarrer avec un volume vierge** (entraine la perte des donnees) :
```
docker compose down -v
docker compose up -d
```
- **Modifier le mot de passe en base** sans toucher au volume :
```
docker compose exec postgres psql -U postgres
```
Puis dans le prompt `psql` :
```sql
ALTER USER loremind WITH PASSWORD 'valeur_exacte_du_env';
\q
```
Redemarrer ensuite le Core : `docker compose restart core`.
### Erreur "502 Bad Gateway" ou message d'erreur IA dans l'interface
Le service Brain ne parvient pas a contacter le fournisseur LLM. Verifier :
- **Ollama** : `ollama serve` est-il actif ? Le modele est-il telecharge
(`ollama list`) ? La valeur de `LLM_MODEL` correspond-elle exactement au
nom d'un modele liste ?
- **1min.ai** : la cle API est-elle valide ? Le modele existe-t-il ?
- Consulter les logs du Brain :
```
docker compose logs brain
```
### Un service ne demarre pas ou reste en etat `unhealthy`
Consulter les logs du service concerne :
```
docker compose logs <service>
```
Services disponibles : `postgres`, `minio`, `core`, `brain`, `web`.
### Redemarrage d'un service apres modification du `.env`
```
docker compose up -d <service>
```
Redemarrage complet : `docker compose restart`.
### Remise a zero complete (PERTE DES DONNEES)
```
docker compose down -v
```
L'option `-v` supprime les volumes. L'ensemble des lores, campagnes, images
et parametres est perdu de maniere definitive.
### "No such image" ou "pull access denied" au premier lancement
Le registry Gitea peut necessiter une authentification selon la visibilite
configuree pour les images. Contacter l'editeur du projet.
## 9. Exposition reseau des services
- **Interface web** : http://localhost:8081 (port configurable via
`WEB_PORT`).
- **PostgreSQL** : accessible uniquement via le reseau Docker interne, non
expose vers l'hote.
- **MinIO** : accessible uniquement via le reseau Docker interne. Les images
transitent par le reverse-proxy Java sur `/api/images/{id}/content`. Le
binding `127.0.0.1:9000/9001` defini dans `docker-compose.override.yml`
n'est actif qu'en developpement.
- **Brain Python** : accessible uniquement via le reseau Docker interne.
Toute requete doit porter l'en-tete `X-Internal-Secret`, injectee
automatiquement par le Core Java et jamais exposee au navigateur.
## 10. Desinstallation
```
docker compose down -v
docker image rm git.igmlcreation.fr/ietm64/core git.igmlcreation.fr/ietm64/brain git.igmlcreation.fr/ietm64/web
```
Supprimer ensuite le dossier contenant `docker-compose.yml` et `.env`.

View File

@@ -99,6 +99,29 @@
</excludes>
</configuration>
</plugin>
<!-- JaCoCo : rapport de couverture des tests unitaires.
Rapport HTML auto-genere a chaque `mvn test` dans target/site/jacoco/. -->
<plugin>
<groupId>org.jacoco</groupId>
<artifactId>jacoco-maven-plugin</artifactId>
<version>0.8.12</version>
<executions>
<execution>
<id>prepare-agent</id>
<goals>
<goal>prepare-agent</goal>
</goals>
</execution>
<execution>
<id>report</id>
<phase>test</phase>
<goals>
<goal>report</goal>
</goals>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>

View File

@@ -0,0 +1,175 @@
package com.loremind.application.generationcontext;
import com.loremind.domain.campaigncontext.Arc;
import com.loremind.domain.campaigncontext.Campaign;
import com.loremind.domain.campaigncontext.Chapter;
import com.loremind.domain.campaigncontext.Scene;
import com.loremind.domain.campaigncontext.SceneBranch;
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.campaigncontext.ports.SceneRepository;
import com.loremind.domain.generationcontext.CampaignStructuralContext;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.util.List;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
/**
* Test unitaire pour CampaignStructuralContextBuilder.
* Vérifie la projection Campaign Context → Generation Context (arcs → chapitres → scènes),
* le tri par `order`, la résolution des branches via la map id→nom, et le comptage
* null-safe des illustrations.
*/
@ExtendWith(MockitoExtension.class)
public class CampaignStructuralContextBuilderTest {
@Mock
private CampaignRepository campaignRepository;
@Mock
private ArcRepository arcRepository;
@Mock
private ChapterRepository chapterRepository;
@Mock
private SceneRepository sceneRepository;
@InjectMocks
private CampaignStructuralContextBuilder builder;
private Campaign campaign;
@BeforeEach
void setUp() {
campaign = Campaign.builder()
.id("camp-1")
.name("Les Terres Brisées")
.description("Campagne dark fantasy")
.build();
}
@Test
void testBuild_CampaignNotFound() {
when(campaignRepository.findById("missing")).thenReturn(Optional.empty());
IllegalArgumentException ex = assertThrows(IllegalArgumentException.class,
() -> builder.build("missing"));
assertTrue(ex.getMessage().contains("missing"));
}
@Test
void testBuild_EmptyCampaign() {
when(campaignRepository.findById("camp-1")).thenReturn(Optional.of(campaign));
when(arcRepository.findByCampaignId("camp-1")).thenReturn(List.of());
CampaignStructuralContext ctx = builder.build("camp-1");
assertEquals("Les Terres Brisées", ctx.getCampaignName());
assertEquals("Campagne dark fantasy", ctx.getCampaignDescription());
assertTrue(ctx.getArcs().isEmpty());
}
@Test
void testBuild_SortsArcsChaptersScenesByOrder() {
Arc arc1 = Arc.builder().id("arc-1").name("Arc A").description("first").order(1).build();
Arc arc2 = Arc.builder().id("arc-2").name("Arc B").description("second").order(2).build();
Chapter ch1 = Chapter.builder().id("ch-1").arcId("arc-1").name("Ch A").description("d").order(2).build();
Chapter ch2 = Chapter.builder().id("ch-2").arcId("arc-1").name("Ch B").description("d").order(1).build();
Scene s1 = Scene.builder().id("s-1").chapterId("ch-1").name("Scene A").description("d").order(2).build();
Scene s2 = Scene.builder().id("s-2").chapterId("ch-1").name("Scene B").description("d").order(1).build();
when(campaignRepository.findById("camp-1")).thenReturn(Optional.of(campaign));
// Volontairement inverse pour verifier le tri.
when(arcRepository.findByCampaignId("camp-1")).thenReturn(List.of(arc2, arc1));
when(chapterRepository.findByArcId("arc-1")).thenReturn(List.of(ch1, ch2));
when(chapterRepository.findByArcId("arc-2")).thenReturn(List.of());
when(sceneRepository.findByChapterId("ch-1")).thenReturn(List.of(s1, s2));
when(sceneRepository.findByChapterId("ch-2")).thenReturn(List.of());
CampaignStructuralContext ctx = builder.build("camp-1");
assertEquals(2, ctx.getArcs().size());
assertEquals("Arc A", ctx.getArcs().get(0).getName());
assertEquals("Arc B", ctx.getArcs().get(1).getName());
// Chapitres tries : ch2 (order 1) avant ch1 (order 2)
assertEquals(2, ctx.getArcs().get(0).getChapters().size());
assertEquals("Ch B", ctx.getArcs().get(0).getChapters().get(0).getName());
assertEquals("Ch A", ctx.getArcs().get(0).getChapters().get(1).getName());
// Scenes dans ch-1 : s2 (order 1) avant s1 (order 2)
var chADto = ctx.getArcs().get(0).getChapters().get(1);
assertEquals("Scene B", chADto.getScenes().get(0).getName());
assertEquals("Scene A", chADto.getScenes().get(1).getName());
}
@Test
void testBuild_ResolvesBranchTargetSceneName() {
Arc arc = Arc.builder().id("arc-1").name("Arc").description("").order(1).build();
Chapter ch = Chapter.builder().id("ch-1").arcId("arc-1").name("Ch").description("").order(1).build();
SceneBranch validBranch = SceneBranch.builder()
.label("Si les joueurs fuient")
.targetSceneId("s-2")
.condition("en cas de combat perdu")
.build();
SceneBranch danglingBranch = SceneBranch.builder()
.label("Vers l'inconnu")
.targetSceneId("s-inconnu")
.build();
Scene s1 = Scene.builder().id("s-1").chapterId("ch-1").name("Entrée").description("")
.order(1)
.branches(List.of(validBranch, danglingBranch))
.build();
Scene s2 = Scene.builder().id("s-2").chapterId("ch-1").name("Fuite").description("").order(2).build();
when(campaignRepository.findById("camp-1")).thenReturn(Optional.of(campaign));
when(arcRepository.findByCampaignId("camp-1")).thenReturn(List.of(arc));
when(chapterRepository.findByArcId("arc-1")).thenReturn(List.of(ch));
when(sceneRepository.findByChapterId("ch-1")).thenReturn(List.of(s1, s2));
CampaignStructuralContext ctx = builder.build("camp-1");
var scene1Summary = ctx.getArcs().get(0).getChapters().get(0).getScenes().get(0);
assertEquals(2, scene1Summary.getBranches().size());
assertEquals("Fuite", scene1Summary.getBranches().get(0).getTargetSceneName());
assertEquals("en cas de combat perdu", scene1Summary.getBranches().get(0).getCondition());
// ID inconnu → libellé de fallback
assertEquals("(scène inconnue)", scene1Summary.getBranches().get(1).getTargetSceneName());
}
@Test
void testBuild_CountsIllustrationsNullSafe() {
Arc arc = Arc.builder().id("arc-1").name("Arc").description("").order(1)
.illustrationImageIds(List.of("img-1", "img-2")).build();
Chapter ch = Chapter.builder().id("ch-1").arcId("arc-1").name("Ch").description("").order(1)
.illustrationImageIds(null) // null-safe attendu
.build();
Scene s = Scene.builder().id("s-1").chapterId("ch-1").name("S").description("").order(1)
.illustrationImageIds(List.of("img-3"))
.branches(null)
.build();
when(campaignRepository.findById("camp-1")).thenReturn(Optional.of(campaign));
when(arcRepository.findByCampaignId("camp-1")).thenReturn(List.of(arc));
when(chapterRepository.findByArcId("arc-1")).thenReturn(List.of(ch));
when(sceneRepository.findByChapterId("ch-1")).thenReturn(List.of(s));
CampaignStructuralContext ctx = builder.build("camp-1");
assertEquals(2, ctx.getArcs().get(0).getIllustrationCount());
assertEquals(0, ctx.getArcs().get(0).getChapters().get(0).getIllustrationCount());
assertEquals(1, ctx.getArcs().get(0).getChapters().get(0).getScenes().get(0).getIllustrationCount());
assertTrue(ctx.getArcs().get(0).getChapters().get(0).getScenes().get(0).getBranches().isEmpty());
}
}

View File

@@ -0,0 +1,164 @@
package com.loremind.application.generationcontext;
import com.loremind.domain.generationcontext.GenerationContext;
import com.loremind.domain.generationcontext.GenerationResult;
import com.loremind.domain.generationcontext.ports.AiProvider;
import com.loremind.domain.lorecontext.FieldType;
import com.loremind.domain.lorecontext.Lore;
import com.loremind.domain.lorecontext.LoreNode;
import com.loremind.domain.lorecontext.Page;
import com.loremind.domain.lorecontext.Template;
import com.loremind.domain.lorecontext.TemplateField;
import com.loremind.domain.lorecontext.ports.LoreNodeRepository;
import com.loremind.domain.lorecontext.ports.LoreRepository;
import com.loremind.domain.lorecontext.ports.PageRepository;
import com.loremind.domain.lorecontext.ports.TemplateRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
/**
* Test unitaire pour GeneratePageValuesUseCase.
* Couvre : le happy path (contexte IA correctement assemblé avec uniquement
* les champs TEXT), les erreurs d'intégrité (Page/Template/Lore/Folder
* introuvables), et la validation métier (template sans champ texte).
*/
@ExtendWith(MockitoExtension.class)
public class GeneratePageValuesUseCaseTest {
@Mock private PageRepository pageRepository;
@Mock private TemplateRepository templateRepository;
@Mock private LoreRepository loreRepository;
@Mock private LoreNodeRepository loreNodeRepository;
@Mock private AiProvider aiProvider;
@InjectMocks private GeneratePageValuesUseCase useCase;
private Page page;
private Template template;
private Lore lore;
private LoreNode folder;
@BeforeEach
void setUp() {
page = Page.builder()
.id("p-1").loreId("lore-1").nodeId("node-1").templateId("tpl-1")
.title("Alice")
.build();
template = Template.builder()
.id("tpl-1").loreId("lore-1").name("Personnage")
.fields(List.of(
TemplateField.text("Histoire"),
TemplateField.text("Apparence"),
TemplateField.image("Portrait")))
.build();
lore = Lore.builder().id("lore-1").name("Aetheria").description("monde aérien").build();
folder = LoreNode.builder().id("node-1").name("PNJ").loreId("lore-1").build();
}
@Test
void testExecute_HappyPath_OnlyTextFieldsSentToAi() {
when(pageRepository.findById("p-1")).thenReturn(Optional.of(page));
when(templateRepository.findById("tpl-1")).thenReturn(Optional.of(template));
when(loreRepository.findById("lore-1")).thenReturn(Optional.of(lore));
when(loreNodeRepository.findById("node-1")).thenReturn(Optional.of(folder));
Map<String, String> generated = Map.of(
"Histoire", "Alice est une...",
"Apparence", "Cheveux roux");
when(aiProvider.generatePage(any())).thenReturn(new GenerationResult(generated));
Map<String, String> result = useCase.execute("p-1");
assertEquals(generated, result);
ArgumentCaptor<GenerationContext> captor = ArgumentCaptor.forClass(GenerationContext.class);
verify(aiProvider).generatePage(captor.capture());
GenerationContext ctx = captor.getValue();
assertEquals("Aetheria", ctx.getLoreName());
assertEquals("monde aérien", ctx.getLoreDescription());
assertEquals("PNJ", ctx.getFolderName());
assertEquals("Personnage", ctx.getTemplateName());
assertEquals("Alice", ctx.getPageTitle());
// Seuls les champs TEXT doivent etre envoyes (pas "Portrait" IMAGE).
assertEquals(List.of("Histoire", "Apparence"), ctx.getTemplateFields());
}
@Test
void testExecute_PageNotFound() {
when(pageRepository.findById("missing")).thenReturn(Optional.empty());
assertThrows(IllegalArgumentException.class, () -> useCase.execute("missing"));
verifyNoInteractions(aiProvider);
}
@Test
void testExecute_PageWithoutTemplateId() {
Page orphan = Page.builder().id("p-1").loreId("lore-1").nodeId("node-1")
.templateId(null).title("Orphan").build();
when(pageRepository.findById("p-1")).thenReturn(Optional.of(orphan));
IllegalStateException ex = assertThrows(IllegalStateException.class,
() -> useCase.execute("p-1"));
assertTrue(ex.getMessage().contains("template"));
}
@Test
void testExecute_TemplateNotFound() {
when(pageRepository.findById("p-1")).thenReturn(Optional.of(page));
when(templateRepository.findById("tpl-1")).thenReturn(Optional.empty());
assertThrows(IllegalStateException.class, () -> useCase.execute("p-1"));
}
@Test
void testExecute_LoreNotFound() {
when(pageRepository.findById("p-1")).thenReturn(Optional.of(page));
when(templateRepository.findById("tpl-1")).thenReturn(Optional.of(template));
when(loreRepository.findById("lore-1")).thenReturn(Optional.empty());
assertThrows(IllegalStateException.class, () -> useCase.execute("p-1"));
}
@Test
void testExecute_FolderNotFound() {
when(pageRepository.findById("p-1")).thenReturn(Optional.of(page));
when(templateRepository.findById("tpl-1")).thenReturn(Optional.of(template));
when(loreRepository.findById("lore-1")).thenReturn(Optional.of(lore));
when(loreNodeRepository.findById("node-1")).thenReturn(Optional.empty());
assertThrows(IllegalStateException.class, () -> useCase.execute("p-1"));
}
@Test
void testExecute_TemplateWithoutTextFields() {
Template imageOnly = Template.builder()
.id("tpl-1").loreId("lore-1").name("Galerie")
.fields(List.of(new TemplateField("Portrait", FieldType.IMAGE)))
.build();
when(pageRepository.findById("p-1")).thenReturn(Optional.of(page));
when(templateRepository.findById("tpl-1")).thenReturn(Optional.of(imageOnly));
when(loreRepository.findById("lore-1")).thenReturn(Optional.of(lore));
when(loreNodeRepository.findById("node-1")).thenReturn(Optional.of(folder));
IllegalStateException ex = assertThrows(IllegalStateException.class,
() -> useCase.execute("p-1"));
assertTrue(ex.getMessage().contains("Galerie"));
verifyNoInteractions(aiProvider);
}
}

View File

@@ -0,0 +1,193 @@
package com.loremind.application.generationcontext;
import com.loremind.domain.generationcontext.LoreStructuralContext;
import com.loremind.domain.lorecontext.Lore;
import com.loremind.domain.lorecontext.LoreNode;
import com.loremind.domain.lorecontext.Page;
import com.loremind.domain.lorecontext.Template;
import com.loremind.domain.lorecontext.ports.LoreNodeRepository;
import com.loremind.domain.lorecontext.ports.LoreRepository;
import com.loremind.domain.lorecontext.ports.PageRepository;
import com.loremind.domain.lorecontext.ports.TemplateRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
/**
* Test unitaire pour LoreStructuralContextBuilder.
* Couvre la projection LoreContext → GenerationContext : construction du
* dossier→pages, résolution template/relatedPages, troncature des valeurs,
* filtrage des valeurs vides, et extraction unique des tags.
*/
@ExtendWith(MockitoExtension.class)
public class LoreStructuralContextBuilderTest {
@Mock private LoreRepository loreRepository;
@Mock private LoreNodeRepository loreNodeRepository;
@Mock private PageRepository pageRepository;
@Mock private TemplateRepository templateRepository;
@InjectMocks private LoreStructuralContextBuilder builder;
private Lore lore;
@BeforeEach
void setUp() {
lore = Lore.builder().id("lore-1").name("Aetheria").description("Monde aérien").build();
}
@Test
void testBuild_LoreNotFound_ThrowsOnStrict() {
when(loreRepository.findById("missing")).thenReturn(Optional.empty());
assertThrows(IllegalArgumentException.class, () -> builder.build("missing"));
}
@Test
void testBuildOptional_LoreNotFound_ReturnsEmpty() {
when(loreRepository.findById("missing")).thenReturn(Optional.empty());
assertTrue(builder.buildOptional("missing").isEmpty());
}
@Test
void testBuild_EmptyLore() {
when(loreRepository.findById("lore-1")).thenReturn(Optional.of(lore));
when(loreNodeRepository.findByLoreId("lore-1")).thenReturn(List.of());
when(pageRepository.findByLoreId("lore-1")).thenReturn(List.of());
when(templateRepository.findByLoreId("lore-1")).thenReturn(List.of());
LoreStructuralContext ctx = builder.build("lore-1");
assertEquals("Aetheria", ctx.getLoreName());
assertEquals("Monde aérien", ctx.getLoreDescription());
assertTrue(ctx.getFolders().isEmpty());
assertTrue(ctx.getTags().isEmpty());
}
@Test
void testBuild_FoldersAndPagesMapping() {
LoreNode nodePnj = LoreNode.builder().id("n-1").name("PNJ").loreId("lore-1").build();
LoreNode nodeLieux = LoreNode.builder().id("n-2").name("Lieux").loreId("lore-1").build();
Template tpl = Template.builder().id("tpl-1").name("Personnage").build();
Map<String, String> values = new LinkedHashMap<>();
values.put("Histoire", "Il était une fois...");
values.put("VideField", " "); // blank → filtré
values.put("NullField", null); // null → filtré
Page p1 = Page.builder()
.id("p-1").loreId("lore-1").nodeId("n-1")
.templateId("tpl-1").title("Alice")
.values(values)
.tags(List.of("hero", "magic"))
.relatedPageIds(List.of("p-2", "p-ghost"))
.build();
Page p2 = Page.builder()
.id("p-2").loreId("lore-1").nodeId("n-2")
.templateId("tpl-missing").title("La Forêt")
.values(Map.of())
.tags(List.of("magic"))
.build();
when(loreRepository.findById("lore-1")).thenReturn(Optional.of(lore));
when(loreNodeRepository.findByLoreId("lore-1")).thenReturn(List.of(nodePnj, nodeLieux));
when(pageRepository.findByLoreId("lore-1")).thenReturn(List.of(p1, p2));
when(templateRepository.findByLoreId("lore-1")).thenReturn(List.of(tpl));
LoreStructuralContext ctx = builder.build("lore-1");
assertEquals(2, ctx.getFolders().size());
assertTrue(ctx.getFolders().containsKey("PNJ"));
assertTrue(ctx.getFolders().containsKey("Lieux"));
var pnjPages = ctx.getFolders().get("PNJ");
assertEquals(1, pnjPages.size());
var aliceSummary = pnjPages.get(0);
assertEquals("Alice", aliceSummary.getTitle());
assertEquals("Personnage", aliceSummary.getTemplateName());
// Blank/null filtrés
assertEquals(1, aliceSummary.getValues().size());
assertEquals("Il était une fois...", aliceSummary.getValues().get("Histoire"));
assertEquals(List.of("hero", "magic"), aliceSummary.getTags());
// p-2 resolved into title, p-ghost dropped silently
assertEquals(List.of("La Forêt"), aliceSummary.getRelatedPageTitles());
var forestSummary = ctx.getFolders().get("Lieux").get(0);
// Template introuvable → "?"
assertEquals("?", forestSummary.getTemplateName());
assertTrue(forestSummary.getValues().isEmpty());
assertTrue(forestSummary.getRelatedPageTitles().isEmpty());
// Tags uniques entre les 2 pages
assertEquals(2, ctx.getTags().size());
assertTrue(ctx.getTags().contains("hero"));
assertTrue(ctx.getTags().contains("magic"));
}
@Test
void testBuild_TruncatesLongValues() {
LoreNode node = LoreNode.builder().id("n-1").name("PNJ").build();
Template tpl = Template.builder().id("tpl-1").name("Personnage").build();
String longText = "a".repeat(600); // au-dessus du plafond 500
Map<String, String> values = new HashMap<>();
values.put("Histoire", longText);
Page p = Page.builder()
.id("p-1").loreId("lore-1").nodeId("n-1")
.templateId("tpl-1").title("Alice")
.values(values)
.build();
when(loreRepository.findById("lore-1")).thenReturn(Optional.of(lore));
when(loreNodeRepository.findByLoreId("lore-1")).thenReturn(List.of(node));
when(pageRepository.findByLoreId("lore-1")).thenReturn(List.of(p));
when(templateRepository.findByLoreId("lore-1")).thenReturn(List.of(tpl));
LoreStructuralContext ctx = builder.build("lore-1");
String truncated = ctx.getFolders().get("PNJ").get(0).getValues().get("Histoire");
assertNotNull(truncated);
assertEquals(500 + 1, truncated.length()); // 500 + ellipse
assertTrue(truncated.endsWith(""));
}
@Test
void testBuild_HandlesNullValuesAndTags() {
LoreNode node = LoreNode.builder().id("n-1").name("PNJ").build();
Page p = Page.builder()
.id("p-1").loreId("lore-1").nodeId("n-1")
.templateId(null).title("Alice")
.values(null)
.tags(null)
.relatedPageIds(null)
.build();
when(loreRepository.findById("lore-1")).thenReturn(Optional.of(lore));
when(loreNodeRepository.findByLoreId("lore-1")).thenReturn(List.of(node));
when(pageRepository.findByLoreId("lore-1")).thenReturn(List.of(p));
when(templateRepository.findByLoreId("lore-1")).thenReturn(List.of());
LoreStructuralContext ctx = builder.build("lore-1");
var summary = ctx.getFolders().get("PNJ").get(0);
assertTrue(summary.getValues().isEmpty());
assertTrue(summary.getTags().isEmpty());
assertTrue(summary.getRelatedPageTitles().isEmpty());
}
}

View File

@@ -0,0 +1,130 @@
package com.loremind.application.generationcontext;
import com.loremind.domain.campaigncontext.Arc;
import com.loremind.domain.campaigncontext.Chapter;
import com.loremind.domain.campaigncontext.Scene;
import com.loremind.domain.campaigncontext.ports.ArcRepository;
import com.loremind.domain.campaigncontext.ports.ChapterRepository;
import com.loremind.domain.campaigncontext.ports.SceneRepository;
import com.loremind.domain.generationcontext.NarrativeEntityContext;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
/**
* Test unitaire pour NarrativeEntityContextBuilder.
* Vérifie la projection Arc/Chapter/Scene → NarrativeEntityContext pour
* les 3 types, la normalisation du type (casse/whitespace), la gestion des
* champs null (remplacés par ""), et les erreurs (type inconnu, entité absente).
*/
@ExtendWith(MockitoExtension.class)
public class NarrativeEntityContextBuilderTest {
@Mock private ArcRepository arcRepository;
@Mock private ChapterRepository chapterRepository;
@Mock private SceneRepository sceneRepository;
@InjectMocks private NarrativeEntityContextBuilder builder;
@Test
void testBuild_Arc() {
Arc arc = Arc.builder()
.id("arc-1").name("L'arc sombre").description("synopsis")
.themes("trahison").stakes("vie ou mort").rewards("pouvoir")
.resolution("le roi meurt").gmNotes("secret")
.build();
when(arcRepository.findById("arc-1")).thenReturn(Optional.of(arc));
NarrativeEntityContext ctx = builder.build("arc", "arc-1");
assertEquals("arc", ctx.getEntityType());
assertEquals("L'arc sombre", ctx.getTitle());
assertEquals("synopsis", ctx.getFields().get("description (synopsis)"));
assertEquals("trahison", ctx.getFields().get("themes"));
assertEquals("vie ou mort", ctx.getFields().get("stakes"));
assertEquals("pouvoir", ctx.getFields().get("rewards"));
assertEquals("le roi meurt", ctx.getFields().get("resolution"));
assertEquals("secret", ctx.getFields().get("gmNotes"));
}
@Test
void testBuild_Chapter_WithNullFieldsReplacedByEmptyString() {
Chapter ch = Chapter.builder()
.id("ch-1").name("Chapitre 1").description(null)
.playerObjectives(null).narrativeStakes("haut").gmNotes(null)
.build();
when(chapterRepository.findById("ch-1")).thenReturn(Optional.of(ch));
NarrativeEntityContext ctx = builder.build("chapter", "ch-1");
assertEquals("chapter", ctx.getEntityType());
assertEquals("Chapitre 1", ctx.getTitle());
assertEquals("", ctx.getFields().get("description (synopsis)"));
assertEquals("", ctx.getFields().get("playerObjectives"));
assertEquals("haut", ctx.getFields().get("narrativeStakes"));
assertEquals("", ctx.getFields().get("gmNotes"));
}
@Test
void testBuild_Scene_AllFieldsMapped() {
Scene sc = Scene.builder()
.id("s-1").name("L'auberge").description("lieu calme")
.location("Taverne").timing("Soir").atmosphere("tendue")
.playerNarration("Vous entrez...").choicesConsequences("option A...")
.combatDifficulty("moyen").enemies("3 bandits")
.gmSecretNotes("trésor caché")
.build();
when(sceneRepository.findById("s-1")).thenReturn(Optional.of(sc));
NarrativeEntityContext ctx = builder.build("scene", "s-1");
assertEquals("scene", ctx.getEntityType());
assertEquals("L'auberge", ctx.getTitle());
assertEquals("lieu calme", ctx.getFields().get("description"));
assertEquals("Taverne", ctx.getFields().get("location"));
assertEquals("Soir", ctx.getFields().get("timing"));
assertEquals("tendue", ctx.getFields().get("atmosphere"));
assertEquals("Vous entrez...", ctx.getFields().get("playerNarration"));
assertEquals("option A...", ctx.getFields().get("choicesConsequences"));
assertEquals("moyen", ctx.getFields().get("combatDifficulty"));
assertEquals("3 bandits", ctx.getFields().get("enemies"));
assertEquals("trésor caché", ctx.getFields().get("gmSecretNotes"));
}
@Test
void testBuild_NormalizesTypeCaseAndWhitespace() {
Arc arc = Arc.builder().id("arc-1").name("A").build();
when(arcRepository.findById("arc-1")).thenReturn(Optional.of(arc));
NarrativeEntityContext ctx = builder.build(" ARC ", "arc-1");
assertEquals("arc", ctx.getEntityType());
}
@Test
void testBuild_UnknownTypeThrows() {
IllegalArgumentException ex = assertThrows(IllegalArgumentException.class,
() -> builder.build("npc", "id"));
assertTrue(ex.getMessage().contains("npc"));
}
@Test
void testBuild_NullTypeThrows() {
assertThrows(IllegalArgumentException.class, () -> builder.build(null, "id"));
}
@Test
void testBuild_EntityNotFound() {
when(sceneRepository.findById("missing")).thenReturn(Optional.empty());
IllegalArgumentException ex = assertThrows(IllegalArgumentException.class,
() -> builder.build("scene", "missing"));
assertTrue(ex.getMessage().contains("missing"));
}
}

View File

@@ -0,0 +1,156 @@
package com.loremind.application.generationcontext;
import com.loremind.domain.campaigncontext.Campaign;
import com.loremind.domain.campaigncontext.ports.CampaignRepository;
import com.loremind.domain.generationcontext.CampaignStructuralContext;
import com.loremind.domain.generationcontext.ChatMessage;
import com.loremind.domain.generationcontext.ChatRequest;
import com.loremind.domain.generationcontext.LoreStructuralContext;
import com.loremind.domain.generationcontext.NarrativeEntityContext;
import com.loremind.domain.generationcontext.ports.AiChatProvider;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.function.Consumer;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
/**
* Test unitaire pour StreamChatForCampaignUseCase.
* Vérifie l'orchestration : chargement Campaign, chargement optionnel du
* Lore lié (avec tolérance d'un Lore supprimé), chargement optionnel de
* l'entité narrative focus, et délégation au port AiChatProvider avec la
* ChatRequest correcte.
*/
@ExtendWith(MockitoExtension.class)
public class StreamChatForCampaignUseCaseTest {
@Mock private CampaignRepository campaignRepository;
@Mock private CampaignStructuralContextBuilder campaignContextBuilder;
@Mock private LoreStructuralContextBuilder loreContextBuilder;
@Mock private NarrativeEntityContextBuilder narrativeEntityContextBuilder;
@Mock private AiChatProvider aiChatProvider;
@InjectMocks private StreamChatForCampaignUseCase useCase;
private CampaignStructuralContext campaignCtx;
private List<ChatMessage> messages;
private Consumer<String> onToken;
private Runnable onComplete;
private Consumer<Throwable> onError;
@SuppressWarnings("unchecked")
@BeforeEach
void setUp() {
campaignCtx = CampaignStructuralContext.builder()
.campaignName("X").campaignDescription("d")
.build();
messages = List.of();
onToken = mock(Consumer.class);
onComplete = mock(Runnable.class);
onError = mock(Consumer.class);
}
@Test
void testExecute_CampaignNotFound_Throws() {
when(campaignRepository.findById("missing")).thenReturn(Optional.empty());
assertThrows(IllegalArgumentException.class,
() -> useCase.execute("missing", null, null, messages, onToken, onComplete, onError));
verifyNoInteractions(aiChatProvider);
}
@Test
void testExecute_StandaloneCampaign_NoLoreNoEntity() {
Campaign standalone = Campaign.builder().id("c-1").name("C").loreId(null).build();
when(campaignRepository.findById("c-1")).thenReturn(Optional.of(standalone));
when(campaignContextBuilder.build("c-1")).thenReturn(campaignCtx);
useCase.execute("c-1", null, null, messages, onToken, onComplete, onError);
ArgumentCaptor<ChatRequest> captor = ArgumentCaptor.forClass(ChatRequest.class);
verify(aiChatProvider).streamChat(captor.capture(), eq(onToken), eq(onComplete), eq(onError));
ChatRequest req = captor.getValue();
assertSame(campaignCtx, req.getCampaignContext());
assertNull(req.getLoreContext());
assertNull(req.getNarrativeEntity());
assertNull(req.getPageContext());
verifyNoInteractions(loreContextBuilder);
verifyNoInteractions(narrativeEntityContextBuilder);
}
@Test
void testExecute_LinkedCampaign_LoadsLoreContext() {
Campaign linked = Campaign.builder().id("c-1").name("C").loreId("lore-1").build();
LoreStructuralContext loreCtx = LoreStructuralContext.builder()
.loreName("L").loreDescription("d").folders(Collections.emptyMap()).build();
when(campaignRepository.findById("c-1")).thenReturn(Optional.of(linked));
when(campaignContextBuilder.build("c-1")).thenReturn(campaignCtx);
when(loreContextBuilder.buildOptional("lore-1")).thenReturn(Optional.of(loreCtx));
useCase.execute("c-1", null, null, messages, onToken, onComplete, onError);
ArgumentCaptor<ChatRequest> captor = ArgumentCaptor.forClass(ChatRequest.class);
verify(aiChatProvider).streamChat(captor.capture(), any(), any(), any());
assertSame(loreCtx, captor.getValue().getLoreContext());
}
@Test
void testExecute_LinkedCampaignButLoreDeleted_ContinuesWithNullLore() {
Campaign linked = Campaign.builder().id("c-1").name("C").loreId("lore-ghost").build();
when(campaignRepository.findById("c-1")).thenReturn(Optional.of(linked));
when(campaignContextBuilder.build("c-1")).thenReturn(campaignCtx);
when(loreContextBuilder.buildOptional("lore-ghost")).thenReturn(Optional.empty());
useCase.execute("c-1", null, null, messages, onToken, onComplete, onError);
ArgumentCaptor<ChatRequest> captor = ArgumentCaptor.forClass(ChatRequest.class);
verify(aiChatProvider).streamChat(captor.capture(), any(), any(), any());
assertNull(captor.getValue().getLoreContext());
// La requete doit tout de meme partir (pas d'exception).
}
@Test
void testExecute_WithEntityFocus_BuildsNarrativeEntity() {
Campaign standalone = Campaign.builder().id("c-1").name("C").loreId(null).build();
NarrativeEntityContext entity = NarrativeEntityContext.builder()
.entityType("scene").title("L'auberge").fields(Map.of()).build();
when(campaignRepository.findById("c-1")).thenReturn(Optional.of(standalone));
when(campaignContextBuilder.build("c-1")).thenReturn(campaignCtx);
when(narrativeEntityContextBuilder.build("scene", "s-1")).thenReturn(entity);
useCase.execute("c-1", "scene", "s-1", messages, onToken, onComplete, onError);
ArgumentCaptor<ChatRequest> captor = ArgumentCaptor.forClass(ChatRequest.class);
verify(aiChatProvider).streamChat(captor.capture(), any(), any(), any());
assertSame(entity, captor.getValue().getNarrativeEntity());
}
@Test
void testExecute_BlankEntityTypeOrId_NoNarrativeEntityLoaded() {
Campaign standalone = Campaign.builder().id("c-1").name("C").loreId(null).build();
when(campaignRepository.findById("c-1")).thenReturn(Optional.of(standalone));
when(campaignContextBuilder.build("c-1")).thenReturn(campaignCtx);
useCase.execute("c-1", "scene", " ", messages, onToken, onComplete, onError);
ArgumentCaptor<ChatRequest> captor = ArgumentCaptor.forClass(ChatRequest.class);
verify(aiChatProvider).streamChat(captor.capture(), any(), any(), any());
assertNull(captor.getValue().getNarrativeEntity());
verifyNoInteractions(narrativeEntityContextBuilder);
}
}

View File

@@ -0,0 +1,174 @@
package com.loremind.application.generationcontext;
import com.loremind.domain.generationcontext.ChatMessage;
import com.loremind.domain.generationcontext.ChatRequest;
import com.loremind.domain.generationcontext.LoreStructuralContext;
import com.loremind.domain.generationcontext.ports.AiChatProvider;
import com.loremind.domain.lorecontext.FieldType;
import com.loremind.domain.lorecontext.Page;
import com.loremind.domain.lorecontext.Template;
import com.loremind.domain.lorecontext.TemplateField;
import com.loremind.domain.lorecontext.ports.PageRepository;
import com.loremind.domain.lorecontext.ports.TemplateRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.function.Consumer;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
/**
* Test unitaire pour StreamChatForLoreUseCase.
* Vérifie l'orchestration : chargement du LoreStructuralContext obligatoire,
* construction conditionnelle du PageContext (sans / avec page / page sans
* template), et délégation au port AiChatProvider avec la bonne ChatRequest.
*/
@ExtendWith(MockitoExtension.class)
public class StreamChatForLoreUseCaseTest {
@Mock private LoreStructuralContextBuilder loreContextBuilder;
@Mock private PageRepository pageRepository;
@Mock private TemplateRepository templateRepository;
@Mock private AiChatProvider aiChatProvider;
@InjectMocks private StreamChatForLoreUseCase useCase;
private LoreStructuralContext loreCtx;
private List<ChatMessage> messages;
private Consumer<String> onToken;
private Runnable onComplete;
private Consumer<Throwable> onError;
@SuppressWarnings("unchecked")
@BeforeEach
void setUp() {
loreCtx = LoreStructuralContext.builder()
.loreName("Aetheria").loreDescription("d")
.folders(Collections.emptyMap())
.build();
messages = List.of();
onToken = mock(Consumer.class);
onComplete = mock(Runnable.class);
onError = mock(Consumer.class);
}
@Test
void testExecute_NoPageId_SendsRequestWithoutPageContext() {
when(loreContextBuilder.build("lore-1")).thenReturn(loreCtx);
useCase.execute("lore-1", null, messages, onToken, onComplete, onError);
ArgumentCaptor<ChatRequest> captor = ArgumentCaptor.forClass(ChatRequest.class);
verify(aiChatProvider).streamChat(captor.capture(), eq(onToken), eq(onComplete), eq(onError));
ChatRequest req = captor.getValue();
assertSame(loreCtx, req.getLoreContext());
assertNull(req.getPageContext());
assertNull(req.getCampaignContext());
}
@Test
void testExecute_BlankPageId_TreatedAsNoPage() {
when(loreContextBuilder.build("lore-1")).thenReturn(loreCtx);
useCase.execute("lore-1", " ", messages, onToken, onComplete, onError);
ArgumentCaptor<ChatRequest> captor = ArgumentCaptor.forClass(ChatRequest.class);
verify(aiChatProvider).streamChat(captor.capture(), any(), any(), any());
assertNull(captor.getValue().getPageContext());
verifyNoInteractions(pageRepository);
}
@Test
void testExecute_WithPageAndTemplate_BuildsPageContext() {
Template tpl = Template.builder()
.id("tpl-1").name("Personnage")
.fields(List.of(
TemplateField.text("Histoire"),
new TemplateField("Portrait", FieldType.IMAGE)))
.build();
Map<String, String> values = Map.of("Histoire", "...");
Page page = Page.builder()
.id("p-1").loreId("lore-1").nodeId("n-1")
.templateId("tpl-1").title("Alice")
.values(values)
.build();
when(loreContextBuilder.build("lore-1")).thenReturn(loreCtx);
when(pageRepository.findById("p-1")).thenReturn(Optional.of(page));
when(templateRepository.findById("tpl-1")).thenReturn(Optional.of(tpl));
useCase.execute("lore-1", "p-1", messages, onToken, onComplete, onError);
ArgumentCaptor<ChatRequest> captor = ArgumentCaptor.forClass(ChatRequest.class);
verify(aiChatProvider).streamChat(captor.capture(), any(), any(), any());
ChatRequest req = captor.getValue();
assertNotNull(req.getPageContext());
assertEquals("Alice", req.getPageContext().getTitle());
assertEquals("Personnage", req.getPageContext().getTemplateName());
// Seuls les champs TEXT exposes
assertEquals(List.of("Histoire"), req.getPageContext().getTemplateFields());
assertEquals(values, req.getPageContext().getValues());
}
@Test
void testExecute_PageWithoutTemplate_FallbackContext() {
Page page = Page.builder()
.id("p-1").loreId("lore-1").nodeId("n-1")
.templateId(null).title("Orphan").values(null)
.build();
when(loreContextBuilder.build("lore-1")).thenReturn(loreCtx);
when(pageRepository.findById("p-1")).thenReturn(Optional.of(page));
useCase.execute("lore-1", "p-1", messages, onToken, onComplete, onError);
ArgumentCaptor<ChatRequest> captor = ArgumentCaptor.forClass(ChatRequest.class);
verify(aiChatProvider).streamChat(captor.capture(), any(), any(), any());
var pageCtx = captor.getValue().getPageContext();
assertNotNull(pageCtx);
assertEquals("Orphan", pageCtx.getTitle());
assertEquals("?", pageCtx.getTemplateName());
assertTrue(pageCtx.getTemplateFields().isEmpty());
assertTrue(pageCtx.getValues().isEmpty());
verifyNoInteractions(templateRepository);
}
@Test
void testExecute_PageWithTemplateIdButTemplateMissing_FallbackToQuestionMark() {
Page page = Page.builder()
.id("p-1").loreId("lore-1").nodeId("n-1")
.templateId("tpl-ghost").title("Alice").values(Map.of("k", "v"))
.build();
when(loreContextBuilder.build("lore-1")).thenReturn(loreCtx);
when(pageRepository.findById("p-1")).thenReturn(Optional.of(page));
when(templateRepository.findById("tpl-ghost")).thenReturn(Optional.empty());
useCase.execute("lore-1", "p-1", messages, onToken, onComplete, onError);
ArgumentCaptor<ChatRequest> captor = ArgumentCaptor.forClass(ChatRequest.class);
verify(aiChatProvider).streamChat(captor.capture(), any(), any(), any());
var pageCtx = captor.getValue().getPageContext();
assertEquals("?", pageCtx.getTemplateName());
assertTrue(pageCtx.getTemplateFields().isEmpty());
}
@Test
void testExecute_PageNotFound_Throws() {
when(loreContextBuilder.build("lore-1")).thenReturn(loreCtx);
when(pageRepository.findById("missing")).thenReturn(Optional.empty());
assertThrows(IllegalArgumentException.class,
() -> useCase.execute("lore-1", "missing", messages, onToken, onComplete, onError));
verifyNoInteractions(aiChatProvider);
}
}

View File

@@ -0,0 +1,198 @@
package com.loremind.application.images;
import com.loremind.domain.images.Image;
import com.loremind.domain.images.ports.ImageRepository;
import com.loremind.domain.images.ports.ImageStorage;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.io.ByteArrayInputStream;
import java.io.InputStream;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.*;
/**
* Test unitaire pour ImageService.
* Couvre : validation upload (filename/MIME/size), happy path upload, compensation
* en cas d'échec DB après upload MinIO réussi, download et delete.
*/
@ExtendWith(MockitoExtension.class)
public class ImageServiceTest {
@Mock private ImageRepository imageRepository;
@Mock private ImageStorage imageStorage;
@InjectMocks private ImageService imageService;
private InputStream data;
@BeforeEach
void setUp() {
data = new ByteArrayInputStream(new byte[]{1, 2, 3});
}
@Test
void testUpload_HappyPath_PersistsMetadata() {
when(imageStorage.upload(eq("portrait.jpg"), eq("image/jpeg"), any(), eq(1024L)))
.thenReturn("images/abc.jpg");
when(imageRepository.save(any(Image.class))).thenAnswer(inv -> {
Image i = inv.getArgument(0);
i.setId("img-1");
return i;
});
Image result = imageService.upload("portrait.jpg", "image/jpeg", data, 1024L);
assertEquals("img-1", result.getId());
assertEquals("images/abc.jpg", result.getStorageKey());
assertNotNull(result.getUploadedAt());
ArgumentCaptor<Image> captor = ArgumentCaptor.forClass(Image.class);
verify(imageRepository).save(captor.capture());
Image saved = captor.getValue();
assertEquals("portrait.jpg", saved.getFilename());
assertEquals("image/jpeg", saved.getContentType());
assertEquals(1024L, saved.getSizeBytes());
}
@Test
void testUpload_NormalizesContentTypeCase() {
when(imageStorage.upload(anyString(), anyString(), any(), anyLong())).thenReturn("k");
when(imageRepository.save(any(Image.class))).thenAnswer(inv -> inv.getArgument(0));
// MIME en majuscules doit etre accepte (normalisation en lowercase lors de la validation)
assertDoesNotThrow(() -> imageService.upload("a.png", "IMAGE/PNG", data, 100L));
}
@Test
void testUpload_DbFailure_CompensatesByDeletingBinary() {
when(imageStorage.upload(anyString(), anyString(), any(), anyLong()))
.thenReturn("images/orphan.jpg");
when(imageRepository.save(any(Image.class))).thenThrow(new RuntimeException("DB down"));
RuntimeException ex = assertThrows(RuntimeException.class,
() -> imageService.upload("a.jpg", "image/jpeg", data, 500L));
assertEquals("DB down", ex.getMessage());
// Compensation : suppression du binaire orphelin
verify(imageStorage).delete("images/orphan.jpg");
}
@Test
void testUpload_BlankFilename_Throws() {
assertThrows(IllegalArgumentException.class,
() -> imageService.upload(" ", "image/jpeg", data, 100L));
verifyNoInteractions(imageStorage);
verifyNoInteractions(imageRepository);
}
@Test
void testUpload_NullFilename_Throws() {
assertThrows(IllegalArgumentException.class,
() -> imageService.upload(null, "image/jpeg", data, 100L));
}
@Test
void testUpload_UnsupportedMime_Throws() {
assertThrows(IllegalArgumentException.class,
() -> imageService.upload("a.pdf", "application/pdf", data, 100L));
verifyNoInteractions(imageStorage);
}
@Test
void testUpload_NullMime_Throws() {
assertThrows(IllegalArgumentException.class,
() -> imageService.upload("a.jpg", null, data, 100L));
}
@Test
void testUpload_ZeroSize_Throws() {
assertThrows(IllegalArgumentException.class,
() -> imageService.upload("a.jpg", "image/jpeg", data, 0L));
}
@Test
void testUpload_NegativeSize_Throws() {
assertThrows(IllegalArgumentException.class,
() -> imageService.upload("a.jpg", "image/jpeg", data, -1L));
}
@Test
void testUpload_TooLarge_Throws() {
long tooBig = 10L * 1024 * 1024 + 1;
assertThrows(IllegalArgumentException.class,
() -> imageService.upload("a.jpg", "image/jpeg", data, tooBig));
verifyNoInteractions(imageStorage);
}
@Test
void testUpload_ExactMaxSize_Accepted() {
long max = 10L * 1024 * 1024;
when(imageStorage.upload(anyString(), anyString(), any(), eq(max))).thenReturn("k");
when(imageRepository.save(any(Image.class))).thenAnswer(inv -> inv.getArgument(0));
assertDoesNotThrow(() -> imageService.upload("a.jpg", "image/jpeg", data, max));
}
@Test
void testGetById_DelegatesToRepository() {
Image img = Image.builder().id("img-1").build();
when(imageRepository.findById("img-1")).thenReturn(Optional.of(img));
assertEquals(Optional.of(img), imageService.getById("img-1"));
}
@Test
void testDownloadById_FoundReturnsStream() {
Image img = Image.builder().id("img-1").storageKey("images/k.jpg").build();
InputStream stream = new ByteArrayInputStream(new byte[]{9});
when(imageRepository.findById("img-1")).thenReturn(Optional.of(img));
when(imageStorage.download("images/k.jpg")).thenReturn(stream);
Optional<InputStream> result = imageService.downloadById("img-1");
assertTrue(result.isPresent());
assertSame(stream, result.get());
}
@Test
void testDownloadById_NotFoundReturnsEmpty() {
when(imageRepository.findById("missing")).thenReturn(Optional.empty());
assertTrue(imageService.downloadById("missing").isEmpty());
verifyNoInteractions(imageStorage);
}
@Test
void testDeleteById_RemovesBinaryThenMetadata() {
Image img = Image.builder().id("img-1").storageKey("images/k.jpg").build();
when(imageRepository.findById("img-1")).thenReturn(Optional.of(img));
imageService.deleteById("img-1");
// Ordre important : binaire d'abord, metadata ensuite.
var order = inOrder(imageStorage, imageRepository);
order.verify(imageStorage).delete("images/k.jpg");
order.verify(imageRepository).deleteById("img-1");
}
@Test
void testDeleteById_NotFound_NoOp() {
when(imageRepository.findById("missing")).thenReturn(Optional.empty());
imageService.deleteById("missing");
verifyNoInteractions(imageStorage);
verify(imageRepository, never()).deleteById(anyString());
}
}

View File

@@ -0,0 +1,125 @@
package com.loremind.application.lorecontext;
import com.loremind.domain.lorecontext.LoreNode;
import com.loremind.domain.lorecontext.ports.LoreNodeRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.util.List;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
/**
* Test unitaire pour LoreNodeService.
* Vérifie le CRUD, le pattern Parameter Object pour update/create, et
* l'immuabilité de loreId en update.
*/
@ExtendWith(MockitoExtension.class)
public class LoreNodeServiceTest {
@Mock private LoreNodeRepository loreNodeRepository;
@InjectMocks private LoreNodeService loreNodeService;
private LoreNode existing;
@BeforeEach
void setUp() {
existing = LoreNode.builder()
.id("n-1").name("PNJ").icon("users")
.parentId(null).loreId("lore-1")
.build();
}
@Test
void testCreateLoreNode_CopiesChanges() {
LoreNode changes = LoreNode.builder()
.name("Lieux").icon("map-pin").parentId("n-parent").loreId("lore-1")
.build();
when(loreNodeRepository.save(any(LoreNode.class))).thenAnswer(inv -> inv.getArgument(0));
loreNodeService.createLoreNode(changes);
ArgumentCaptor<LoreNode> captor = ArgumentCaptor.forClass(LoreNode.class);
verify(loreNodeRepository).save(captor.capture());
LoreNode saved = captor.getValue();
assertEquals("Lieux", saved.getName());
assertEquals("map-pin", saved.getIcon());
assertEquals("n-parent", saved.getParentId());
assertEquals("lore-1", saved.getLoreId());
}
@Test
void testGetLoreNodeById() {
when(loreNodeRepository.findById("n-1")).thenReturn(Optional.of(existing));
assertTrue(loreNodeService.getLoreNodeById("n-1").isPresent());
}
@Test
void testGetAll_AndByLoreId_AndByParentId() {
when(loreNodeRepository.findAll()).thenReturn(List.of(existing));
when(loreNodeRepository.findByLoreId("lore-1")).thenReturn(List.of(existing));
when(loreNodeRepository.findByParentId("n-parent")).thenReturn(List.of(existing));
assertEquals(1, loreNodeService.getAllLoreNodes().size());
assertEquals(1, loreNodeService.getLoreNodesByLoreId("lore-1").size());
assertEquals(1, loreNodeService.getLoreNodesByParentId("n-parent").size());
}
@Test
void testSearch_NullOrBlankReturnsEmpty() {
assertTrue(loreNodeService.searchLoreNodes(null).isEmpty());
assertTrue(loreNodeService.searchLoreNodes(" ").isEmpty());
verifyNoInteractions(loreNodeRepository);
}
@Test
void testSearch_TrimsQuery() {
when(loreNodeRepository.searchByName("pnj")).thenReturn(List.of(existing));
loreNodeService.searchLoreNodes(" pnj ");
verify(loreNodeRepository).searchByName("pnj");
}
@Test
void testUpdateLoreNode_AppliesChangesButKeepsLoreId() {
LoreNode changes = LoreNode.builder()
.name("Villes").icon("castle").parentId("n-parent")
.loreId("lore-2") // tentative de migration - doit etre ignoree
.build();
when(loreNodeRepository.findById("n-1")).thenReturn(Optional.of(existing));
when(loreNodeRepository.save(any(LoreNode.class))).thenAnswer(inv -> inv.getArgument(0));
LoreNode result = loreNodeService.updateLoreNode("n-1", changes);
assertEquals("Villes", result.getName());
assertEquals("castle", result.getIcon());
assertEquals("n-parent", result.getParentId());
// loreId doit rester inchange (immutable)
assertEquals("lore-1", result.getLoreId());
}
@Test
void testUpdateLoreNode_NotFoundThrows() {
when(loreNodeRepository.findById("missing")).thenReturn(Optional.empty());
assertThrows(IllegalArgumentException.class,
() -> loreNodeService.updateLoreNode("missing", existing));
}
@Test
void testDelete() {
loreNodeService.deleteLoreNode("n-1");
verify(loreNodeRepository).deleteById("n-1");
}
}

View File

@@ -0,0 +1,141 @@
package com.loremind.application.lorecontext;
import com.loremind.domain.lorecontext.Lore;
import com.loremind.domain.lorecontext.ports.LoreNodeRepository;
import com.loremind.domain.lorecontext.ports.LoreRepository;
import com.loremind.domain.lorecontext.ports.PageRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.util.List;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
/**
* Test unitaire pour LoreService.
* Vérifie le CRUD, l'enrichissement à la volée des compteurs nodeCount/pageCount,
* et le comportement défensif de la recherche.
*/
@ExtendWith(MockitoExtension.class)
public class LoreServiceTest {
@Mock private LoreRepository loreRepository;
@Mock private LoreNodeRepository loreNodeRepository;
@Mock private PageRepository pageRepository;
@InjectMocks private LoreService loreService;
private Lore testLore;
@BeforeEach
void setUp() {
testLore = Lore.builder().id("lore-1").name("Aetheria").description("d").build();
}
@Test
void testCreateLore_InitialCountsZero() {
when(loreRepository.save(any(Lore.class))).thenReturn(testLore);
loreService.createLore("Aetheria", "desc");
ArgumentCaptor<Lore> captor = ArgumentCaptor.forClass(Lore.class);
verify(loreRepository).save(captor.capture());
Lore saved = captor.getValue();
assertEquals("Aetheria", saved.getName());
assertEquals("desc", saved.getDescription());
assertEquals(0, saved.getNodeCount());
assertEquals(0, saved.getPageCount());
}
@Test
void testGetLoreById_EnrichesWithCounts() {
when(loreRepository.findById("lore-1")).thenReturn(Optional.of(testLore));
when(loreNodeRepository.countByLoreId("lore-1")).thenReturn(5L);
when(pageRepository.countByLoreId("lore-1")).thenReturn(42L);
Optional<Lore> result = loreService.getLoreById("lore-1");
assertTrue(result.isPresent());
assertEquals(5, result.get().getNodeCount());
assertEquals(42, result.get().getPageCount());
}
@Test
void testGetLoreById_NotFound() {
when(loreRepository.findById("missing")).thenReturn(Optional.empty());
assertTrue(loreService.getLoreById("missing").isEmpty());
verifyNoInteractions(loreNodeRepository);
verifyNoInteractions(pageRepository);
}
@Test
void testGetAllLores_EnrichesEach() {
Lore lore2 = Lore.builder().id("lore-2").name("B").build();
when(loreRepository.findAll()).thenReturn(List.of(testLore, lore2));
when(loreNodeRepository.countByLoreId("lore-1")).thenReturn(3L);
when(loreNodeRepository.countByLoreId("lore-2")).thenReturn(7L);
when(pageRepository.countByLoreId("lore-1")).thenReturn(10L);
when(pageRepository.countByLoreId("lore-2")).thenReturn(20L);
List<Lore> result = loreService.getAllLores();
assertEquals(2, result.size());
assertEquals(3, result.get(0).getNodeCount());
assertEquals(10, result.get(0).getPageCount());
assertEquals(7, result.get(1).getNodeCount());
assertEquals(20, result.get(1).getPageCount());
}
@Test
void testSearchLores_NullOrBlankReturnsEmpty() {
assertTrue(loreService.searchLores(null).isEmpty());
assertTrue(loreService.searchLores(" ").isEmpty());
verifyNoInteractions(loreRepository);
}
@Test
void testSearchLores_TrimsQuery() {
when(loreRepository.searchByName("aet")).thenReturn(List.of(testLore));
List<Lore> result = loreService.searchLores(" aet ");
assertEquals(1, result.size());
verify(loreRepository).searchByName("aet");
}
@Test
void testUpdateLore_Success() {
when(loreRepository.findById("lore-1")).thenReturn(Optional.of(testLore));
when(loreRepository.save(any(Lore.class))).thenAnswer(inv -> inv.getArgument(0));
Lore updated = loreService.updateLore("lore-1", "New Name", "New Desc");
assertEquals("New Name", updated.getName());
assertEquals("New Desc", updated.getDescription());
verify(loreRepository).save(testLore);
}
@Test
void testUpdateLore_NotFoundThrows() {
when(loreRepository.findById("missing")).thenReturn(Optional.empty());
assertThrows(IllegalArgumentException.class,
() -> loreService.updateLore("missing", "n", "d"));
verify(loreRepository, never()).save(any());
}
@Test
void testDeleteLore_DelegatesToRepository() {
loreService.deleteLore("lore-1");
verify(loreRepository).deleteById("lore-1");
}
}

View File

@@ -0,0 +1,170 @@
package com.loremind.application.lorecontext;
import com.loremind.domain.lorecontext.Page;
import com.loremind.domain.lorecontext.ports.PageRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
/**
* Test unitaire pour PageService.
* Vérifie la création MVP (collections initialisées vides), le CRUD, les
* copies défensives sur update, l'immuabilité de loreId/templateId, et
* la gestion null-safe des collections.
*/
@ExtendWith(MockitoExtension.class)
public class PageServiceTest {
@Mock private PageRepository pageRepository;
@InjectMocks private PageService pageService;
private Page existing;
@BeforeEach
void setUp() {
existing = Page.builder()
.id("p-1").loreId("lore-1").nodeId("n-1").templateId("tpl-1")
.title("Alice")
.values(new HashMap<>())
.build();
}
@Test
void testCreatePage_InitializesEmptyCollections() {
when(pageRepository.save(any(Page.class))).thenAnswer(inv -> inv.getArgument(0));
pageService.createPage("lore-1", "n-1", "tpl-1", "Alice");
ArgumentCaptor<Page> captor = ArgumentCaptor.forClass(Page.class);
verify(pageRepository).save(captor.capture());
Page saved = captor.getValue();
assertEquals("Alice", saved.getTitle());
assertEquals("lore-1", saved.getLoreId());
assertEquals("n-1", saved.getNodeId());
assertEquals("tpl-1", saved.getTemplateId());
assertNotNull(saved.getValues());
assertTrue(saved.getValues().isEmpty());
assertNotNull(saved.getTags());
assertTrue(saved.getTags().isEmpty());
assertNotNull(saved.getRelatedPageIds());
assertTrue(saved.getRelatedPageIds().isEmpty());
}
@Test
void testGetById_And_All_And_ByLore_And_ByNode() {
when(pageRepository.findById("p-1")).thenReturn(Optional.of(existing));
when(pageRepository.findAll()).thenReturn(List.of(existing));
when(pageRepository.findByLoreId("lore-1")).thenReturn(List.of(existing));
when(pageRepository.findByNodeId("n-1")).thenReturn(List.of(existing));
assertTrue(pageService.getPageById("p-1").isPresent());
assertEquals(1, pageService.getAllPages().size());
assertEquals(1, pageService.getPagesByLoreId("lore-1").size());
assertEquals(1, pageService.getPagesByNodeId("n-1").size());
}
@Test
void testSearch_NullOrBlankReturnsEmpty() {
assertTrue(pageService.searchPages(null).isEmpty());
assertTrue(pageService.searchPages(" ").isEmpty());
verifyNoInteractions(pageRepository);
}
@Test
void testSearch_TrimsQuery() {
when(pageRepository.searchByTitle("alice")).thenReturn(List.of(existing));
pageService.searchPages(" alice ");
verify(pageRepository).searchByTitle("alice");
}
@Test
void testUpdatePage_AppliesChangesAndKeepsImmutables() {
Map<String, String> newValues = Map.of("Histoire", "Il...");
Map<String, List<String>> newImages = Map.of("Portrait", List.of("img-1"));
List<String> newTags = List.of("hero");
List<String> newRelated = List.of("p-2");
Page changes = Page.builder()
.loreId("lore-OTHER") // doit etre ignore
.templateId("tpl-OTHER") // doit etre ignore
.nodeId("n-2") // mutable : deplacement
.title("Alice v2")
.values(newValues)
.imageValues(newImages)
.notes("notes MJ")
.tags(newTags)
.relatedPageIds(newRelated)
.build();
when(pageRepository.findById("p-1")).thenReturn(Optional.of(existing));
when(pageRepository.save(any(Page.class))).thenAnswer(inv -> inv.getArgument(0));
Page result = pageService.updatePage("p-1", changes);
assertEquals("Alice v2", result.getTitle());
assertEquals("n-2", result.getNodeId());
// loreId et templateId immuables
assertEquals("lore-1", result.getLoreId());
assertEquals("tpl-1", result.getTemplateId());
assertEquals(newValues, result.getValues());
assertNotSame(newValues, result.getValues()); // copie defensive
assertEquals(newImages, result.getImageValues());
assertNotSame(newImages, result.getImageValues());
assertEquals("notes MJ", result.getNotes());
assertEquals(newTags, result.getTags());
assertNotSame(newTags, result.getTags());
assertEquals(newRelated, result.getRelatedPageIds());
assertNotSame(newRelated, result.getRelatedPageIds());
}
@Test
void testUpdatePage_NullCollectionsBecomeEmpty() {
Page changes = Page.builder()
.nodeId("n-1").title("t")
.values(null).imageValues(null).tags(null).relatedPageIds(null)
.build();
when(pageRepository.findById("p-1")).thenReturn(Optional.of(existing));
when(pageRepository.save(any(Page.class))).thenAnswer(inv -> inv.getArgument(0));
Page result = pageService.updatePage("p-1", changes);
assertNotNull(result.getValues());
assertTrue(result.getValues().isEmpty());
assertNotNull(result.getImageValues());
assertTrue(result.getImageValues().isEmpty());
assertNotNull(result.getTags());
assertTrue(result.getTags().isEmpty());
assertNotNull(result.getRelatedPageIds());
assertTrue(result.getRelatedPageIds().isEmpty());
}
@Test
void testUpdatePage_NotFoundThrows() {
when(pageRepository.findById("missing")).thenReturn(Optional.empty());
assertThrows(IllegalArgumentException.class,
() -> pageService.updatePage("missing", existing));
}
@Test
void testDelete() {
pageService.deletePage("p-1");
verify(pageRepository).deleteById("p-1");
}
}

View File

@@ -0,0 +1,146 @@
package com.loremind.application.lorecontext;
import com.loremind.domain.lorecontext.Template;
import com.loremind.domain.lorecontext.TemplateField;
import com.loremind.domain.lorecontext.ports.TemplateRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.util.List;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
/**
* Test unitaire pour TemplateService.
* Vérifie le CRUD, la copie défensive des champs en create/update, le
* comportement null-safe de fields, et l'immuabilité de loreId.
*/
@ExtendWith(MockitoExtension.class)
public class TemplateServiceTest {
@Mock private TemplateRepository templateRepository;
@InjectMocks private TemplateService templateService;
private Template existing;
private List<TemplateField> originalFields;
@BeforeEach
void setUp() {
originalFields = List.of(TemplateField.text("Histoire"), TemplateField.image("Portrait"));
existing = Template.builder()
.id("tpl-1").loreId("lore-1").name("Personnage")
.description("desc").defaultNodeId("n-1")
.fields(List.of(TemplateField.text("Old")))
.build();
}
@Test
void testCreateTemplate_CopiesFieldsDefensively() {
when(templateRepository.save(any(Template.class))).thenAnswer(inv -> inv.getArgument(0));
templateService.createTemplate("lore-1", "T", "d", "n-1", originalFields);
ArgumentCaptor<Template> captor = ArgumentCaptor.forClass(Template.class);
verify(templateRepository).save(captor.capture());
Template saved = captor.getValue();
assertEquals("T", saved.getName());
assertEquals(2, saved.getFields().size());
assertNotSame(originalFields, saved.getFields()); // copie defensive
}
@Test
void testCreateTemplate_NullFieldsBecomesEmptyList() {
when(templateRepository.save(any(Template.class))).thenAnswer(inv -> inv.getArgument(0));
templateService.createTemplate("lore-1", "T", "d", "n-1", null);
ArgumentCaptor<Template> captor = ArgumentCaptor.forClass(Template.class);
verify(templateRepository).save(captor.capture());
assertNotNull(captor.getValue().getFields());
assertTrue(captor.getValue().getFields().isEmpty());
}
@Test
void testGetTemplateById_AndByLoreId_AndAll() {
when(templateRepository.findById("tpl-1")).thenReturn(Optional.of(existing));
when(templateRepository.findAll()).thenReturn(List.of(existing));
when(templateRepository.findByLoreId("lore-1")).thenReturn(List.of(existing));
assertTrue(templateService.getTemplateById("tpl-1").isPresent());
assertEquals(1, templateService.getAllTemplates().size());
assertEquals(1, templateService.getTemplatesByLoreId("lore-1").size());
}
@Test
void testSearch_NullOrBlankReturnsEmpty() {
assertTrue(templateService.searchTemplates(null).isEmpty());
assertTrue(templateService.searchTemplates(" ").isEmpty());
verifyNoInteractions(templateRepository);
}
@Test
void testSearch_TrimsQuery() {
when(templateRepository.searchByName("perso")).thenReturn(List.of(existing));
templateService.searchTemplates(" perso ");
verify(templateRepository).searchByName("perso");
}
@Test
void testUpdateTemplate_AppliesChangesKeepsLoreId() {
Template changes = Template.builder()
.loreId("lore-OTHER") // doit etre ignore
.name("Nouveau").description("nd").defaultNodeId("n-2")
.fields(originalFields)
.build();
when(templateRepository.findById("tpl-1")).thenReturn(Optional.of(existing));
when(templateRepository.save(any(Template.class))).thenAnswer(inv -> inv.getArgument(0));
Template result = templateService.updateTemplate("tpl-1", changes);
assertEquals("Nouveau", result.getName());
assertEquals("nd", result.getDescription());
assertEquals("n-2", result.getDefaultNodeId());
assertEquals(2, result.getFields().size());
assertNotSame(originalFields, result.getFields()); // copie defensive
// loreId immuable
assertEquals("lore-1", result.getLoreId());
}
@Test
void testUpdateTemplate_NullFieldsBecomesEmpty() {
Template changes = Template.builder().name("N").description("d")
.defaultNodeId("n-1").fields(null).build();
when(templateRepository.findById("tpl-1")).thenReturn(Optional.of(existing));
when(templateRepository.save(any(Template.class))).thenAnswer(inv -> inv.getArgument(0));
Template result = templateService.updateTemplate("tpl-1", changes);
assertNotNull(result.getFields());
assertTrue(result.getFields().isEmpty());
}
@Test
void testUpdateTemplate_NotFoundThrows() {
when(templateRepository.findById("missing")).thenReturn(Optional.empty());
assertThrows(IllegalArgumentException.class,
() -> templateService.updateTemplate("missing", existing));
}
@Test
void testDelete() {
templateService.deleteTemplate("tpl-1");
verify(templateRepository).deleteById("tpl-1");
}
}