- 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.
454 lines
17 KiB
TypeScript
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);
|
|
});
|