6 Commits

Author SHA1 Message Date
Matthieu
6bed715b7f fix(machines): fix skeleton creation — load all items + atomic creation
- Load composants/pieces/products with itemsPerPage: 200 instead of 30
  (root cause: only first 30 items were available in creation dropdowns)
- Rollback machine if skeleton PATCH fails (delete orphaned machine)
- Initialize custom fields after successful machine creation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 10:39:45 +01:00
Matthieu
dbf8c8856b test(e2e) : add Playwright setup with product and category CRUD specs
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 09:07:23 +01:00
Matthieu
62127a33f5 chore(release) : update changelog for v1.6.1
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-12 14:51:13 +01:00
Matthieu
2fffe4a368 chore(release) : update changelog for v1.6.0
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-12 14:27:43 +01:00
Matthieu
c9054e5b4d feat(categories): add bidirectional piece/component category conversion
Add a "Convertir" button on piece and component category lists that allows
converting an entire category (and all its items) between piece and component.
Includes a modal with eligibility checks and blocker display.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-12 14:26:41 +01:00
Matthieu
5cab15422d fix(documents) : exclude path from collection to prevent OOM, lazy-load on demand
The path field contains base64 data URIs that can be several MB each.
Loading 200 documents at once exceeded the 128MB PHP memory limit.
Now the collection endpoint uses document:list group (without path)
and the frontend fetches the full document on demand when the user
clicks download or preview.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 17:16:15 +01:00
14 changed files with 923 additions and 14 deletions

5
.gitignore vendored
View File

@@ -22,3 +22,8 @@ logs
.env
.env.*
!.env.example
# Playwright
e2e/.auth/
playwright-report/
test-results/

View File

@@ -0,0 +1,172 @@
<template>
<dialog class="modal" :class="{ 'modal-open': open }">
<div class="modal-box max-w-2xl">
<h3 class="text-lg font-bold text-base-content">
Convertir la catégorie
</h3>
<!-- Loading state -->
<div v-if="checking" class="mt-4 flex items-center gap-2 text-sm text-info">
<span class="loading loading-spinner loading-sm" aria-hidden="true"></span>
Vérification de la conversion
</div>
<!-- Error state -->
<div v-else-if="checkError" class="mt-4 text-sm text-error">
{{ checkError }}
</div>
<!-- Blocked state -->
<template v-else-if="checkResult && !checkResult.canConvert">
<p class="mt-3 text-sm text-base-content/70">
La conversion de « {{ modelType?.name }} » est impossible pour les raisons suivantes :
</p>
<ul class="mt-3 space-y-1">
<li
v-for="(blocker, i) in checkResult.blockers"
:key="i"
class="flex items-start gap-2 rounded-lg border border-error/20 bg-error/5 px-3 py-2 text-sm text-error"
>
<IconLucideCircleX class="mt-0.5 h-4 w-4 shrink-0" aria-hidden="true" />
{{ blocker }}
</li>
</ul>
</template>
<!-- Eligible state -->
<template v-else-if="checkResult && checkResult.canConvert">
<div class="mt-3 rounded-lg border border-warning/20 bg-warning/5 px-4 py-3">
<p class="text-sm font-medium text-warning">
{{ directionLabel }}
</p>
<p class="mt-1 text-sm text-base-content/70">
{{ checkResult.itemCount }} élément(s) seront convertis. Cette opération est irréversible.
</p>
</div>
<div
v-if="checkResult.names.length > 0"
class="mt-3 rounded-xl border border-base-200 bg-base-100"
>
<p class="px-4 pt-3 text-sm font-medium text-base-content/70">
Éléments concernés :
</p>
<ul class="max-h-48 divide-y divide-base-200 overflow-y-auto px-4 pb-3">
<li
v-for="(name, i) in checkResult.names"
:key="i"
class="py-1.5 text-sm text-base-content"
>
{{ name }}
</li>
</ul>
</div>
<div v-if="convertError" class="mt-3 text-sm text-error">
{{ convertError }}
</div>
</template>
<div class="modal-action">
<button
type="button"
class="btn"
:disabled="converting"
@click="emit('close')"
>
Fermer
</button>
<button
v-if="checkResult?.canConvert"
type="button"
class="btn btn-warning"
:disabled="converting"
@click="doConvert"
>
<span v-if="converting" class="loading loading-spinner loading-sm" aria-hidden="true"></span>
Convertir
</button>
</div>
</div>
</dialog>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue';
import IconLucideCircleX from '~icons/lucide/circle-x';
import {
checkConversion,
convertCategory,
type ConversionCheck,
type ModelType,
} from '~/services/modelTypes';
const props = defineProps<{
open: boolean;
modelType: ModelType | null;
}>();
const emit = defineEmits<{
(e: 'close'): void;
(e: 'converted'): void;
}>();
const checking = ref(false);
const checkError = ref<string | null>(null);
const checkResult = ref<ConversionCheck | null>(null);
const converting = ref(false);
const convertError = ref<string | null>(null);
const directionLabel = computed(() => {
if (!checkResult.value) return '';
return checkResult.value.direction === 'piece_to_component'
? 'Conversion : Catégorie de pièce → Catégorie de composant'
: 'Conversion : Catégorie de composant → Catégorie de pièce';
});
watch(
() => props.open,
async (isOpen) => {
if (!isOpen || !props.modelType) {
return;
}
checking.value = true;
checkError.value = null;
checkResult.value = null;
convertError.value = null;
try {
checkResult.value = await checkConversion(props.modelType.id);
} catch (err: any) {
checkError.value =
err?.data?.message || err?.message || 'Erreur lors de la vérification.';
} finally {
checking.value = false;
}
},
);
const doConvert = async () => {
if (!props.modelType) return;
converting.value = true;
convertError.value = null;
try {
const result = await convertCategory(props.modelType.id);
if (!result.success) {
convertError.value = result.error || 'La conversion a échoué.';
return;
}
emit('converted');
} catch (err: any) {
convertError.value =
err?.data?.message || err?.message || 'Erreur lors de la conversion.';
} finally {
converting.value = false;
}
};
</script>

