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