Files
MichaelandClaude Opus 5 06198ddbce Recueillir à l'embauche ce qu'exige le contrat
Créer un salarié demandait cinq champs. Le contrat, la déclaration et le
registre en réclament une vingtaine, et rien ne permettait de les saisir :
le sexe, le nom de naissance, le pays et le département de naissance, la
situation de famille, les personnes à charge, le téléphone fixe, le
complément d'adresse et l'heure d'embauche n'existaient pas en base.

La migration les ajoute, toutes facultatives : un dossier incomplet doit
pouvoir exister — c'est au registre de signaler ce qui lui manque, pas à
la base de refuser l'embauche. Seul l'envoi des plannings par SMS fait
exception : c'est un consentement, donc faux par défaut, la charge de la
preuve pesant sur l'employeur.

Le pays et le département de naissance sont des colonnes à part et non une
commune saisie librement : la déclaration sociale les demande séparément,
et les rétro-extraire échouerait au premier « Bar-le-Duc (Meuse) ».

Le formulaire devient un panneau latéral : une trentaine de champs posés
au centre masquaient l'annuaire, et on embauche en regardant qui est déjà
là. Le matricule y est proposé à la suite du dernier, attribué dans la
transaction pour que deux embauches simultanées ne tombent pas sur le même
rang. Le nom de naissance vaut le nom de famille quand il n'en diffère
pas, plutôt que d'imposer une ressaisie à l'immense majorité des dossiers.

Le responsable hiérarchique et la politique RTT se posent enfin à
l'embauche : les deux modèles existaient sans qu'aucun écran ne les
alimente.

Le registre unique du personnel gagne sa colonne « sexe » et se tient
désormais au nom de naissance — le nom d'usage peut changer sans que la
personne change.

Migration écrite et validée contre le schéma, non appliquée : aucune base
n'était ouverte ici. À passer avec `pnpm db:migrate`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 17:28:28 +02:00