View File

@@ -29,12 +29,21 @@
:total="total"
:limit="limit"
:offset="offset"
:category="selectedCategory"
@related="openRelatedModal"
@edit="openEditPage"
@delete="confirmDelete"
@convert="openConversionModal"
@update:offset="onOffsetChange"
/>
<ModelTypesConversionModal
:open="conversionModalOpen"
:model-type="conversionTarget"
@close="closeConversionModal"
@converted="onConverted"
/>
<dialog class="modal" :class="{ 'modal-open': relatedModalOpen }">
<div class="modal-box max-w-3xl">
<h3 class="text-lg font-bold text-base-content">
@@ -96,6 +105,7 @@ import { computed, onBeforeUnmount, onMounted, ref, watch, type Ref } from "vue"
import { useHead, useRouter } from "#imports";
import ModelTypesToolbar from "~/components/model-types/Toolbar.vue";
import ModelTypesTable from "~/components/model-types/Table.vue";
import ModelTypesConversionModal from "~/components/model-types/ConversionModal.vue";
import { useApi } from "~/composables/useApi";
import { useUrlState } from "~/composables/useUrlState";
import { extractCollection } from "~/shared/utils/apiHelpers";
@@ -484,6 +494,26 @@ const closeRelatedModal = () => {
relatedModalOpen.value = false;
};
const conversionModalOpen = ref(false);
const conversionTarget = ref<ModelType | null>(null);
const openConversionModal = (item: ModelType) => {
conversionTarget.value = item;
conversionModalOpen.value = true;
};
const closeConversionModal = () => {
conversionModalOpen.value = false;
};
const onConverted = () => {
conversionModalOpen.value = false;
invalidateEntityTypeCache("PIECE");
invalidateEntityTypeCache("COMPONENT");
showSuccess("Catégorie convertie avec succès.");
refresh();
};
watch(
() => searchInput.value,
(value) => {

View File

@@ -48,6 +48,15 @@
<button type="button" class="btn btn-ghost btn-sm" @click="emit('related', item)">
Liés
</button>
<button
v-if="showConvertButton"
type="button"
class="btn btn-ghost btn-sm text-warning"
@click="emit('convert', item)"
>
<IconLucideArrowLeftRight class="h-4 w-4" aria-hidden="true" />
Convertir
</button>
<button type="button" class="btn btn-ghost btn-sm" @click="emit('edit', item)">
Éditer
</button>
@@ -78,6 +87,15 @@
<button type="button" class="btn btn-ghost btn-sm" @click="emit('related', item)">
Liés
</button>
<button
v-if="showConvertButton"
type="button"
class="btn btn-ghost btn-sm text-warning"
@click="emit('convert', item)"
>
<IconLucideArrowLeftRight class="h-4 w-4" aria-hidden="true" />
Convertir
</button>
<button type="button" class="btn btn-ghost btn-sm" @click="emit('edit', item)">
Éditer
</button>
@@ -118,6 +136,7 @@
<script setup lang="ts">
import { computed } from 'vue';
import IconLucideInbox from '~icons/lucide/inbox';
import IconLucideArrowLeftRight from '~icons/lucide/arrow-left-right';
import type { ModelType, ModelCategory } from '~/services/modelTypes';
const props = defineProps<{
@@ -126,15 +145,21 @@ const props = defineProps<{
total: number;
limit: number;
offset: number;
category?: ModelCategory;
}>();
const emit = defineEmits<{
(e: 'related', item: ModelType): void;
(e: 'edit', item: ModelType): void;
(e: 'delete', item: ModelType): void;
(e: 'convert', item: ModelType): void;
(e: 'update:offset', offset: number): void;
}>();
const showConvertButton = computed(() =>
props.category === 'PIECE' || props.category === 'COMPONENT',
);
const categoryDictionary: Record<ModelCategory, string> = {
COMPONENT: 'Composants',
PIECE: 'Pièces',

View File

@@ -34,7 +34,7 @@ export function useMachineCreatePage() {
// Composable calls
// ---------------------------------------------------------------------------
const { createMachine, createMachineFromType, reconfigureSkeleton } = useMachines()
const { createMachine, createMachineFromType, reconfigureSkeleton, addMissingCustomFields, deleteMachine } = useMachines()
const { sites, loadSites } = useSites()
const { machineTypes, loadMachineTypes, loading: machineTypesLoading } = useMachineTypesApi()
const { composants, loadComposants, loading: composantsLoading } = useComposants()
@@ -340,17 +340,24 @@ export function useMachineCreatePage() {
: await createMachineFromType(baseMachineData as any, type)
if (result.success) {
if (hasRequirements && result.data?.id) {
const skeletonResult: any = await reconfigureSkeleton(result.data.id, {
const machineId = result.data?.id
if (hasRequirements && machineId) {
const skeletonResult: any = await reconfigureSkeleton(machineId, {
componentLinks,
pieceLinks,
productLinks,
} as any)
if (!skeletonResult.success) {
toast.showError(skeletonResult.error || 'Impossible d\'enregistrer les pièces/composants')
// Rollback: delete the orphaned machine
await deleteMachine(machineId).catch(() => {})
toast.showError(skeletonResult.error || 'Impossible d\'enregistrer les pièces/composants. La machine n\'a pas été créée.')
return
}
}
// Initialize custom fields for the machine type
if (machineId) {
await addMissingCustomFields(machineId, { showToast: false }).catch(() => {})
}
newMachine.name = ''
newMachine.siteId = ''
newMachine.typeMachineId = ''
@@ -386,9 +393,9 @@ export function useMachineCreatePage() {
await Promise.all([
loadSites(),
loadMachineTypes(),
loadComposants(),
loadPieces(),
loadProducts(),
loadComposants({ itemsPerPage: 200, force: true }),
loadPieces({ itemsPerPage: 200, force: true }),
loadProducts({ itemsPerPage: 200, force: true }),
])
})

View File

@@ -69,6 +69,25 @@ const badgeClass = (type: ChangeType) => {
}
const releases: Release[] = [
{
version: 'v1.6.1',
date: '2026-02-12',
changes: [
{ type: 'feat', text: 'Suivi d\'audit étendu : enregistrement des opérations CRUD sur les machines, fournisseurs, catégories (ModelType) et documents' },
{ type: 'feat', text: 'Traçabilité des conversions de catégories dans le journal d\'activité (action « convert » avec direction, nombre et noms des éléments)' },
{ type: 'feat', text: 'Endpoint historique machine : GET /api/machines/{id}/history' },
],
},
{
version: 'v1.6.0',
date: '2026-02-12',
changes: [
{ type: 'feat', text: 'Conversion bidirectionnelle des catégories : possibilité de convertir une catégorie de pièce en catégorie de composant (et inversement) avec transfert automatique de tous les éléments, documents, champs personnalisés et fournisseurs' },
{ type: 'feat', text: 'Vérification des conditions de blocage avant conversion : liaisons machines, templates de type machine, sous-composants dans la structure, collisions de noms' },
{ type: 'feat', text: 'Bouton « Convertir » sur les listes de catégories pièce et composant avec modale de confirmation détaillée' },
{ type: 'chore', text: 'Passage php-cs-fixer sur l\'ensemble des contrôleurs et entités du backend' },
],
},
{
version: 'v1.5.0',
date: '2026-02-11',

View File

@@ -132,6 +132,7 @@
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useDocuments } from '~/composables/useDocuments'
import { useApi } from '~/composables/useApi'
import { useUrlState } from '~/composables/useUrlState'
import { getFileIcon } from '~/utils/fileIcons'
import { canPreviewDocument } from '~/utils/documentPreview'
@@ -140,6 +141,7 @@ import DocumentPreviewModal from '~/components/DocumentPreviewModal.vue'
import IconLucideFileSearch from '~icons/lucide/file-search'
const { documents, loading, loadDocuments } = useDocuments()
const { get } = useApi()
const { q: searchTerm, filter: attachmentFilter } = useUrlState({
q: { default: '', debounce: 300 },
@@ -195,22 +197,36 @@ const formatSize = (size) => {
const documentIcon = doc => getFileIcon({ name: doc.filename || doc.name, mime: doc.mimeType })
const downloadDocument = (doc) => {
if (!doc?.path) { return }
/** Fetch the full document (with path) from the API on demand. */
const fetchDocumentPath = async (doc) => {
if (doc?.path) { return doc.path }
if (!doc?.id) { return null }
const result = await get(`/documents/${doc.id}`)
if (result.success && result.data?.path) {
doc.path = result.data.path
return result.data.path
}
return null
}
if (doc.path.startsWith('data:')) {
const downloadDocument = async (doc) => {
const path = await fetchDocumentPath(doc)
if (!path) { return }
if (path.startsWith('data:')) {
const link = document.createElement('a')
link.href = doc.path
link.href = path
link.download = doc.filename || doc.name || 'document'
link.click()
return
}
window.open(doc.path, '_blank')
window.open(path, '_blank')
}
const openPreview = (doc) => {
const openPreview = async (doc) => {
if (!canPreviewDocument(doc)) { return }
await fetchDocumentPath(doc)
previewDocument.value = doc
previewVisible.value = true
}

View File

@@ -219,3 +219,33 @@ export function getModelType(id: string, opts: { signal?: AbortSignal } = {}) {
signal: opts.signal,
})).then(normalizeModelType);
}
export interface ConversionCheck {
canConvert: boolean;
direction: 'piece_to_component' | 'component_to_piece' | null;
itemCount: number;
names: string[];
blockers: string[];
}
export interface ConversionResult {
success: boolean;
convertedCount: number;
error?: string | null;
}
export function checkConversion(id: string, opts: { signal?: AbortSignal } = {}) {
const requestFetch = useRequestFetch();
return requestFetch<ConversionCheck>(`${ENDPOINT}/${id}/conversion-check`, createOptions({
method: 'GET',
signal: opts.signal,
}));
}
export function convertCategory(id: string, opts: { signal?: AbortSignal } = {}) {
const requestFetch = useRequestFetch();
return requestFetch<ConversionResult>(`${ENDPOINT}/${id}/convert`, createOptions({
method: 'POST',
signal: opts.signal,
}));
}

35
e2e/auth.setup.ts Normal file
View File

@@ -0,0 +1,35 @@
import { test as setup, expect } from '@playwright/test'
const AUTH_FILE = 'e2e/.auth/session.json'
/**
* Authentication setup: selects the first available profile
* to establish a session cookie before running any test.
*
* The app uses a profile-based session system:
* - GET /api/session/profiles → list available profiles
* - POST /api/session/profile → activate a profile (sets cookie)
*
* The global middleware (profile.global.ts) redirects to /profiles
* if no active profile is found.
*/
setup('select a profile to authenticate', async ({ page }) => {
// Go to the profiles page
await page.goto('/profiles')
// Wait for profiles to load
await expect(page.getByRole('heading', { name: 'Choisir un profil' })).toBeVisible({ timeout: 15_000 })
// Wait for at least one profile button to appear
const profileButton = page.locator('button.btn-outline').first()
await expect(profileButton).toBeVisible({ timeout: 10_000 })
// Click the first available profile
await profileButton.click()
// Wait for redirect to home page (profile selected → session cookie set)
await page.waitForURL('/', { timeout: 10_000 })
// Save authenticated state (cookies + localStorage)
await page.context().storageState({ path: AUTH_FILE })
})

View File

@@ -0,0 +1,166 @@
import { test, expect } from '@playwright/test'
/**
* E2E tests for Product Category CRUD operations.
*
* Prerequisites:
* - Frontend running on http://localhost:3001 (npm run dev)
* - Backend running on http://localhost:8081 (docker compose up)
* - Auth setup must run first (profile selected)
*/
const UNIQUE = Date.now()
const CATEGORY_NAME = `E2E Catégorie Produit ${UNIQUE}`
const CATEGORY_NOTES = `Notes de test automatisé ${UNIQUE}`
const CATEGORY_NAME_UPDATED = `${CATEGORY_NAME} modifié`
const CATEGORY_NOTES_UPDATED = `${CATEGORY_NOTES} — mis à jour`
test.describe('Product Category CRUD', () => {
test.describe.configure({ mode: 'serial' })
// ──────────────────────────────────────────────
// CREATE
// ──────────────────────────────────────────────
test('should display the product category list page', async ({ page }) => {
await page.goto('/product-category')
await expect(page.getByRole('heading', { name: 'Catégories de produit' })).toBeVisible({ timeout: 10_000 })
await expect(page.getByText('Catégories enregistrées')).toBeVisible()
})
test('should navigate to the create form', async ({ page }) => {
await page.goto('/product-category')
// The toolbar button text is "Créer" (with a plus icon)
await page.getByRole('button', { name: /créer/i }).click()
await expect(page).toHaveURL('/product-category/new')
await expect(page.getByRole('heading', { name: 'Nouvelle catégorie de produit' })).toBeVisible()
})
test('should show validation error for short name', async ({ page }) => {
await page.goto('/product-category/new')
await page.locator('#model-type-name').fill('A')
// The form submit button in ModelTypeForm is also "Créer"
await page.locator('button[type="submit"]').click()
await expect(page.getByText('Le nom doit contenir au moins 2 caractères')).toBeVisible()
})
test('should create a new product category', async ({ page }) => {
await page.goto('/product-category/new')
await page.locator('#model-type-name').fill(CATEGORY_NAME)
await page.locator('#model-type-notes').fill(CATEGORY_NOTES)
// Verify category is locked to PRODUCT
const categorySelect = page.locator('#model-type-category')
await expect(categorySelect).toBeDisabled()
await expect(categorySelect).toHaveValue('PRODUCT')
await page.locator('button[type="submit"]').click()
// Should redirect to list and show success toast
await expect(page).toHaveURL('/product-category', { timeout: 10_000 })
await expect(page.getByText('Catégorie de produit créée avec succès')).toBeVisible()
})
// ──────────────────────────────────────────────
// READ
// ──────────────────────────────────────────────
test('should display the created category in the list', async ({ page }) => {
await page.goto('/product-category')
// Target the table cell specifically (desktop view also renders a mobile card)
await expect(page.getByRole('cell', { name: CATEGORY_NAME })).toBeVisible({ timeout: 10_000 })
})
test('should find the category via search', async ({ page }) => {
await page.goto('/product-category')
// Type in search input (placeholder: "Rechercher par nom…")
const searchInput = page.getByPlaceholder('Rechercher par nom…')
await searchInput.fill(UNIQUE.toString())
// Wait for debounce (300ms) + API response
await page.waitForTimeout(500)
await expect(page.getByRole('cell', { name: CATEGORY_NAME })).toBeVisible({ timeout: 5_000 })
})
// ──────────────────────────────────────────────
// UPDATE
// ──────────────────────────────────────────────
test('should navigate to the edit page', async ({ page }) => {
await page.goto('/product-category')
await expect(page.getByRole('cell', { name: CATEGORY_NAME })).toBeVisible({ timeout: 10_000 })
// Find the row with our category and click "Éditer"
const row = page.getByRole('row').filter({ hasText: CATEGORY_NAME })
await row.getByRole('button', { name: 'Éditer' }).click()
await expect(page.getByRole('heading', { name: /modifier/i })).toBeVisible({ timeout: 10_000 })
})
test('should edit the category name and notes', async ({ page }) => {
await page.goto('/product-category')
await expect(page.getByRole('cell', { name: CATEGORY_NAME })).toBeVisible({ timeout: 10_000 })
const row = page.getByRole('row').filter({ hasText: CATEGORY_NAME })
await row.getByRole('button', { name: 'Éditer' }).click()
await expect(page.getByRole('heading', { name: /modifier/i })).toBeVisible({ timeout: 10_000 })
// Update name
const nameInput = page.locator('#model-type-name')
await nameInput.clear()
await nameInput.fill(CATEGORY_NAME_UPDATED)
// Update notes
const notesTextarea = page.locator('#model-type-notes')
await notesTextarea.clear()
await notesTextarea.fill(CATEGORY_NOTES_UPDATED)
await page.locator('button[type="submit"]').click()
// Should redirect and show success
await expect(page).toHaveURL('/product-category', { timeout: 10_000 })
await expect(page.getByText('Catégorie de produit mise à jour avec succès')).toBeVisible()
})
test('should display updated category in the list', async ({ page }) => {
await page.goto('/product-category')
await expect(page.getByRole('cell', { name: CATEGORY_NAME_UPDATED })).toBeVisible({ timeout: 10_000 })
})
// ──────────────────────────────────────────────
// DELETE
// ──────────────────────────────────────────────
test('should cancel deletion when clicking Annuler', async ({ page }) => {
await page.goto('/product-category')
await expect(page.getByRole('cell', { name: CATEGORY_NAME_UPDATED })).toBeVisible({ timeout: 10_000 })
const row = page.getByRole('row').filter({ hasText: CATEGORY_NAME_UPDATED })
await row.getByRole('button', { name: 'Supprimer' }).click()
// Confirmation modal should appear
await expect(page.getByText('Supprimer ce type ?')).toBeVisible()
await page.getByRole('button', { name: 'Annuler' }).click()
// Category should still be present
await expect(page.getByRole('cell', { name: CATEGORY_NAME_UPDATED })).toBeVisible()
})
test('should delete the category', async ({ page }) => {
await page.goto('/product-category')
await expect(page.getByRole('cell', { name: CATEGORY_NAME_UPDATED })).toBeVisible({ timeout: 10_000 })
const row = page.getByRole('row').filter({ hasText: CATEGORY_NAME_UPDATED })
await row.getByRole('button', { name: 'Supprimer' }).click()
// Confirm deletion in modal
await expect(page.getByText('Supprimer ce type ?')).toBeVisible()
// Click the confirm "Supprimer" button inside the modal (btn-error style)
await page.locator('button.btn-error').filter({ hasText: 'Supprimer' }).click()
// Should show success toast and category should disappear
await expect(page.getByText(/supprimé avec succès/i)).toBeVisible({ timeout: 10_000 })
await expect(page.getByRole('cell', { name: CATEGORY_NAME_UPDATED })).not.toBeVisible()
})
})

299
e2e/product-crud.spec.ts Normal file
View File

@@ -0,0 +1,299 @@
import { test, expect, type Page } from '@playwright/test'
/**
* E2E tests for Product CRUD operations.
*
* Prerequisites:
* - Frontend running on http://localhost:3001 (npm run dev)
* - Backend running on http://localhost:8081 (docker compose up)
* - Auth setup must run first (profile selected)
*
* These tests create a temporary product category, use it to test
* the full product CRUD, then clean up both.
*/
const UNIQUE = Date.now()
const TEST_CATEGORY_NAME = `E2E Cat Produit ${UNIQUE}`
const PRODUCT_NAME = `E2E Produit Test ${UNIQUE}`
const PRODUCT_REFERENCE = `REF-E2E-${UNIQUE}`
const PRODUCT_PRICE = '42.50'
const PRODUCT_NAME_UPDATED = `${PRODUCT_NAME} modifié`
const PRODUCT_REFERENCE_UPDATED = `${PRODUCT_REFERENCE}-UPD`
const PRODUCT_PRICE_UPDATED = '99.99'
// ──────────────────────────────────────────────
// Helpers
// ──────────────────────────────────────────────
/**
* Creates a product category via the UI.
*/
async function createTestCategory(page: Page) {
await page.goto('/product-category/new')
await page.locator('#model-type-name').fill(TEST_CATEGORY_NAME)
await page.locator('button[type="submit"]').click()
await expect(page).toHaveURL('/product-category', { timeout: 10_000 })
await expect(page.getByText('Catégorie de produit créée avec succès')).toBeVisible()
}
/**
* Selects an option in a SearchSelect component.
*
* The SearchSelect renders:
* .search-select > .relative > input[placeholder]
* .search-select > .relative > div (dropdown) > ul > li > button
*/
async function selectSearchOption(page: Page, placeholder: string, searchText: string) {
const input = page.getByPlaceholder(placeholder)
await input.click()
await input.fill(searchText)
// The dropdown is inside .search-select > .relative > div > ul > li > button
const option = page.locator('.search-select ul li button')
.filter({ hasText: searchText })
.first()
await option.waitFor({ state: 'visible', timeout: 10_000 })
await option.click()
}
/**
* Cleans up test data: deletes the category via the UI.
*/
async function cleanupTestCategory(page: Page) {
await page.goto('/product-category')
// Wait for list to load
await page.waitForTimeout(1_000)
const row = page.getByRole('row').filter({ hasText: TEST_CATEGORY_NAME })
if (await row.isVisible().catch(() => false)) {
await row.getByRole('button', { name: 'Supprimer' }).click()
const confirmBtn = page.locator('button.btn-error').filter({ hasText: 'Supprimer' })
await confirmBtn.waitFor({ state: 'visible', timeout: 5_000 })
await confirmBtn.click()
await page.waitForTimeout(1_000)
}
}
test.describe('Product CRUD', () => {
test.describe.configure({ mode: 'serial' })
// ──────────────────────────────────────────────
// SETUP: Create a test category
// ──────────────────────────────────────────────
test('setup: create a test product category', async ({ page }) => {
await createTestCategory(page)
})
// ──────────────────────────────────────────────
// LIST PAGE
// ──────────────────────────────────────────────
test('should display the product catalog page', async ({ page }) => {
await page.goto('/product-catalog')
await expect(page.getByRole('heading', { name: 'Catalogue des produits' })).toBeVisible({ timeout: 10_000 })
await expect(page.getByRole('link', { name: /ajouter un produit/i })).toBeVisible()
await expect(page.getByRole('link', { name: /gérer les catégories/i })).toBeVisible()
})
test('should navigate to create product page', async ({ page }) => {
await page.goto('/product-catalog')
await page.getByRole('link', { name: /ajouter un produit/i }).click()
await expect(page).toHaveURL('/product/create')
await expect(page.getByRole('heading', { name: 'Nouveau produit' })).toBeVisible()
})
// ──────────────────────────────────────────────
// CREATE
// ──────────────────────────────────────────────
test('should show disabled fields until category is selected', async ({ page }) => {
await page.goto('/product/create')
// Name input should be disabled before selecting a category
const nameInput = page.getByPlaceholder('Nom affiché dans le catalogue')
await expect(nameInput).toBeDisabled()
})
test('should create a product with all fields', async ({ page }) => {
await page.goto('/product/create')
// 1. Select category via SearchSelect
await selectSearchOption(page, 'Rechercher une catégorie...', TEST_CATEGORY_NAME)
// Wait for form to enable after category selection
const nameInput = page.getByPlaceholder('Nom affiché dans le catalogue')
await expect(nameInput).toBeEnabled({ timeout: 5_000 })
// 2. Fill form fields
await nameInput.clear()
await nameInput.fill(PRODUCT_NAME)
const referenceInput = page.getByPlaceholder('Référence interne ou fournisseur')
await referenceInput.fill(PRODUCT_REFERENCE)
const priceInput = page.getByPlaceholder('Valeur indicatrice')
await priceInput.fill(PRODUCT_PRICE)
// 3. Submit
await page.getByRole('button', { name: /créer le produit/i }).click()
// Should redirect to catalog and show success
await expect(page).toHaveURL('/product-catalog', { timeout: 15_000 })
await expect(page.getByText('Produit créé avec succès')).toBeVisible()
})
// ──────────────────────────────────────────────
// READ
// ──────────────────────────────────────────────
test('should display the created product in the catalog', async ({ page }) => {
await page.goto('/product-catalog')
await expect(page.getByText(PRODUCT_NAME)).toBeVisible({ timeout: 10_000 })
})
test('should show product reference in the catalog', async ({ page }) => {
await page.goto('/product-catalog')
await expect(page.getByText(PRODUCT_REFERENCE)).toBeVisible({ timeout: 10_000 })
})
test('should find the product via search', async ({ page }) => {
await page.goto('/product-catalog')
const searchInput = page.getByPlaceholder('Nom ou référence…')
await searchInput.fill(PRODUCT_REFERENCE)
// Wait for client-side filtering
await page.waitForTimeout(300)
await expect(page.getByText(PRODUCT_NAME)).toBeVisible()
await expect(page.getByText(PRODUCT_REFERENCE)).toBeVisible()
})
test('should show category link in the catalog', async ({ page }) => {
await page.goto('/product-catalog')
await expect(page.getByText(PRODUCT_NAME)).toBeVisible({ timeout: 10_000 })
const row = page.getByRole('row').filter({ hasText: PRODUCT_NAME })
await expect(row.getByText(TEST_CATEGORY_NAME)).toBeVisible()
})
test('should sort products by name', async ({ page }) => {
await page.goto('/product-catalog')
await page.locator('#product-sort').selectOption('name')
await page.locator('#product-dir').selectOption('asc')
await expect(page.getByRole('heading', { name: 'Catalogue des produits' })).toBeVisible()
})
test('should sort products by creation date', async ({ page }) => {
await page.goto('/product-catalog')
await page.locator('#product-sort').selectOption('createdAt')
await page.locator('#product-dir').selectOption('desc')
await expect(page.getByRole('heading', { name: 'Catalogue des produits' })).toBeVisible()
})
// ──────────────────────────────────────────────
// UPDATE
// ──────────────────────────────────────────────
test('should navigate to edit page from catalog', async ({ page }) => {
await page.goto('/product-catalog')
await expect(page.getByText(PRODUCT_NAME)).toBeVisible({ timeout: 10_000 })
const row = page.getByRole('row').filter({ hasText: PRODUCT_NAME })
await row.getByRole('link', { name: 'Modifier' }).click()
await expect(page.getByRole('heading', { name: 'Modifier le produit' })).toBeVisible({ timeout: 10_000 })
})
test('should show category note on edit page', async ({ page }) => {
await page.goto('/product-catalog')
await expect(page.getByText(PRODUCT_NAME)).toBeVisible({ timeout: 10_000 })
const row = page.getByRole('row').filter({ hasText: PRODUCT_NAME })
await row.getByRole('link', { name: 'Modifier' }).click()
await expect(page.getByRole('heading', { name: 'Modifier le produit' })).toBeVisible({ timeout: 10_000 })
// Category should be displayed but disabled
await expect(page.getByText("La catégorie d'origine ne peut pas être modifiée")).toBeVisible()
})
test('should update the product', async ({ page }) => {
await page.goto('/product-catalog')
await expect(page.getByText(PRODUCT_NAME)).toBeVisible({ timeout: 10_000 })
const row = page.getByRole('row').filter({ hasText: PRODUCT_NAME })
await row.getByRole('link', { name: 'Modifier' }).click()
await expect(page.getByRole('heading', { name: 'Modifier le produit' })).toBeVisible({ timeout: 10_000 })
// Update name (label-text "Nom du produit" → sibling input)
const nameInput = page.locator('.form-control').filter({ hasText: 'Nom du produit' }).locator('input')
await nameInput.clear()
await nameInput.fill(PRODUCT_NAME_UPDATED)
// Update reference
const refInput = page.locator('.form-control').filter({ hasText: 'Référence' }).locator('input')
await refInput.clear()
await refInput.fill(PRODUCT_REFERENCE_UPDATED)
// Update price
const priceInput = page.locator('.form-control').filter({ hasText: 'Prix fournisseur' }).locator('input')
await priceInput.clear()
await priceInput.fill(PRODUCT_PRICE_UPDATED)
// Submit
await page.getByRole('button', { name: /enregistrer les modifications/i }).click()
await expect(page).toHaveURL('/product-catalog', { timeout: 10_000 })
await expect(page.getByText('Produit mis à jour avec succès')).toBeVisible()
})
test('should display updated product in catalog', async ({ page }) => {
await page.goto('/product-catalog')
await expect(page.getByText(PRODUCT_NAME_UPDATED)).toBeVisible({ timeout: 10_000 })
await expect(page.getByText(PRODUCT_REFERENCE_UPDATED)).toBeVisible()
})
// ──────────────────────────────────────────────
// DELETE
// ──────────────────────────────────────────────
test('should cancel product deletion', async ({ page }) => {
await page.goto('/product-catalog')
await expect(page.getByText(PRODUCT_NAME_UPDATED)).toBeVisible({ timeout: 10_000 })
const row = page.getByRole('row').filter({ hasText: PRODUCT_NAME_UPDATED })
await row.getByRole('button', { name: 'Supprimer' }).click()
// Confirmation modal
await expect(page.getByText(/voulez-vous vraiment supprimer/i)).toBeVisible()
await page.getByRole('button', { name: 'Annuler' }).click()
// Product should still be here
await expect(page.getByText(PRODUCT_NAME_UPDATED)).toBeVisible()
})
test('should delete the product', async ({ page }) => {
await page.goto('/product-catalog')
await expect(page.getByText(PRODUCT_NAME_UPDATED)).toBeVisible({ timeout: 10_000 })
const row = page.getByRole('row').filter({ hasText: PRODUCT_NAME_UPDATED })
await row.getByRole('button', { name: 'Supprimer' }).click()
// Confirm deletion in modal
await expect(page.getByText(/voulez-vous vraiment supprimer/i)).toBeVisible()
await page.locator('button.btn-error').filter({ hasText: 'Supprimer' }).click()
await expect(page.getByText(/supprimé/i)).toBeVisible({ timeout: 10_000 })
// The toast message contains the product name, so check the table specifically
const table = page.locator('table')
await expect(table.getByText(PRODUCT_NAME_UPDATED)).not.toBeVisible({ timeout: 5_000 })
})
// ──────────────────────────────────────────────
// CLEANUP: Remove the test category
// ──────────────────────────────────────────────
test('cleanup: delete the test product category', async ({ page }) => {
await cleanupTestCategory(page)
})
})

64
package-lock.json generated
View File

@@ -18,6 +18,7 @@
"devDependencies": {
"@iconify-json/lucide": "^1.2.68",
"@nuxt/eslint-config": "^1.9.0",
"@playwright/test": "^1.58.2",
"@rushstack/eslint-patch": "^1.12.0",
"@types/node": "^25.2.1",
"@typescript-eslint/eslint-plugin": "^8.44.1",
@@ -3202,6 +3203,22 @@
"node": ">=14"
}
},
"node_modules/@playwright/test": {
"version": "1.58.2",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz",
"integrity": "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.58.2"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@polka/url": {
"version": "1.0.0-next.29",
"resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz",
@@ -10828,6 +10845,53 @@
"pathe": "^2.0.3"
}
},
"node_modules/playwright": {
"version": "1.58.2",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz",
"integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.58.2"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.58.2",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz",
"integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/playwright/node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/pluralize": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz",

View File

@@ -12,7 +12,10 @@
"lint": "eslint . --ext .js,.ts,.vue",
"lint:fix": "npm run lint -- --fix",
"test": "vitest run",
"test:watch": "vitest"
"test:watch": "vitest",
"test:e2e": "playwright test",
"test:e2e:ui": "playwright test --ui",
"test:e2e:headed": "playwright test --headed"
},
"dependencies": {
"@nuxtjs/tailwindcss": "^6.14.0",
@@ -26,6 +29,7 @@
"devDependencies": {
"@iconify-json/lucide": "^1.2.68",
"@nuxt/eslint-config": "^1.9.0",
"@playwright/test": "^1.58.2",
"@rushstack/eslint-patch": "^1.12.0",
"@types/node": "^25.2.1",
"@typescript-eslint/eslint-plugin": "^8.44.1",

37
playwright.config.ts Normal file
View File

@@ -0,0 +1,37 @@
import { defineConfig, devices } from '@playwright/test'
const AUTH_FILE = 'e2e/.auth/session.json'
export default defineConfig({
testDir: './e2e',
fullyParallel: false,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: 1,
reporter: 'html',
timeout: 30_000,
use: {
baseURL: process.env.E2E_BASE_URL || 'http://localhost:3001',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
},
projects: [
// Auth setup: selects a profile to get a session cookie
{
name: 'auth-setup',
testMatch: /auth\.setup\.ts/,
},
// All tests run after auth setup, with the saved session
{
name: 'chromium',
use: {
...devices['Desktop Chrome'],
storageState: AUTH_FILE,
},
dependencies: ['auth-setup'],
},
],
})