import { expect, Locator, Page, test } from '@playwright/test'; async function mockUser(page: Page, authenticated: boolean, completeProfile = true): Promise { await page.unroute('**/api/user'); await page.unroute('**/api/landlord-profile'); 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: '' }, }); }); if (!authenticated) return; const profile = { firstName: 'Alice', lastName: 'Martin', email: 'alice@example.com', phone: completeProfile ? '+33600000000' : '', address: completeProfile ? '1 rue de Paris' : '', complete: completeProfile, version: 0, }; await page.route('**/api/landlord-profile', async (route) => { if (route.request().method() === 'PUT') { Object.assign(profile, route.request().postDataJSON(), { complete: true, version: 1 }); } await route.fulfill({ contentType: 'application/json', json: profile }); }); } 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); } async function expectNoScrollbars(locator: Locator): Promise { const scrollableElements = await locator.evaluate((element) => { return [element, ...element.querySelectorAll('*')] .filter((candidate) => { const { overflowX, overflowY } = getComputedStyle(candidate); const hasHorizontalScrollbar = (overflowX === 'auto' || overflowX === 'scroll') && candidate.scrollWidth > candidate.clientWidth; const hasVerticalScrollbar = (overflowY === 'auto' || overflowY === 'scroll') && candidate.scrollHeight > candidate.clientHeight; return hasHorizontalScrollbar || hasVerticalScrollbar; }) .map( (candidate) => `${candidate.tagName.toLowerCase()}.${candidate.getAttribute('class') ?? ''}`, ); }); expect(scrollableElements).toEqual([]); } 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('requires a newly connected landlord to complete their profile', async ({ page }) => { await mockUser(page, true, false); await page.goto('/'); const dialog = page.getByRole('dialog'); await expect(dialog.getByRole('heading', { name: 'Complétez votre profil' })).toBeVisible(); await expectNoHorizontalOverflow(page); await expectNoScrollbars(dialog); await page.keyboard.press('Escape'); await expect(dialog).toBeVisible(); const logoutButton = dialog.getByRole('button', { name: 'Déconnexion' }); const saveButton = dialog.getByRole('button', { name: 'Enregistrer le profil' }); await expect(logoutButton).toBeVisible(); await expect(saveButton).toBeVisible(); const [logoutBox, saveBox, actionBox, surfaceBox] = await Promise.all([ logoutButton.boundingBox(), saveButton.boundingBox(), dialog.locator('.profile-actions').boundingBox(), dialog.locator('.mat-mdc-dialog-surface').boundingBox(), ]); if (!logoutBox || !saveBox || !actionBox || !surfaceBox) { throw new Error('Profile dialog actions must be measurable.'); } expect(logoutBox.x).toBeCloseTo(actionBox.x, 1); expect(saveBox.x + saveBox.width).toBeCloseTo(actionBox.x + actionBox.width, 1); expect(Math.abs(logoutBox.y - saveBox.y)).toBeLessThan(8); expect( surfaceBox.y + surfaceBox.height - (actionBox.y + actionBox.height), ).toBeGreaterThanOrEqual(16); await dialog.getByLabel('Téléphone').fill('+33612345678'); await dialog.getByLabel('Adresse postale').fill('1 rue des Lilas, Lyon'); await saveButton.click(); await expect(dialog).toBeHidden(); await expect(page.getByTestId('primary-navigation')).toBeVisible(); }); 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 }) => { let logoutMethod = ''; 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) => { logoutMethod = route.request().method(); 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.poll(() => logoutMethod).toBe('POST'); await expect(page).toHaveURL(/\/$/); }); 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('offers the native guided photo control for an entry condition report', async ({ page }) => { await mockUser(page, true); await page.route( (url) => url.pathname === '/api/leases/lease-1/condition-reports/ENTRY', async (route) => { await route.fulfill({ contentType: 'application/json', json: { id: 'report-1', type: 'ENTRY', status: 'DRAFT', occurredOn: '2026-07-22', timeZone: 'Europe/Paris', participants: [], forwardingAddresses: [], zones: [ { id: 'kitchen', privateAreaId: 'area-kitchen', label: 'Cuisine', equipment: 'NONE', }, ], observations: [{ subjectId: 'kitchen', state: 'BON_ETAT', observation: '' }], meterReadings: [], accessMeans: [], inventoryConditions: [], photos: [], zonePhotoAbsences: [], entryPhotoDispositions: [], supplements: [], entrySnapshot: null, revisions: [], currentRevision: null, finalization: null, deliveryAttestations: [], leaseRevisionNumber: 1, leaseRevisionFingerprint: 'lease-source', version: 0, }, }); }, ); await page.goto('/leases/lease-1/condition-reports/ENTRY'); const kitchen = page.getByRole('article').filter({ has: page.getByRole('heading', { name: 'Cuisine' }), }); const photo = kitchen.getByLabel('Prendre ou choisir une photo'); await expect(photo).toHaveAttribute( 'accept', 'image/jpeg,image/png,image/webp,image/heic,image/heif', ); await expect(photo).toHaveAttribute('capture', 'environment'); await expect(page.getByRole('heading', { name: 'Parcours photos guidé' })).toBeVisible(); }); 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() === 'POST' && path === '/api/leases/lease-1/revisions') { await route.fulfill({ contentType: 'application/json', json: { number: 1, templateVersion: '2026-01', sourceFingerprint: 'source-fingerprint', sha256: 'lease-sha256', generatedAt: '2026-07-19T12:00:00Z', authorId: 'owner-1', annexes: [], warningCodes: [], warnings: [], }, }); return; } if (request.method() === 'GET' && path.endsWith('/pdf')) { expect(path).toBe('/api/leases/lease-1/revisions/1/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' && path === '/api/leases') { const lease = { id: 'lease-1', status: 'DRAFT', ...request.postDataJSON() }; leases.push(lease); await route.fulfill({ contentType: 'application/json', json: lease }); return; } await route.fulfill({ contentType: 'application/json', json: leases }); }); await page.route('**/api/rent-dues/refresh', (route) => { expect(route.request().method()).toBe('POST'); return route.fulfill({ contentType: 'application/json', json: [] }); }); await page.route('**/api/lease-clauses?context=*', (route) => route.fulfill({ contentType: 'application/json', json: [] }), ); 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 d'entrée").fill('2026-09-01'); 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 brouillon' }).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: 'Générer / télécharger le bail' }).click(); const download = await downloadPromise; expect(download.suggestedFilename()).toBe('bail-lease-1-revision-1.pdf'); await expectNoHorizontalOverflow(page); }); test('records a received rent payment and keeps its surplus visible', async ({ page }) => { const apartment = { id: 'apartment-1', name: 'Studio République', address: '12 rue de la République, Lyon', purchasePrice: 150000, description: '', }; const lease = { id: 'lease-1', apartmentId: apartment.id, tenantIds: ['tenant-1'], guarantors: [], startDate: '2026-07-01', endDate: '2027-06-30', monthlyRent: 850, monthlyCharges: 50, securityDeposit: 850, status: 'ACTIVE', }; let rentDues = [ { id: 'due-1', leaseId: lease.id, month: '2026-07', dueDate: '2026-07-05', rent: 850, charges: 50, total: 900, allocatedAmount: 0, remainingAmount: 900, settlement: 'UNPAID', timing: 'OVERDUE', version: 1, }, ]; let payments: Record[] = []; let submittedPayment: Record | undefined; await mockUser(page, true); await page.route('**/api/apartments', (route) => route.fulfill({ contentType: 'application/json', json: [apartment] }), ); await page.route('**/api/leases', (route) => route.fulfill({ contentType: 'application/json', json: [lease] }), ); await page.route('**/api/rent-dues/refresh', (route) => { expect(route.request().method()).toBe('POST'); return route.fulfill({ contentType: 'application/json', json: rentDues }); }); await page.route('**/api/rent-payments', async (route) => { if (route.request().method() === 'POST') { submittedPayment = route.request().postDataJSON() as Record; const receivedAt = submittedPayment['receivedAt'] as string; payments = [ { id: 'payment-1', leaseId: lease.id, receivedAt, amount: 950, status: 'CONFIRMED', allocations: [ { id: 'allocation-1', rentDueId: 'due-1', amount: 900, allocatedAt: `${receivedAt}T12:00:00Z`, }, ], allocatedAmount: 900, refundedAmount: 0, unallocatedBalance: 50, confirmedAt: `${receivedAt}T12:00:00Z`, version: 1, refunds: [], reversal: null, }, ]; rentDues = [ { ...rentDues[0], allocatedAmount: 900, remainingAmount: 0, settlement: 'PAID', version: 2, }, ]; await route.fulfill({ contentType: 'application/json', json: payments[0] }); return; } await route.fulfill({ contentType: 'application/json', json: payments }); }); await page.goto('/'); await page.getByTestId('primary-navigation').getByRole('button', { name: 'Loyers' }).click(); await expect(page.getByRole('heading', { name: 'Studio République' })).toBeVisible(); await page.getByRole('button', { name: 'Enregistrer un règlement' }).click(); const dialog = page.getByRole('dialog'); await expect(dialog.getByRole('heading', { name: 'Enregistrer un règlement' })).toBeVisible(); await dialog.getByLabel('Montant reçu').fill('950'); await expect(dialog.getByText(/Surplus non affecté/)).toContainText('50,00'); await dialog.getByRole('button', { name: 'Confirmer le règlement' }).click(); await expect(page.getByRole('status')).toHaveText('Le règlement a été enregistré.'); expect(submittedPayment).toMatchObject({ rentDueId: 'due-1', rentDueVersion: 1, amount: 950, }); expect(submittedPayment?.['operationId']).toEqual(expect.any(String)); const dueCard = page .getByRole('article') .filter({ has: page.getByRole('heading', { name: 'Studio République' }) }); await expect(dueCard.getByText('Réglée', { exact: true })).toBeVisible(); await expect(dueCard.getByRole('button', { name: 'Télécharger la quittance' })).toBeVisible(); const paymentCard = page .getByRole('article') .filter({ has: page.getByText('Confirmé', { exact: true }) }); await expect(paymentCard.getByText(/^950,00\s€$/)).toBeVisible(); await expect(paymentCard.getByText(/^50,00\s€$/)).toBeVisible(); await expect(paymentCard.getByRole('button', { name: 'Rembourser' })).toBeVisible(); await expectNoHorizontalOverflow(page); });