Files
planflow/tests/e2e/documents.spec.ts
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

205 lines
7.7 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';
/**
* Pièces du dossier salarié — PLAN.md §3.6 et WP-10.
*
* Trois garanties à protéger : le contenu revient tel qu'il a été déposé, le
* lien expire, et il ne suffit pas à lui seul — un navigateur sans session
* n'obtient rien.
*
* Chaque test dépose sur **son** salarié : les pièces s'accumulent, et deux
* tests qui se partageraient un dossier compteraient les pièces de l'autre.
*
* Les liens sont suivis par **navigation** et non par le client HTTP de
* Playwright : le cookie de session est marqué `Secure`, et ce client ne
* l'émet pas sur http, ce qui ferait passer les refus pour de bonnes raisons
* sans rien prouver.
*/
/** Un PDF minimal mais authentique : le type est contrôlé au dépôt. */
const PDF = Buffer.from(
'%PDF-1.4\n1 0 obj<</Type/Catalog>>endobj\ntrailer<</Root 1 0 R>>\n%%EOF\n',
'ascii',
);
async function createEmployee(page: Page, tag: string) {
const suffix = `${Date.now()}-${tag}`;
const lastName = `Dossier${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 form = page.locator('form').filter({ hasText: 'Informations salarié' });
await form.getByLabel('Prénom').fill('Inès');
await form.getByLabel('Nom de famille').fill(lastName);
await form.getByRole('button', { name: 'Saisir un matricule' }).click();
await form.getByLabel('Matricule').fill(`DOC${suffix}`);
// 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 form.getByLabel('Ouvrir un contrat maintenant').uncheck();
await form.getByRole('button', { name: 'Enregistrer' }).click();
await expect(page.getByText('Salarié ajouté.')).toBeVisible();
await page.getByRole('link', { name: new RegExp(lastName) }).click();
await expect(page.getByRole('heading', { name: new RegExp(lastName) })).toBeVisible();
// La fiche s'ouvre sur les informations personnelles : les pièces sont un
// onglet, et une route à part entière.
await page.goto(`${page.url()}/documents`);
return { lastName, url: page.url() };
}
/**
* Récupère une pièce depuis la page elle-même.
*
* `page.goto` sur un PDF passe par la visionneuse intégrée de Chromium, qui ne
* restitue pas le corps ; une requête émise depuis la page contourne cela et
* emporte les mêmes cookies que la navigation.
*/
async function fetchDocument(page: Page, href: string) {
return page.evaluate(async (target) => {
const response = await fetch(target);
const bytes = new Uint8Array(await response.arrayBuffer());
let binary = '';
for (const byte of bytes) binary += String.fromCharCode(byte);
return {
status: response.status,
contentType: response.headers.get('content-type'),
base64: btoa(binary),
};
}, href);
}
function uploadForm(page: Page) {
return page.locator('form').filter({ hasText: 'Déposer' });
}
async function upload(page: Page, category: string, name: string) {
await uploadForm(page).getByLabel('Catégorie').selectOption(category);
await uploadForm(page)
.getByLabel('Fichier')
.setInputFiles({ name, mimeType: 'application/pdf', buffer: PDF });
await uploadForm(page).getByRole('button', { name: 'Déposer' }).click();
await expect(page.getByText(`« ${name} » déposé.`)).toBeVisible();
}
test('une pièce déposée se relit à l’identique', async ({ page }) => {
await createEmployee(page, 'depot');
await expect(page.getByText(/Aucune pièce/)).toBeVisible();
await upload(page, 'IDENTITY', 'carte-identite.pdf');
const link = page.getByRole('link', { name: 'carte-identite.pdf' });
await expect(link).toBeVisible();
const href = (await link.getAttribute('href'))!;
const response = await fetchDocument(page, href);
expect(response.status).toBe(200);
expect(response.contentType).toBe('application/pdf');
// Le contenu traverse un chiffrement au repos : il doit revenir tel quel.
expect(Buffer.from(response.base64, 'base64').equals(PDF)).toBe(true);
});
test('un arrêt de travail est signalé comme donnée de santé', async ({
page,
}) => {
await createEmployee(page, 'sante');
await upload(page, 'SICK_NOTE', 'arret.pdf');
// Celui qui ouvre la pièce doit savoir que sa consultation laisse une trace
// nominative — c'est la contrepartie de la journalisation, pas un détail.
await expect(page.getByText('Santé · lecture journalisée')).toBeVisible();
const href = (await page
.getByRole('link', { name: 'arret.pdf' })
.getAttribute('href'))!;
expect((await fetchDocument(page, href)).status).toBe(200);
});
test('un lien de téléchargement ne vaut rien sans session', async ({
page,
browser,
}) => {
await createEmployee(page, 'anonyme');
await upload(page, 'CONTRACT', 'contrat.pdf');
const href = (await page
.getByRole('link', { name: 'contrat.pdf' })
.getAttribute('href'))!;
// La signature seule ne suffit pas : un lien recopié dans un message ne doit
// rien ouvrir à qui n'a pas le droit de lire le dossier.
const guest = await browser.newContext({ storageState: undefined });
const guestPage = await guest.newPage();
await guestPage.goto(new URL(href, page.url()).toString());
// Renvoyé vers la connexion, et sans une once du contenu.
await expect(guestPage).toHaveURL(/connexion/);
expect(await guestPage.content()).not.toContain('%PDF');
await guest.close();
});
test('un lien altéré ou expiré est refusé', async ({ page }) => {
await createEmployee(page, 'signature');
await upload(page, 'BANK', 'rib.pdf');
const href = (await page
.getByRole('link', { name: 'rib.pdf' })
.getAttribute('href'))!;
const url = new URL(href, page.url());
// Signature falsifiée.
const forged = new URL(url);
forged.searchParams.set('s', 'signature-inventee');
expect((await fetchDocument(page, forged.toString())).status).toBe(400);
// Échéance repoussée à la main : la signature ne couvre plus la valeur.
const extended = new URL(url);
extended.searchParams.set('e', String(Math.floor(Date.now() / 1000) + 86_400));
expect((await fetchDocument(page, extended.toString())).status).toBe(400);
// Échéance passée : refusée aussi, mais après contrôle de la signature —
// répondre « expiré » à un lien fabriqué indiquerait qu'il aurait pu marcher.
const stale = new URL(url);
stale.searchParams.set('e', '1');
expect((await fetchDocument(page, stale.toString())).status).toBe(400);
});
test('un format non accepté est refusé', async ({ page }) => {
await createEmployee(page, 'format');
await uploadForm(page)
.getByLabel('Fichier')
.setInputFiles({
name: 'macro.xls',
mimeType: 'application/vnd.ms-excel',
buffer: Buffer.from('sans importance'),
});
await uploadForm(page).getByRole('button', { name: 'Déposer' }).click();
await expect(page.getByText(/Format non accepté/)).toBeVisible();
await expect(page.getByText(/Aucune pièce/)).toBeVisible();
});
test('une pièce retirée n’est plus servie', async ({ page }) => {
await createEmployee(page, 'retrait');
await upload(page, 'OTHER', 'note.pdf');
const href = (await page
.getByRole('link', { name: 'note.pdf' })
.getAttribute('href'))!;
await page
.locator('li')
.filter({ hasText: 'note.pdf' })
.getByRole('button', { name: 'Retirer' })
.click();
await expect(page.getByText('Pièce retirée.')).toBeVisible();
await expect(page.getByText(/Aucune pièce/)).toBeVisible();
// Le lien signé reste valide dans sa fenêtre, mais le document ne l'est plus.
expect((await fetchDocument(page, href)).status).toBe(404);
});