Files
lmnp-organizer/frontend/e2e/app.spec.ts
T

322 lines
13 KiB
TypeScript

import { expect, Page, test } from '@playwright/test';
async function mockUser(page: Page, authenticated: boolean): Promise<void> {
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<void> {
await page.route('**/api/apartments', async (route) => {
await route.fulfill({ contentType: 'application/json', json: [] });
});
}
async function expectNoHorizontalOverflow(page: Page): Promise<void> {
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: '<h1>OIDC provider</h1>' });
});
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: '<h1>Signed out</h1>' });
});
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<string, unknown>[] = [];
const leases: Record<string, unknown>[] = [];
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<string, unknown>[]).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);
});