Files
Claude b184109e23 Réparer l'intégration continue, et poser des ports internes non communs
**Le test des heures ne pouvait que tomber.** Il interrogeait le mois
précédent, alors que le seed ne pose que deux semaines : la courante et la
précédente. Hors des premiers jours d'un mois, le mois précédent est donc
vide. Mesuré sur une base semée à neuf : un seul mois porte des créneaux,
le mois courant. Le défaut ne se voyait pas en développement, où la base
garde les créneaux des exécutions antérieures — 39 créneaux de juillet
survivaient chez moi à des semis d'il y a plusieurs semaines.

**Le seed ne remettait pas l'état de publication.** Son `update` était vide,
si bien qu'une semaine déjà semée gardait le statut qu'elle avait alors : la
semaine précédente, publiée par définition, restait en brouillon dès qu'elle
avait été semée du temps où elle était la semaine courante. D'où des tests
qui échouent en local et passent en intégration continue — l'écart le plus
coûteux à diagnostiquer. Le statut est désormais réimposé.

**Ports internes.** L'application écoute sur 9317 et la base sur 5439,
jusque dans l'image. Sur un réseau Docker deux conteneurs peuvent écouter le
même port sans se gêner — ce n'est donc pas une correction de collision —
mais une valeur unique de bout en bout lève l'ambiguïté quand plusieurs
piles cohabitent derrière le même proxy, et la configuration du
reverse-proxy porte partout le même nombre. Publication, sonde de santé,
serveur, chaîne de connexion et scripts d'amorçage sont alignés sur une
seule variable par service.

`pnpm verify` : 450 tests. Playwright : 77/77 après remise à zéro du semis.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Cr9dkEHwbDgkWPnyGj1Rjv
2026-08-10 08:10:35 +00:00

