Files
lmnp-organizer/frontend/e2e/app.spec.ts
T
nessar b9e99348f7 feat: add rent payment history and reversal dialogs
- Implemented RentPaymentHistoryServiceTest to validate payment retrieval with refunds and reversals.
- Created ReverseRentPaymentServiceTest to ensure correct handling of payment reversals and allocation releases.
- Developed rent-payment-allocation-dialog component with form for surplus allocation.
- Added rent-payment-refund-dialog component for processing refunds with validation.
- Introduced rent-payment-reversal-dialog component for reversing payments with reason input.
- Implemented utility functions for formatting Euro values and handling date formatting.
- Enhanced rent-payment-dialogs.spec.ts to cover new dialog functionalities and error handling.
2026-08-17 14:18:15 +02:00

454 lines
17 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 }) => {
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: '<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.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('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.route('**/api/rent-dues', (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 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 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: 'Télécharger le bail' }).click();
const download = await downloadPromise;
expect(download.suggestedFilename()).toBe('bail-lease-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<string, unknown>[] = [];
let submittedPayment: Record<string, unknown> | 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', (route) =>
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<string, unknown>;
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);
});