import { expect, Page, test } from '@playwright/test'; async function mockUser(page: Page, authenticated: boolean): Promise { await page.unroute('**/api/user'); await page.route('**/api/user', async (route) => { await route.fulfill({ contentType: 'application/json', json: authenticated ? { authenticated: true, username: 'Alice', email: 'alice@example.com' } : { authenticated: false, username: '', email: '' }, }); }); } async function mockEmptyApartmentList(page: Page): Promise { await page.route('**/api/apartments', async (route) => { await route.fulfill({ contentType: 'application/json', json: [] }); }); } async function expectNoHorizontalOverflow(page: Page): Promise { const hasHorizontalOverflow = await page.evaluate( () => document.documentElement.scrollWidth > document.documentElement.clientWidth, ); expect(hasHorizontalOverflow).toBe(false); } test.beforeEach(async ({ page }) => { await mockUser(page, false); }); test('renders the sign-in experience without horizontal overflow', async ({ page }) => { await page.goto('/'); await expect(page.getByRole('heading', { name: 'LMNP Organizer' })).toBeVisible(); await expect(page.getByRole('button', { name: 'Se connecter avec OIDC' })).toBeVisible(); await expectNoHorizontalOverflow(page); }); test('starts the OIDC login flow from the sign-in screen', async ({ page }) => { await page.route('**/oauth2/authorization/oidc', async (route) => { await route.fulfill({ contentType: 'text/html', body: '

OIDC provider

' }); }); await page.goto('/'); await page.getByRole('button', { name: 'Se connecter avec OIDC' }).click(); await expect(page).toHaveURL(/\/oauth2\/authorization\/oidc$/); }); test('persists a dark appearance preference from Settings', async ({ page }) => { await mockUser(page, true); await mockEmptyApartmentList(page); await page.goto('/'); await page.getByRole('button', { name: 'Réglages' }).click(); await page.getByRole('radio', { name: 'Sombre' }).check(); await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark'); expect( await page.evaluate( // This callback runs in the browser page, where localStorage is guaranteed by the test. // eslint-disable-next-line no-restricted-globals () => localStorage.getItem('lmnp-theme'), ), ).toBe('dark'); await page.reload(); await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark'); await page.getByRole('button', { name: 'Réglages' }).click(); await expect(page.getByRole('radio', { name: 'Sombre' })).toBeChecked(); }); test('adapts authenticated navigation to the viewport', async ({ page }, testInfo) => { await mockUser(page, true); await mockEmptyApartmentList(page); await page.goto('/'); const navigation = page.getByTestId('primary-navigation'); await expect(navigation).toBeVisible(); const navigationBox = await navigation.boundingBox(); const viewport = page.viewportSize(); expect(navigationBox).not.toBeNull(); expect(viewport).not.toBeNull(); if (testInfo.project.name === 'desktop') { expect(navigationBox?.x).toBe(0); expect(navigationBox?.height).toBe(viewport?.height); } else { expect(Math.round((navigationBox?.y ?? 0) + (navigationBox?.height ?? 0))).toBe( viewport?.height, ); for (const button of await navigation.locator('.navigation-item').all()) { const buttonBox = await button.boundingBox(); const labelBox = await button.locator('.mdc-button__label > span').boundingBox(); expect(buttonBox).not.toBeNull(); expect(labelBox).not.toBeNull(); expect(labelBox!.x).toBeGreaterThanOrEqual(buttonBox!.x); expect(labelBox!.x + labelBox!.width).toBeLessThanOrEqual(buttonBox!.x + buttonBox!.width); } } await navigation.getByRole('button', { name: 'Appartements' }).click(); await expect(page.getByRole('heading', { name: 'Mes appartements' })).toBeVisible(); await expectNoHorizontalOverflow(page); }); test('keeps apartment add and edit workflows usable', async ({ page }) => { const apartments = [ { id: 'apt-1', name: 'T2 République', address: '12 rue de la République, Lyon', purchasePrice: 185000, description: 'Deuxième étage', }, ]; await mockUser(page, true); await page.route('**/api/apartments**', async (route) => { const request = route.request(); const method = request.method(); const id = new URL(request.url()).pathname.split('/').at(-1); if (method === 'POST') { const apartment = { id: 'apt-2', ...request.postDataJSON() }; apartments.push(apartment); await route.fulfill({ contentType: 'application/json', json: apartment }); return; } if (method === 'PUT') { const index = apartments.findIndex((apartment) => apartment.id === id); apartments[index] = { id, ...request.postDataJSON() }; await route.fulfill({ contentType: 'application/json', json: apartments[index] }); return; } if (method === 'DELETE') { const index = apartments.findIndex((apartment) => apartment.id === id); apartments.splice(index, 1); await route.fulfill({ status: 204 }); return; } await route.fulfill({ contentType: 'application/json', json: apartments }); }); await page.goto('/'); await page .getByTestId('primary-navigation') .getByRole('button', { name: 'Appartements' }) .click(); await expect(page.getByRole('heading', { name: 'T2 République' })).toBeVisible(); await page.getByRole('button', { name: 'Modifier' }).click(); await page.getByLabel("Nom de l'appartement").fill('T2 République rénové'); await page.getByRole('button', { name: 'Enregistrer' }).click(); await expect(page.getByRole('heading', { name: 'T2 République rénové' })).toBeVisible(); await page.getByRole('button', { name: 'Ajouter un appartement' }).click(); await page.getByLabel("Nom de l'appartement").fill('Studio Croix-Rousse'); await page.getByLabel('Adresse complète').fill('8 montée de la Boucle, Lyon'); await page.getByLabel("Prix d'acquisition").fill('125000'); await page.getByRole('button', { name: 'Enregistrer' }).click(); await expect(page.getByRole('heading', { name: 'Studio Croix-Rousse' })).toBeVisible(); page.once('dialog', (dialog) => dialog.accept()); const renovatedApartment = page .getByRole('article') .filter({ has: page.getByRole('heading', { name: 'T2 République rénové' }) }); await renovatedApartment.getByRole('button', { name: 'Supprimer' }).click(); await expect(page.getByRole('heading', { name: 'T2 République rénové' })).toBeHidden(); await expectNoHorizontalOverflow(page); }); test('keeps the secured API check and logout available', async ({ page }) => { await mockUser(page, true); await mockEmptyApartmentList(page); await page.route('**/api/hello', async (route) => { await route.fulfill({ contentType: 'text/plain', body: 'Service disponible' }); }); await page.route('**/logout', async (route) => { await route.fulfill({ contentType: 'text/html', body: '

Signed out

' }); }); await page.goto('/'); await page.getByRole('button', { name: 'Tester la connexion' }).click(); await expect(page.getByText('Service disponible')).toBeVisible(); await page.getByTestId('primary-navigation').getByRole('button', { name: 'Réglages' }).click(); await page.getByTestId('session-settings').getByRole('button', { name: 'Déconnexion' }).click(); await expect(page).toHaveURL(/\/logout$/); }); test('follows operating-system changes for the System theme', async ({ page }) => { await page.addInitScript( // This callback runs in the browser page, where localStorage is guaranteed by the test. // eslint-disable-next-line no-restricted-globals () => localStorage.setItem('lmnp-theme', 'system'), ); await page.emulateMedia({ colorScheme: 'dark' }); await mockUser(page, true); await mockEmptyApartmentList(page); await page.goto('/'); await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark'); await page.emulateMedia({ colorScheme: 'light' }); await expect(page.locator('html')).toHaveAttribute('data-theme', 'light'); }); test('creates a tenant with an attachment and a lease with a guarantor', async ({ page }) => { const apartments = [ { id: 'apartment-1', name: 'Studio République', address: '12 rue de la République, Lyon', purchasePrice: 150000, description: '', }, ]; const tenants: Record[] = []; const leases: Record[] = []; await mockUser(page, true); await page.route('**/api/apartments', (route) => route.fulfill({ contentType: 'application/json', json: apartments }), ); await page.route('**/api/tenants**', async (route) => { const request = route.request(); const path = new URL(request.url()).pathname; if (request.method() === 'POST' && path.endsWith('/attachments')) { const attachment = { id: 'attachment-1', fileName: 'identite.pdf', contentType: 'application/pdf', size: 8, }; (tenants[0]['attachments'] as Record[]).push(attachment); await route.fulfill({ contentType: 'application/json', json: attachment }); return; } if (request.method() === 'POST') { const tenant = { id: 'tenant-1', attachments: [], ...request.postDataJSON() }; tenants.push(tenant); await route.fulfill({ contentType: 'application/json', json: tenant }); return; } await route.fulfill({ contentType: 'application/json', json: tenants }); }); await page.route('**/api/leases**', async (route) => { const request = route.request(); const path = new URL(request.url()).pathname; if (request.method() === 'GET' && path.endsWith('/pdf')) { await route.fulfill({ contentType: 'application/pdf', headers: { 'Content-Disposition': 'attachment; filename="bail-lease-1.pdf"' }, body: '%PDF-1.4\n%%EOF', }); return; } if (request.method() === 'POST') { const lease = { id: 'lease-1', ...request.postDataJSON() }; leases.push(lease); await route.fulfill({ contentType: 'application/json', json: lease }); return; } await route.fulfill({ contentType: 'application/json', json: leases }); }); await page.goto('/'); await page.getByTestId('primary-navigation').getByRole('button', { name: 'Locataires' }).click(); await page.getByRole('button', { name: 'Ajouter un locataire' }).first().click(); await page.getByLabel('Prénom', { exact: true }).fill('Camille'); await page.getByLabel('Nom', { exact: true }).fill('Martin'); await page.getByLabel('E-mail', { exact: true }).fill('camille@example.com'); await page.getByLabel('Téléphone', { exact: true }).fill('+33612345678'); await page.getByLabel('Adresse complète').fill('10 rue des Lilas, Lyon'); await page.getByRole('button', { name: 'Enregistrer' }).click(); await expect(page.getByRole('heading', { name: 'Camille Martin' })).toBeVisible(); await page.getByLabel('Ajouter une pièce jointe pour Camille Martin').setInputFiles({ name: 'identite.pdf', mimeType: 'application/pdf', buffer: Buffer.from('identity'), }); await expect( page.getByRole('button', { name: 'Télécharger la pièce jointe identite.pdf' }), ).toBeVisible(); await page.getByTestId('primary-navigation').getByRole('button', { name: 'Baux' }).click(); await page.getByRole('button', { name: 'Créer un bail' }).first().click(); await page.getByRole('combobox', { name: 'Appartement' }).click(); await page.getByRole('option', { name: 'Studio République' }).click(); await page.getByRole('combobox', { name: 'Locataires' }).click(); await page.getByRole('option', { name: 'Camille Martin' }).click(); await page.keyboard.press('Escape'); await page.getByLabel('Date de début').fill('2026-09-01'); await page.getByLabel('Date de fin').fill('2027-08-31'); await page.getByLabel('Loyer mensuel').fill('850'); await page.getByLabel('Charges mensuelles').fill('50'); await page.getByLabel('Dépôt de garantie').fill('850'); await page.getByLabel('Prénom du garant').fill('Alex'); await page.getByLabel('Nom du garant', { exact: true }).fill('Durand'); await page.getByLabel('E-mail du garant').fill('alex@example.com'); await page.getByRole('button', { name: 'Ajouter le garant' }).click(); await page.getByRole('button', { name: 'Enregistrer le bail' }).click(); await expect(page.getByRole('heading', { name: 'Studio République' })).toBeVisible(); await expect(page.getByText('Alex Durand')).toBeVisible(); const downloadPromise = page.waitForEvent('download'); await page.getByRole('button', { name: 'Télécharger le bail' }).click(); const download = await downloadPromise; expect(download.suggestedFilename()).toBe('bail-lease-1.pdf'); await expectNoHorizontalOverflow(page); });