Fix workflow gitea action pour e2e (tests automatisés via playwright) + correction d'une incohérence dans l'API coté java. Ajout d'autres tests utilisateur
Some checks failed
E2E Tests / e2e (push) Failing after 2m31s

This commit is contained in:
2026-04-25 00:45:04 +02:00
parent 6c6bd20f0d
commit 4b866e5212
17 changed files with 327 additions and 51 deletions

View File

@@ -41,9 +41,20 @@ jobs:
run: |
docker compose -f docker-compose.yml -f docker-compose.e2e.yml up -d --build
- name: Attach runner to compose network
run: |
NET=$(docker network ls --format '{{.Name}}' | grep -E '(^|_)loremind(_|$)' | grep -i default | head -1)
if [ -z "$NET" ]; then
echo "Compose network not found" >&2
docker network ls
exit 1
fi
echo "Connecting $(hostname) to network $NET"
docker network connect "$NET" "$(hostname)"
- name: Wait for web to be ready
run: |
timeout 180 bash -c 'until curl -sf http://localhost:8081 > /dev/null; do echo "waiting..."; sleep 3; done'
timeout 180 bash -c 'until curl -sf http://web/ > /dev/null; do echo "waiting..."; sleep 3; done'
- name: Install web deps
working-directory: web
@@ -56,7 +67,7 @@ jobs:
- name: Run Playwright tests
working-directory: web
env:
E2E_BASE_URL: http://localhost:8081
E2E_BASE_URL: http://web
CI: 'true'
run: npm run e2e

View File

