diff --git a/.gitea/workflows/e2e.yml b/.gitea/workflows/e2e.yml index 54d0f27..ce463a3 100644 --- a/.gitea/workflows/e2e.yml +++ b/.gitea/workflows/e2e.yml @@ -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 diff --git a/core/src/main/java/com/loremind/infrastructure/web/controller/ArcController.java b/core/src/main/java/com/loremind/infrastructure/web/controller/ArcController.java index 75b0394..75196be 100644 --- a/core/src/main/java/com/loremind/infrastructure/web/controller/ArcController.java +++ b/core/src/main/java/com/loremind/infrastructure/web/controller/ArcController.java @@ -40,17 +40,11 @@ public class ArcController { } @GetMapping - public ResponseEntity> getAllArcs() { - List arcs = arcService.getAllArcs(); - List arcDTOs = arcs.stream() - .map(arcMapper::toDTO) - .collect(Collectors.toList()); - return ResponseEntity.ok(arcDTOs); - } - - @GetMapping("/campaign/{campaignId}") - public ResponseEntity> getArcsByCampaignId(@PathVariable String campaignId) { - List arcs = arcService.getArcsByCampaignId(campaignId); + public ResponseEntity> getAllArcs( + @RequestParam(value = "campaignId", required = false) String campaignId) { + List arcs = (campaignId != null && !campaignId.isBlank()) + ? arcService.getArcsByCampaignId(campaignId) + : arcService.getAllArcs(); List arcDTOs = arcs.stream() .map(arcMapper::toDTO) .collect(Collectors.toList()); diff --git a/core/src/main/java/com/loremind/infrastructure/web/controller/ChapterController.java b/core/src/main/java/com/loremind/infrastructure/web/controller/ChapterController.java index 486d6d1..f029a8d 100644 --- a/core/src/main/java/com/loremind/infrastructure/web/controller/ChapterController.java +++ b/core/src/main/java/com/loremind/infrastructure/web/controller/ChapterController.java @@ -40,17 +40,11 @@ public class ChapterController { } @GetMapping - public ResponseEntity> getAllChapters() { - List chapters = chapterService.getAllChapters(); - List chapterDTOs = chapters.stream() - .map(chapterMapper::toDTO) - .collect(Collectors.toList()); - return ResponseEntity.ok(chapterDTOs); - } - - @GetMapping("/arc/{arcId}") - public ResponseEntity> getChaptersByArcId(@PathVariable String arcId) { - List chapters = chapterService.getChaptersByArcId(arcId); + public ResponseEntity> getAllChapters( + @RequestParam(value = "arcId", required = false) String arcId) { + List chapters = (arcId != null && !arcId.isBlank()) + ? chapterService.getChaptersByArcId(arcId) + : chapterService.getAllChapters(); List chapterDTOs = chapters.stream() .map(chapterMapper::toDTO) .collect(Collectors.toList()); diff --git a/core/src/main/java/com/loremind/infrastructure/web/controller/SceneController.java b/core/src/main/java/com/loremind/infrastructure/web/controller/SceneController.java index e743e83..f76a838 100644 --- a/core/src/main/java/com/loremind/infrastructure/web/controller/SceneController.java +++ b/core/src/main/java/com/loremind/infrastructure/web/controller/SceneController.java @@ -40,17 +40,11 @@ public class SceneController { } @GetMapping - public ResponseEntity> getAllScenes() { - List scenes = sceneService.getAllScenes(); - List sceneDTOs = scenes.stream() - .map(sceneMapper::toDTO) - .collect(Collectors.toList()); - return ResponseEntity.ok(sceneDTOs); - } - - @GetMapping("/chapter/{chapterId}") - public ResponseEntity> getScenesByChapterId(@PathVariable String chapterId) { - List scenes = sceneService.getScenesByChapterId(chapterId); + public ResponseEntity> getAllScenes( + @RequestParam(value = "chapterId", required = false) String chapterId) { + List scenes = (chapterId != null && !chapterId.isBlank()) + ? sceneService.getScenesByChapterId(chapterId) + : sceneService.getAllScenes(); List sceneDTOs = scenes.stream() .map(sceneMapper::toDTO) .collect(Collectors.toList()); diff --git a/core/src/test/java/com/loremind/infrastructure/web/controller/ArcControllerTest.java b/core/src/test/java/com/loremind/infrastructure/web/controller/ArcControllerTest.java index 39e3f1b..1eca1b6 100644 --- a/core/src/test/java/com/loremind/infrastructure/web/controller/ArcControllerTest.java +++ b/core/src/test/java/com/loremind/infrastructure/web/controller/ArcControllerTest.java @@ -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()); } diff --git a/core/src/test/java/com/loremind/infrastructure/web/controller/ChapterControllerTest.java b/core/src/test/java/com/loremind/infrastructure/web/controller/ChapterControllerTest.java index a151666..734f52f 100644 --- a/core/src/test/java/com/loremind/infrastructure/web/controller/ChapterControllerTest.java +++ b/core/src/test/java/com/loremind/infrastructure/web/controller/ChapterControllerTest.java @@ -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()); } diff --git a/core/src/test/java/com/loremind/infrastructure/web/controller/SceneControllerTest.java b/core/src/test/java/com/loremind/infrastructure/web/controller/SceneControllerTest.java index 410e0c4..12e9c84 100644 --- a/core/src/test/java/com/loremind/infrastructure/web/controller/SceneControllerTest.java +++ b/core/src/test/java/com/loremind/infrastructure/web/controller/SceneControllerTest.java @@ -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()); } diff --git a/web/e2e/fixtures/api.ts b/web/e2e/fixtures/api.ts index 31975e2..ac5f0ff 100644 --- a/web/e2e/fixtures/api.ts +++ b/web/e2e/fixtures/api.ts @@ -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> { + 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> { + 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> { + 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, diff --git a/web/e2e/tests/campaign/arc-create.spec.ts b/web/e2e/tests/campaign/arc-create.spec.ts new file mode 100644 index 0000000..4c91ba1 --- /dev/null +++ b/web/e2e/tests/campaign/arc-create.spec.ts @@ -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(); + }); +}); diff --git a/web/e2e/tests/campaign/chapter-create.spec.ts b/web/e2e/tests/campaign/chapter-create.spec.ts new file mode 100644 index 0000000..a0efb24 --- /dev/null +++ b/web/e2e/tests/campaign/chapter-create.spec.ts @@ -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(); + }); +}); diff --git a/web/e2e/tests/campaign/scene-create.spec.ts b/web/e2e/tests/campaign/scene-create.spec.ts new file mode 100644 index 0000000..d479cf6 --- /dev/null +++ b/web/e2e/tests/campaign/scene-create.spec.ts @@ -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(); + }); +}); diff --git a/web/e2e/tests/lore/lore-detail-edit.spec.ts b/web/e2e/tests/lore/lore-detail-edit.spec.ts new file mode 100644 index 0000000..b30f848 --- /dev/null +++ b/web/e2e/tests/lore/lore-detail-edit.spec.ts @@ -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); + }); +}); diff --git a/web/src/app/campaigns/arc-create/arc-create.component.html b/web/src/app/campaigns/arc-create/arc-create.component.html index e18f52a..7b04d28 100644 --- a/web/src/app/campaigns/arc-create/arc-create.component.html +++ b/web/src/app/campaigns/arc-create/arc-create.component.html @@ -7,8 +7,9 @@
- +
- + + +