219 lines
8.8 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { expect, test, type Page } from '@playwright/test';
import { slugifyRoleKey } from '../../src/domain/access/role-editing';
/**
* Rôles et permissions — critère d'acceptation de WP-01.
*
* « Un rôle personnalisé créé par un client modifie effectivement l'accès, sans
* changement de code. » Le vérifier suppose d'aller jusqu'au bout : créer le
* rôle, l'attribuer, et constater qu'un écran s'ouvre ou se ferme en
* conséquence. Un test qui s'arrêterait à « la case est cochée » ne dirait rien.
*
* En série, et il le faut : l'un de ces tests retire momentanément « Gérer les
* rôles » aux rôles semés pour atteindre le refus de verrouillage. Joué en
* parallèle des autres, qui s'appuient sur cette même capacité, il les fait
* échouer par intermittence — et la cause n'est visible nulle part.
*/
test.describe.configure({ mode: 'serial' });
const OWNER_PASSWORD = 'planflow-demo-2026';
function roleName(tag: string): string {
return `Rôle ${tag} ${Date.now()}`;
}
/**
* Carte d'un rôle, par sa **clé**.
*
* Ni par son nom ni par un texte : les libellés de capacités contiennent
* « propriétaire », et une recherche textuelle — insensible à la casse —
* désignerait plusieurs cartes. La clé d'un rôle semé n'est d'ailleurs pas
* dérivée de son nom : « Propriétaire » porte la clé `owner`.
*/
function roleCard(page: Page, key: string) {
return page.getByTestId(`role-${key}`);
}
test('l’écran énonce les deux garde-fous', async ({ page }) => {
await page.goto('/reglages/roles');
await expect(
page.getByRole('heading', { name: 'Rôles et permissions' }),
).toBeVisible();
await expect(
page.getByText(/accorder que les capacités que vous détenez/),
).toBeVisible();
await expect(page.getByText(/Au moins un rôle doit conserver/)).toBeVisible();
});
test('un rôle naît sans aucune capacité', async ({ page }) => {
const name = roleName('vierge');
await page.goto('/reglages/roles');
await page.getByLabel('Nom du rôle').fill(name);
await page.getByRole('button', { name: 'Créer' }).click();
// Un rôle neuf qui hériterait des capacités de son créateur distribuerait des
// droits que personne n'a demandés.
await expect(page.getByText(/sans aucune capacité/)).toBeVisible();
await page.reload();
const card = roleCard(page, slugifyRoleKey(name));
await expect(card.getByText('0 capacité')).toBeVisible();
});
test('un rôle système ne s’efface pas', async ({ page }) => {
await page.goto('/reglages/roles');
const card = roleCard(page, 'owner');
await expect(card.getByText('Rôle système')).toBeVisible();
// Le code et le semis y font référence par sa clé.
await expect(page.getByTestId('role-delete-owner')).toHaveCount(0);
});
test('retirer la dernière gestion des droits est refusé', async ({ page }) => {
await page.goto('/reglages/roles');
// `owner` et `admin` sont les seuls rôles semés à porter la capacité : il
// faut la retirer aux deux pour atteindre le refus.
const admin = roleCard(page, 'admin');
await admin.getByRole('checkbox', { name: /Gérer les rôles/ }).uncheck();
await admin.getByRole('button', { name: 'Enregistrer' }).click();
await expect(admin.getByText('Capacités enregistrées.')).toBeVisible();
await page.reload();
const owner = roleCard(page, 'owner');
await owner.getByRole('checkbox', { name: /Gérer les rôles/ }).uncheck();
await owner.getByRole('button', { name: 'Enregistrer' }).click();
// Le second retrait est celui qui fermerait la porte.
await expect(
page.getByText(/fermerait la gestion des droits à tout le monde/),
).toBeVisible();
// Remettre la capacité : la suite de tests dépend de son existence.
await page.reload();
const restored = roleCard(page, 'admin');
await restored.getByRole('checkbox', { name: /Gérer les rôles/ }).check();
await restored.getByRole('button', { name: 'Enregistrer' }).click();
await expect(restored.getByText('Capacités enregistrées.')).toBeVisible();
});
test('un rôle client change effectivement l’accès', async ({
page,
browser,
}) => {
test.setTimeout(90_000);
const name = roleName('acces');
await page.goto('/reglages/roles');
await page.getByLabel('Nom du rôle').fill(name);
await page.getByRole('button', { name: 'Créer' }).click();
await expect(page.getByText(/sans aucune capacité/)).toBeVisible();
await page.reload();
// Une seule capacité : voir l'annuaire. Rien d'autre.
const card = roleCard(page, slugifyRoleKey(name));
await card.getByRole('checkbox', { name: 'Voir l’annuaire' }).check();
await card.getByRole('button', { name: 'Enregistrer' }).click();
await expect(card.getByText('Capacités enregistrées.')).toBeVisible();
const account = await inviteWithRole(page, name);
const guest = await browser.newContext({ storageState: undefined });
const guestPage = await guest.newPage();
await guestPage.goto('/connexion');
await guestPage.getByLabel('Adresse électronique').fill(account.email);
await guestPage.getByLabel('Mot de passe').fill(account.password);
await guestPage.getByRole('button', { name: 'Se connecter' }).click();
await guestPage.waitForURL((url) => !url.pathname.endsWith('/connexion'));
// Ce que le rôle accorde s'ouvre.
await guestPage.goto('/equipe');
await expect(guestPage.getByRole('heading', { name: 'Équipe' })).toBeVisible();
// Ce qu'il n'accorde pas reste fermé — c'est la moitié qui compte.
await guestPage.goto('/reglages/roles');
await expect(
guestPage.getByRole('heading', { name: 'Rôles et permissions' }),
).toHaveCount(0);
await guest.close();
});
/**
* Crée un salarié, lui attribue le rôle donné en base de l'annuaire, et ouvre
* son accès.
*
* Le rôle se choisit au moment de l'invitation faute d'écran d'affectation :
* l'attribution passe donc par la création, qui pose le rôle « employee », puis
* par une bascule directe — le test vise l'effet du rôle, pas le chemin qui y
* mène.
*/
async function inviteWithRole(page: Page, roleLabel: string) {
const suffix = `${Date.now()}-role`;
const email = `role.${suffix}@exemple.test`;
const password = 'les hirondelles de mars';
const lastName = `Role${suffix}`;
await page.goto('/equipe');
// Le formulaire d’embauche n’est plus posé au bas de la liste : il s’ouvre
// en modale, à la demande.
await page.getByRole('button', { name: 'Ajouter un collaborateur' }).click();
const create = page.locator('form').filter({ hasText: 'Informations salarié' });
await create.getByLabel('Prénom').fill('Alix');
await create.getByLabel('Nom de famille').fill(lastName);
await create.getByRole('button', { name: 'Saisir un matricule' }).click();
await create.getByLabel('Matricule').fill(`ROL${suffix}`);
await create.getByLabel('Adresse électronique').fill(email);
// Le formulaire propose d’ouvrir un contrat d’emblée : ce parcours n’en veut
// pas, et un salarié sans contrat doit rester créable.
await create.getByLabel('Ouvrir un contrat maintenant').uncheck();
await create.getByRole('button', { name: 'Enregistrer' }).click();
await expect(page.getByText('Salarié ajouté.')).toBeVisible();
await page.getByRole('link', { name: new RegExp(lastName) }).click();
// Attendre la fiche avant de lire l'URL : lue trop tôt, elle vaut encore
// celle de l'annuaire et l'identifiant récupéré ne désigne personne.
await expect(
page.getByRole('heading', { name: new RegExp(lastName) }),
).toBeVisible();
const membershipId = page.url().split('/').pop()!;
const { assignRole } = await import('./support/db');
await assignRole(membershipId, roleLabel);
// L'invitation est portée par l'onglet « Documents » de la fiche.
await page.goto(`/equipe/${membershipId}/documents`);
await page
.locator('form')
.filter({ hasText: 'Adresse d’invitation' })
.getByRole('button')
.click();
const url = (await page.getByTestId('invitation-link').textContent())!.trim();
const guest = await page.context().browser()!.newContext({
storageState: undefined,
});
const guestPage = await guest.newPage();
await guestPage.goto(url);
await guestPage.getByLabel('Choisissez un mot de passe').fill(password);
await guestPage.getByLabel('Confirmez').fill(password);
await guestPage.getByRole('button', { name: 'Activer mon accès' }).click();
await expect(guestPage.getByText(/Votre accès est actif/)).toBeVisible();
await guest.close();
return { email, password, membershipId };
}
test('le mot de passe de démonstration reste celui attendu', async ({
page,
}) => {
// Garde-fou : ces tests remanient les droits, et une dérive fermerait la
// suite entière au passage suivant.
await page.goto('/reglages/roles');
await expect(page.getByRole('heading', { name: 'Rôles et permissions' })).toBeVisible();
expect(OWNER_PASSWORD).toBe('planflow-demo-2026');
});