@@ -40,17 +40,11 @@ public class ArcController {
}
@GetMapping
public ResponseEntity<List<ArcDTO>> getAllArcs() {
List<Arc> arcs = arcService.getAllArcs();
List<ArcDTO> arcDTOs = arcs.stream()
.map(arcMapper::toDTO)
.collect(Collectors.toList());
return ResponseEntity.ok(arcDTOs);
}
@GetMapping("/campaign/{campaignId}")
public ResponseEntity<List<ArcDTO>> getArcsByCampaignId(@PathVariable String campaignId) {
List<Arc> arcs = arcService.getArcsByCampaignId(campaignId);
public ResponseEntity<List<ArcDTO>> getAllArcs(
@RequestParam(value = "campaignId", required = false) String campaignId) {
List<Arc> arcs = (campaignId != null && !campaignId.isBlank())
? arcService.getArcsByCampaignId(campaignId)
: arcService.getAllArcs();
List<ArcDTO> arcDTOs = arcs.stream()
.map(arcMapper::toDTO)
.collect(Collectors.toList());

View File

@@ -40,17 +40,11 @@ public class ChapterController {
}
@GetMapping
public ResponseEntity<List<ChapterDTO>> getAllChapters() {
List<Chapter> chapters = chapterService.getAllChapters();
List<ChapterDTO> chapterDTOs = chapters.stream()
.map(chapterMapper::toDTO)
.collect(Collectors.toList());
return ResponseEntity.ok(chapterDTOs);
}
@GetMapping("/arc/{arcId}")
public ResponseEntity<List<ChapterDTO>> getChaptersByArcId(@PathVariable String arcId) {
List<Chapter> chapters = chapterService.getChaptersByArcId(arcId);
public ResponseEntity<List<ChapterDTO>> getAllChapters(
@RequestParam(value = "arcId", required = false) String arcId) {
List<Chapter> chapters = (arcId != null && !arcId.isBlank())
? chapterService.getChaptersByArcId(arcId)
: chapterService.getAllChapters();
List<ChapterDTO> chapterDTOs = chapters.stream()
.map(chapterMapper::toDTO)
.collect(Collectors.toList());

View File

@@ -40,17 +40,11 @@ public class SceneController {
}
@GetMapping
public ResponseEntity<List<SceneDTO>> getAllScenes() {
List<Scene> scenes = sceneService.getAllScenes();
List<SceneDTO> sceneDTOs = scenes.stream()
.map(sceneMapper::toDTO)
.collect(Collectors.toList());
return ResponseEntity.ok(sceneDTOs);
}
@GetMapping("/chapter/{chapterId}")
public ResponseEntity<List<SceneDTO>> getScenesByChapterId(@PathVariable String chapterId) {
List<Scene> scenes = sceneService.getScenesByChapterId(chapterId);
public ResponseEntity<List<SceneDTO>> getAllScenes(
@RequestParam(value = "chapterId", required = false) String chapterId) {
List<Scene> scenes = (chapterId != null && !chapterId.isBlank())
? sceneService.getScenesByChapterId(chapterId)
: sceneService.getAllScenes();
List<SceneDTO> sceneDTOs = scenes.stream()
.map(sceneMapper::toDTO)
.collect(Collectors.toList());

View File

@@ -79,7 +79,7 @@ class ArcControllerTest {
@Test
void getByCampaign_pathVariant() throws Exception {
arcRepository.save(Arc.builder().campaignId(campaignId).name("A").order(0).build());
mockMvc.perform(get("/api/arcs/campaign/{id}", campaignId))
mockMvc.perform(get("/api/arcs").param("campaignId", campaignId))
.andExpect(status().isOk())
.andExpect(jsonPath("$").isArray());
}

View File

@@ -81,7 +81,7 @@ class ChapterControllerTest {
@Test
void getByArc_pathVariant() throws Exception {
chapterRepository.save(Chapter.builder().arcId(arcId).name("A").order(0).build());
mockMvc.perform(get("/api/chapters/arc/{id}", arcId))
mockMvc.perform(get("/api/chapters").param("arcId", arcId))
.andExpect(status().isOk())
.andExpect(jsonPath("$").isArray());
}

View File

@@ -85,7 +85,7 @@ class SceneControllerTest {
@Test
void getByChapter_pathVariant() throws Exception {
sceneRepository.save(Scene.builder().chapterId(chapterId).name("A").order(0).build());
mockMvc.perform(get("/api/scenes/chapter/{id}", chapterId))
mockMvc.perform(get("/api/scenes").param("chapterId", chapterId))
.andExpect(status().isOk())
.andExpect(jsonPath("$").isArray());
}

View File

@@ -36,6 +36,42 @@ export async function deleteLore(request: APIRequestContext, loreId: string): Pr
await request.delete(`/api/lores/${loreId}`).catch(() => undefined);
}
export async function getLoreById(
request: APIRequestContext,
loreId: string,
): Promise<{ id: string; name: string; description: string }> {
const res = await request.get(`/api/lores/${loreId}`);
expect(res.ok(), `GET /api/lores/${loreId} -> ${res.status()}`).toBeTruthy();
return res.json();
}
export async function getArcsForCampaign(
request: APIRequestContext,
campaignId: string,
): Promise<Array<{ id: string; name: string; campaignId: string }>> {
const res = await request.get(`/api/arcs?campaignId=${campaignId}`);
expect(res.ok(), `GET /api/arcs -> ${res.status()}`).toBeTruthy();
return res.json();
}
export async function getChaptersForArc(
request: APIRequestContext,
arcId: string,
): Promise<Array<{ id: string; name: string; arcId: string }>> {
const res = await request.get(`/api/chapters?arcId=${arcId}`);
expect(res.ok(), `GET /api/chapters -> ${res.status()}`).toBeTruthy();
return res.json();
}
export async function getScenesForChapter(
request: APIRequestContext,
chapterId: string,
): Promise<Array<{ id: string; name: string; chapterId: string }>> {
const res = await request.get(`/api/scenes?chapterId=${chapterId}`);
expect(res.ok(), `GET /api/scenes -> ${res.status()}`).toBeTruthy();
return res.json();
}
export async function getTemplatesForLore(
request: APIRequestContext,
loreId: string,

View File

@@ -0,0 +1,50 @@
import { test, expect } from '@playwright/test';
import {
seedCampaign,
deleteCampaign,
getArcById,
type SeededCampaign,
} from '../../fixtures/api';
test.describe('Arc creation', () => {
let campaign: SeededCampaign;
test.beforeEach(async ({ request }) => {
campaign = await seedCampaign(request);
});
test.afterEach(async ({ request }) => {
if (campaign?.id) await deleteCampaign(request, campaign.id);
});
test('creates an arc and redirects to its view', async ({ page, request }) => {
const arcName = `Arc ${Date.now()}`;
const description = 'Synopsis test';
await page.goto(`/campaigns/${campaign.id}/arcs/create`);
await expect(page.getByRole('heading', { name: /Créer un nouvel arc/i })).toBeVisible();
await page.getByLabel(/Nom de l'arc/i).fill(arcName);
await page.getByLabel(/Description/i).fill(description);
await page.getByRole('button', { name: /^Créer l'arc$/i }).click();
await expect(page).toHaveURL(new RegExp(`/campaigns/${campaign.id}/arcs/\\d+$`));
const createdId = page.url().match(/\/arcs\/(\d+)$/)?.[1];
expect(createdId).toBeTruthy();
const persisted = await getArcById(request, createdId!);
expect(persisted.name).toBe(arcName);
expect(persisted.description).toBe(description);
});
test('submit is disabled when name is empty', async ({ page }) => {
await page.goto(`/campaigns/${campaign.id}/arcs/create`);
const submit = page.getByRole('button', { name: /^Créer l'arc$/i });
await expect(submit).toBeDisabled();
await page.getByLabel(/Nom de l'arc/i).fill('Quelque chose');
await expect(submit).toBeEnabled();
});
});

View File

@@ -0,0 +1,54 @@
import { test, expect } from '@playwright/test';
import {
seedCampaign,
seedArc,
deleteCampaign,
getChapterById,
type SeededCampaign,
type SeededArc,
} from '../../fixtures/api';
test.describe('Chapter creation', () => {
let campaign: SeededCampaign;
let arc: SeededArc;
test.beforeEach(async ({ request }) => {
campaign = await seedCampaign(request);
arc = await seedArc(request, { campaignId: campaign.id });
});
test.afterEach(async ({ request }) => {
if (campaign?.id) await deleteCampaign(request, campaign.id);
});
test('creates a chapter and redirects to its view', async ({ page, request }) => {
const chapterName = `Chapitre ${Date.now()}`;
await page.goto(`/campaigns/${campaign.id}/arcs/${arc.id}/chapters/create`);
await expect(page.getByRole('heading', { name: /Créer un nouveau chapitre/i })).toBeVisible();
await page.getByLabel(/Nom du chapitre/i).fill(chapterName);
await page.getByLabel(/Description/i).fill('Synopsis du chapitre');
await page.getByRole('button', { name: /^Créer le chapitre$/i }).click();
await expect(page).toHaveURL(
new RegExp(`/campaigns/${campaign.id}/arcs/${arc.id}/chapters/\\d+$`),
);
const createdId = page.url().match(/\/chapters\/(\d+)$/)?.[1];
expect(createdId).toBeTruthy();
const persisted = await getChapterById(request, createdId!);
expect(persisted.name).toBe(chapterName);
});
test('submit is disabled when name is empty', async ({ page }) => {
await page.goto(`/campaigns/${campaign.id}/arcs/${arc.id}/chapters/create`);
const submit = page.getByRole('button', { name: /^Créer le chapitre$/i });
await expect(submit).toBeDisabled();
await page.getByLabel(/Nom du chapitre/i).fill('OK');
await expect(submit).toBeEnabled();
});
});

View File

@@ -0,0 +1,64 @@
import { test, expect } from '@playwright/test';
import {
seedCampaign,
seedArc,
seedChapter,
deleteCampaign,
getSceneById,
type SeededCampaign,
type SeededArc,
type SeededChapter,
} from '../../fixtures/api';
test.describe('Scene creation', () => {
let campaign: SeededCampaign;
let arc: SeededArc;
let chapter: SeededChapter;
test.beforeEach(async ({ request }) => {
campaign = await seedCampaign(request);
arc = await seedArc(request, { campaignId: campaign.id });
chapter = await seedChapter(request, { arcId: arc.id });
});
test.afterEach(async ({ request }) => {
if (campaign?.id) await deleteCampaign(request, campaign.id);
});
test('creates a scene and redirects to its view', async ({ page, request }) => {
const sceneName = `Scène ${Date.now()}`;
await page.goto(
`/campaigns/${campaign.id}/arcs/${arc.id}/chapters/${chapter.id}/scenes/create`,
);
await expect(page.getByRole('heading', { name: /Créer une nouvelle scène/i })).toBeVisible();
await page.getByLabel(/Nom de la scène/i).fill(sceneName);
await page.getByLabel(/Description/i).fill('Résumé rapide de la scène.');
await page.getByRole('button', { name: /^Créer la scène$/i }).click();
await expect(page).toHaveURL(
new RegExp(
`/campaigns/${campaign.id}/arcs/${arc.id}/chapters/${chapter.id}/scenes/\\d+$`,
),
);
const createdId = page.url().match(/\/scenes\/(\d+)$/)?.[1];
expect(createdId).toBeTruthy();
const persisted = await getSceneById(request, createdId!);
expect(persisted.name).toBe(sceneName);
});
test('submit is disabled when name is empty', async ({ page }) => {
await page.goto(
`/campaigns/${campaign.id}/arcs/${arc.id}/chapters/${chapter.id}/scenes/create`,
);
const submit = page.getByRole('button', { name: /^Créer la scène$/i });
await expect(submit).toBeDisabled();
await page.getByLabel(/Nom de la scène/i).fill('OK');
await expect(submit).toBeEnabled();
});
});

View File

@@ -0,0 +1,70 @@
import { test, expect } from '@playwright/test';
import { seedLoreWithFolder, deleteLore, getLoreById, type SeededLore } from '../../fixtures/api';
test.describe('Lore inline edit (on detail page)', () => {
let seeded: SeededLore;
test.beforeEach(async ({ request }) => {
seeded = await seedLoreWithFolder(request);
});
test.afterEach(async ({ request }) => {
if (seeded?.id) await deleteLore(request, seeded.id);
});
test('renames the lore inline and persists the change', async ({ page, request }) => {
const newName = `${seeded.name} renamed`;
const newDescription = 'Nouvelle description éditée via UI';
await page.goto(`/lore/${seeded.id}`);
await expect(page.locator('.detail-header h1')).toHaveText(seeded.name);
await page.getByRole('button', { name: /^Modifier$/i }).click();
const nameInput = page.getByLabel(/^Nom$/);
const descInput = page.getByLabel(/^Description$/);
await expect(nameInput).toHaveValue(seeded.name);
await nameInput.fill(newName);
await descInput.fill(newDescription);
await page.getByRole('button', { name: /^Sauvegarder$/i }).click();
// Sortie du mode édition : le header bascule en mode lecture avec la nouvelle valeur.
await expect(page.locator('.detail-header h1')).toHaveText(newName);
await expect(page.locator('.detail-header .description')).toHaveText(newDescription);
const persisted = await getLoreById(request, seeded.id);
expect(persisted.name).toBe(newName);
expect(persisted.description).toBe(newDescription);
});
test('save button is disabled when name is emptied during edit', async ({ page }) => {
await page.goto(`/lore/${seeded.id}`);
await page.getByRole('button', { name: /^Modifier$/i }).click();
const saveBtn = page.getByRole('button', { name: /^Sauvegarder$/i });
await expect(saveBtn).toBeEnabled();
await page.getByLabel(/^Nom$/).fill('');
await expect(saveBtn).toBeDisabled();
await page.getByLabel(/^Nom$/).fill(' ');
await expect(saveBtn).toBeDisabled();
});
test('cancel discards the in-flight edits', async ({ page, request }) => {
await page.goto(`/lore/${seeded.id}`);
await page.getByRole('button', { name: /^Modifier$/i }).click();
await page.getByLabel(/^Nom$/).fill('Name jamais sauvegardé');
await page.getByRole('button', { name: /^Annuler$/i }).click();
// Retour en mode lecture avec le nom d'origine.
await expect(page.locator('.detail-header h1')).toHaveText(seeded.name);
const persisted = await getLoreById(request, seeded.id);
expect(persisted.name).toBe(seeded.name);
});
});

View File

@@ -7,8 +7,9 @@
<form [formGroup]="form" (ngSubmit)="submit()" class="arc-form">
<div class="field">
<label>Nom de l'arc *</label>
<label for="arc-create-name">Nom de l'arc *</label>
<input
id="arc-create-name"
type="text"
formControlName="name"
placeholder="Ex: L'Ombre du Nord"
@@ -17,8 +18,9 @@
</div>
<div class="field">
<label>Description</label>
<label for="arc-create-description">Description</label>
<textarea
id="arc-create-description"
formControlName="description"
placeholder="Décrivez l'arc narratif principal..."
rows="5">

View File

@@ -8,8 +8,9 @@
<form [formGroup]="form" (ngSubmit)="submit()" class="chapter-form">
<div class="field">
<label>Nom du chapitre *</label>
<label for="chapter-create-name">Nom du chapitre *</label>
<input
id="chapter-create-name"
type="text"
formControlName="name"
placeholder="Ex: Chapitre 1: Les Disparitions"
@@ -18,8 +19,9 @@
</div>
<div class="field">
<label>Description</label>
<label for="chapter-create-description">Description</label>
<textarea
id="chapter-create-description"
formControlName="description"
placeholder="Décrivez ce chapitre..."
rows="5">

View File

@@ -8,8 +8,9 @@
<form [formGroup]="form" (ngSubmit)="submit()" class="scene-form">
<div class="field">
<label>Nom de la scène *</label>
<label for="scene-create-name">Nom de la scène *</label>
<input
id="scene-create-name"
type="text"
formControlName="name"
placeholder="Ex: Arrivée au village"
@@ -18,8 +19,9 @@
</div>
<div class="field">
<label>Description</label>
<label for="scene-create-description">Description</label>
<textarea
id="scene-create-description"
formControlName="description"
placeholder="Décrivez la scène, les événements clés, les PNJ présents..."
rows="6">

View File

@@ -21,12 +21,12 @@
<!-- ============ Header : mode édition inline ============ -->
<div class="detail-header edit-mode" *ngIf="editing">
<div class="field">
<label>Nom</label>
<input type="text" [(ngModel)]="editName" name="editName" required />
<label for="lore-detail-edit-name">Nom</label>
<input id="lore-detail-edit-name" type="text" [(ngModel)]="editName" name="editName" required />
</div>
<div class="field">
<label>Description</label>
<textarea [(ngModel)]="editDescription" name="editDescription" rows="3"></textarea>
<label for="lore-detail-edit-description">Description</label>
<textarea id="lore-detail-edit-description" [(ngModel)]="editDescription" name="editDescription" rows="3"></textarea>
</div>
<div class="header-actions">
<button type="button" class="btn-primary" (click)="saveEdit()" [disabled]="!editName.trim()">

View File

@@ -60,7 +60,8 @@ export class CampaignService {
// ========== ARC ==========
getArcs(campaignId: string): Observable<Arc[]> {
return this.http.get<Arc[]>(`/api/arcs/campaign/${campaignId}`);
const params = new HttpParams().set('campaignId', campaignId);
return this.http.get<Arc[]>('/api/arcs', { params });
}
getArcById(id: string): Observable<Arc> {
@@ -85,7 +86,8 @@ export class CampaignService {
// ========== CHAPTER ==========
getChapters(arcId: string): Observable<Chapter[]> {
return this.http.get<Chapter[]>(`/api/chapters/arc/${arcId}`);
const params = new HttpParams().set('arcId', arcId);
return this.http.get<Chapter[]>('/api/chapters', { params });
}
getChapterById(id: string): Observable<Chapter> {
@@ -110,7 +112,8 @@ export class CampaignService {
// ========== SCENE ==========
getScenes(chapterId: string): Observable<Scene[]> {
return this.http.get<Scene[]>(`/api/scenes/chapter/${chapterId}`);
const params = new HttpParams().set('chapterId', chapterId);
return this.http.get<Scene[]>('/api/scenes', { params });
}
getSceneById(id: string): Observable<Scene> {