148 lines
6.1 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 } from '@playwright/test';
import { formatMonthParam, monthOf } from '../../src/domain/planning/month';
/**
* Heures et périodes de paie.
*
* Ce que ces tests protègent : qu'un mois transmis au cabinet ne se modifie
* plus par inadvertance, et que rouvrir ce mois soit une décision justifiée
* plutôt qu'un bouton.
*/
/**
* Mois **courant**, et non le précédent.
*
* Le seed ne pose que deux semaines de planning : la courante et la
* précédente. Le mois précédent n'en contient donc aucune, sauf quand on joue
* la suite dans les premiers jours d'un mois — le tableau est vide le reste du
* temps, et l'assertion échoue pour une raison sans rapport avec ce qu'elle
* vérifie. Le défaut ne se voyait pas sur une base de développement, qui garde
* les créneaux des exécutions précédentes ; l'intégration continue, elle,
* repart d'un semis neuf.
*
* Le mois courant, lui, contient toujours aujourd'hui — donc toujours des
* créneaux, et aucun n'a d'heures réelles puisque rien dans le semis ni dans la
* suite n'en saisit.
*/
const MONTH = formatMonthParam(monthOf(new Date()));
/**
* Mois propre à cette exécution.
*
* Une période est unique par (établissement, bornes) et la base n'est pas
* remise à zéro entre deux passages : un mois calculé sur une plage étroite
* finit par retomber sur une période déjà créée, et le test échoue pour une
* raison sans rapport avec ce qu'il vérifie.
*/
const SALT = Math.floor(Date.now() / 1000);
function uniqueMonth(bucket: number): string {
const year = 2100 + ((SALT + bucket * 997) % 400);
const month = ((Math.floor(SALT / 400) + bucket) % 12) + 1;
return `${year}-${String(month).padStart(2, '0')}`;
}
test('le rapport d’heures affiche les trois grandeurs', async ({ page }) => {
await page.goto(`/rapports/heures?mois=${MONTH}`);
await expect(
page.getByRole('heading', { name: /^Heures travaillées · / }),
).toBeVisible();
// Sans saisie, le prévu fait foi — et l'écran le dit plutôt que d'afficher
// un réalisé vide qu'on prendrait pour zéro heure.
await expect(page.getByText(/le prévu fait foi/)).toBeVisible();
await expect(page.getByText('prévu retenu').first()).toBeVisible();
});
test('l’écran dit que la validation ne conditionne pas le paiement', async ({
page,
}) => {
await page.goto(`/rapports/heures?mois=${MONTH}`);
// Bloquer le paiement d'heures accomplies faute de validation est ce que la
// matrice de conformité interdit : l'écran l'énonce pour qu'aucun manager ne
// croie l'inverse.
await expect(
page.getByText(/elle ne les autorise pas à être payées/),
).toBeVisible();
});
test('verrouiller une période ferme le mois aux modifications', async ({
page,
}) => {
await page.goto('/paie/periodes');
await expect(
page.getByRole('heading', { name: 'Périodes de paie' }),
).toBeVisible();
const month = uniqueMonth(0);
const label = `Test ${Date.now()}`;
const create = page.locator('form').filter({ hasText: 'Créer la période' });
await create.getByLabel('Mois').fill(month);
await create.getByLabel('Libellé').fill(label);
await create.getByRole('button', { name: 'Créer la période' }).click();
const card = page.locator('section').filter({ hasText: label }).first();
await expect(card).toBeVisible();
await expect(card.getByText('Ouverte')).toBeVisible();
await card.getByRole('button', { name: 'Verrouiller la période' }).click();
await expect(card.getByText('Verrouillée')).toBeVisible();
// Déverrouiller sans motif est refusé : rouvrir périme les fichiers déjà
// transmis, et six mois plus tard personne ne saurait pourquoi.
const unlock = card.locator('form').filter({ hasText: 'Déverrouiller' });
await expect(unlock.getByPlaceholder('Motif du déverrouillage')).toBeVisible();
await unlock
.getByPlaceholder('Motif du déverrouillage')
.fill('Correction demandée par le cabinet');
await unlock.getByRole('button', { name: 'Déverrouiller' }).click();
await expect(card.getByText('Ouverte')).toBeVisible();
// Rendre le mois : une période est unique par bornes, et en consommer une par
// exécution finit par épuiser la plage — le test échouerait alors sur une
// création refusée, pour une raison sans rapport avec ce qu'il vérifie.
const remove = card.locator('form').filter({ hasText: 'Supprimer' });
await remove.getByRole('button', { name: 'Supprimer' }).click();
await expect(remove.getByText(/saisissez le libellé exact/i)).toBeVisible();
await remove.getByPlaceholder(`Saisir « ${label} »`).fill(label);
await remove.getByRole('button', { name: 'Supprimer' }).click();
await expect(page.locator('section').filter({ hasText: label })).toHaveCount(
0,
);
});
test('supprimer une période exige de retaper son libellé', async ({ page }) => {
await page.goto('/paie/periodes');
const month = uniqueMonth(1);
const label = `Suppr ${Date.now()}`;
const create = page.locator('form').filter({ hasText: 'Créer la période' });
await create.getByLabel('Mois').fill(month);
await create.getByLabel('Libellé').fill(label);
await create.getByRole('button', { name: 'Créer la période' }).click();
// La liste est bornée aux 24 périodes les plus récentes : sans nettoyage, les
// restes d'exécutions interrompues finissent par repousser la nouvelle hors
// de l'écran, et le test échoue pour une raison sans rapport avec ce qu'il
// vérifie.
const card = page.locator('section').filter({ hasText: label }).first();
await expect(card).toBeVisible();
// Un simple « êtes-vous sûr » se clique sans lire : la confirmation demande
// le libellé exact.
const remove = card.locator('form').filter({ hasText: 'Supprimer' });
await remove.getByRole('button', { name: 'Supprimer' }).click();
await expect(remove.getByText(/saisissez le libellé exact/i)).toBeVisible();
await remove.getByPlaceholder(`Saisir « ${label} »`).fill(label);
await remove.getByRole('button', { name: 'Supprimer' }).click();
await expect(page.locator('section').filter({ hasText: label })).toHaveCount(
0,
);
});