WP-06 : absences, registre de compteurs, calendrier
Le dernier module de démonstration disparaît : `src/lib/demo/` ne contient plus que l'aperçu. Le décompte, là où l'on se trompe - **`endDate` est le dernier jour d'absence, pas la date de reprise.** C'est la confusion la plus fréquente du domaine : elle décompte un jour de trop ou de trop peu à chaque demande, et le salarié s'en aperçoit au solde, des mois plus tard. Le champ du formulaire s'appelle « Dernier jour d'absence », et un test couvre explicitement la confusion. - **Un jour férié dans un congé ne se décompte pas**, sans que l'utilisateur ait à scinder sa demande. Exiger la scission, c'est lui déplacer la charge d'un calcul que l'outil sait faire. - Le rythme du contrat est respecté : un temps partiel qui ne travaille jamais le mercredi ne consomme pas de congé ce jour-là. - Ouvrables ou ouvrés est un **paramètre**, pas une constante : se tromper fausse tous les soldes de la même façon. Jours fériés calculés, pas listés Une table écrite à la main est juste l'année où on l'écrit et fausse dès la suivante — et un férié manquant se décompte comme un jour de congé, sans que personne ne le voie. Les onze jours légaux sont donc calculés, Pâques comprise (algorithme grégorien anonyme, vérifié sur quatre années de référence). Quand aucun férié n'est enregistré pour l'année demandée, la demande passe mais l'écran le dit : mieux vaut l'annoncer que laisser croire à un décompte complet. Le registre est la source de vérité - Aucun solde n'est stocké : le solde est la **somme** des écritures. Un solde stocké se désynchronise, et la désynchronisation ne se voit qu'au moment où un salarié conteste. - `UPDATE` et `DELETE` sont refusés par un trigger PostgreSQL, pas seulement par l'application : une règle applicative finit par être contournée par un script de reprise. Les tests d'intégration écrivent directement en base pour le prouver. - Annuler une absence acceptée **contre-passe** la prise au lieu de l'effacer, à la date de la correction — antidater masquerait la correction dans les soldes déjà communiqués. `reversesId` est unique : contre-passer deux fois ferait repartir le solde dans l'autre sens. - Un ajustement manuel sans justification est refusé. Confidentialité du motif médical Un manager voit qu'un salarié est absent, pas de quoi il souffre (matrice n° 9). Le motif d'un arrêt n'est **pas chargé** pour qui n'a pas la capacité de le lire — un champ absent de la réponse ne peut fuiter ni par le HTML ni par un journal. Sur la grille de planning, l'absence s'affiche « Absence » : c'est suffisant pour ne pas planifier quelqu'un. Acquisition 2,5 jours ouvrables par mois travaillé ; 2 jours par mois d'arrêt maladie non professionnelle, plafonnés à 24 par an — droit issu de la réforme de 2024, dont l'oubli prive le salarié d'un droit acquis. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Cr9dkEHwbDgkWPnyGj1Rjv
This commit is contained in:
18 files changed
+3160
-228
No files matched your search
@@ -0,0 +1,208 @@
|
||||
import { expect, test, type Page } from '@playwright/test';
|
||||
|
||||
import { frenchHolidays } from '../../src/domain/absences/holidays';
|
||||
|
||||
/**
|
||||
* Parcours des absences.
|
||||
*
|
||||
* Ce que ces tests protègent en priorité : que « dernier jour d'absence » soit
|
||||
* bien traité comme tel. La confusion avec la date de reprise décompte un jour
|
||||
* de trop ou de trop peu à chaque demande, et le salarié s'en aperçoit au
|
||||
* solde, des mois plus tard.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Décalage propre à cette exécution.
|
||||
*
|
||||
* La base de test n'est pas remise à zéro entre deux passages, et une absence
|
||||
* acceptée bloque toute demande qui la recouvre : des dates fixes feraient
|
||||
* échouer le deuxième passage pour une raison sans rapport avec ce qui est
|
||||
* testé. Chaque exécution travaille donc sur sa propre fenêtre de dates.
|
||||
*/
|
||||
const RUN_OFFSET = 150 + (Math.floor(Date.now() / 1000) % 700);
|
||||
|
||||
/** Une date libre, loin de tout ce que le seed pose. */
|
||||
function isoDate(offsetDays: number): string {
|
||||
return new Date(Date.now() + (RUN_OFFSET + offsetDays) * 86_400_000)
|
||||
.toISOString()
|
||||
.slice(0, 10);
|
||||
}
|
||||
|
||||
/** Prochain jour de la semaine demandé (0 = dimanche) dans la fenêtre du run. */
|
||||
function nextWeekday(weekday: number): string {
|
||||
const cursor = new Date(Date.now() + (RUN_OFFSET + 40) * 86_400_000);
|
||||
while (cursor.getUTCDay() !== weekday) {
|
||||
cursor.setUTCDate(cursor.getUTCDate() + 1);
|
||||
}
|
||||
return cursor.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
async function request(
|
||||
page: Page,
|
||||
options: { from: string; to: string; type?: string; comment?: string },
|
||||
) {
|
||||
const form = page.locator('form').filter({ hasText: 'Demander' }).first();
|
||||
if (options.type) {
|
||||
await form.getByLabel('Type').selectOption({ label: options.type });
|
||||
}
|
||||
await form.getByLabel('Premier jour').fill(options.from);
|
||||
await form.getByLabel('Dernier jour d’absence').fill(options.to);
|
||||
if (options.comment) {
|
||||
await form.getByLabel('Commentaire').fill(options.comment);
|
||||
}
|
||||
await form.getByRole('button', { name: 'Demander' }).click();
|
||||
return form;
|
||||
}
|
||||
|
||||
test('le formulaire nomme le dernier jour d’absence, pas la date de fin', async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto('/conges');
|
||||
// Le libellé exact coûte moins cher qu'une régularisation de solde.
|
||||
await expect(
|
||||
page.getByText('Dernier jour d’absence', { exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByText(/veille de la reprise/),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('une demande apparaît dans la file, puis se décide', async ({ page }) => {
|
||||
await page.goto('/conges');
|
||||
|
||||
const from = isoDate(0);
|
||||
const to = isoDate(4);
|
||||
const comment = `Test ${Date.now()}`;
|
||||
await request(page, { from, to, type: 'Congés payés', comment });
|
||||
|
||||
const pending = page
|
||||
.locator('section')
|
||||
.filter({ hasText: 'Demandes en attente' });
|
||||
const row = pending.locator('li').filter({ hasText: comment });
|
||||
await expect(row).toBeVisible();
|
||||
|
||||
await row.getByRole('button', { name: 'Accepter' }).click();
|
||||
|
||||
// Acceptée, elle quitte la file et rejoint les absences du mois.
|
||||
await expect(pending.locator('li').filter({ hasText: comment })).toHaveCount(
|
||||
0,
|
||||
);
|
||||
});
|
||||
|
||||
test('deux absences qui se recouvrent sont refusées', async ({ page }) => {
|
||||
await page.goto('/conges');
|
||||
|
||||
const comment = `Chevauchement ${Date.now()}`;
|
||||
await request(page, {
|
||||
from: isoDate(60),
|
||||
to: isoDate(65),
|
||||
type: 'Congés payés',
|
||||
comment,
|
||||
});
|
||||
|
||||
// Attendre que la première soit réellement enregistrée : soumettre la
|
||||
// seconde pendant la revalidation ferait porter le clic dans le vide, et le
|
||||
// test passerait pour de mauvaises raisons.
|
||||
const pending = page
|
||||
.locator('section')
|
||||
.filter({ hasText: 'Demandes en attente' });
|
||||
await expect(pending.locator('li').filter({ hasText: comment })).toBeVisible();
|
||||
|
||||
// La seconde chevauche la première d'un seul jour : bornes inclusives des
|
||||
// deux côtés, puisque la date de fin est un jour d'absence.
|
||||
const form = await request(page, {
|
||||
from: isoDate(65),
|
||||
to: isoDate(68),
|
||||
type: 'Congés payés',
|
||||
});
|
||||
await expect(form.getByText(/couvre déjà/)).toBeVisible();
|
||||
});
|
||||
|
||||
test('une période sans jour décomptable est refusée', async ({ page }) => {
|
||||
await page.goto('/conges');
|
||||
|
||||
// Un dimanche seul : jamais ouvrable, donc rien à décompter.
|
||||
const sunday = nextWeekday(0);
|
||||
const form = await request(page, {
|
||||
from: sunday,
|
||||
to: sunday,
|
||||
type: 'Congés payés',
|
||||
});
|
||||
await expect(form.getByText(/aucun jour décomptable/)).toBeVisible();
|
||||
});
|
||||
|
||||
test('un jour férié dans la période n’est pas décompté', async ({ page }) => {
|
||||
await page.goto('/conges');
|
||||
|
||||
// Un jour férié pris au hasard parmi ceux de l'année : la période qui
|
||||
// l'englobe ne doit pas le décompter, et l'utilisateur n'a pas eu à scinder
|
||||
// sa demande. Le tirage évite de retomber sur la même semaine à chaque run.
|
||||
const holidays = frenchHolidays(new Date().getUTCFullYear() + 2);
|
||||
const holiday = holidays[
|
||||
Math.floor(Date.now() / 1000) % holidays.length
|
||||
] as (typeof holidays)[number];
|
||||
|
||||
const day = (offset: number) =>
|
||||
new Date(
|
||||
new Date(`${holiday.isoDate}T00:00:00Z`).getTime() + offset * 86_400_000,
|
||||
)
|
||||
.toISOString()
|
||||
.slice(0, 10);
|
||||
|
||||
const comment = `Ferie ${Date.now()}`;
|
||||
await request(page, {
|
||||
from: day(-1),
|
||||
to: day(1),
|
||||
type: 'Congés payés',
|
||||
comment,
|
||||
});
|
||||
|
||||
const row = page.locator('li').filter({ hasText: comment });
|
||||
await expect(row).toBeVisible();
|
||||
// Trois jours calendaires dont un férié : jamais trois décomptés.
|
||||
await expect(row.getByText(/3 jours décomptés/)).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('une date de fin antérieure au début rappelle la règle', async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto('/conges');
|
||||
|
||||
const form = await request(page, {
|
||||
from: isoDate(100),
|
||||
to: isoDate(98),
|
||||
type: 'Congés payés',
|
||||
});
|
||||
await expect(form.getByRole('alert')).toContainText(
|
||||
/dernier jour d’absence/i,
|
||||
);
|
||||
});
|
||||
|
||||
test('le solde revient à son niveau après un aller-retour', async ({ page }) => {
|
||||
await page.goto('/conges');
|
||||
|
||||
const counters = page.locator('section').filter({ hasText: 'Compteurs' });
|
||||
await expect(counters).toBeVisible();
|
||||
|
||||
const from = isoDate(120);
|
||||
const to = isoDate(122);
|
||||
const comment = `Aller-retour ${Date.now()}`;
|
||||
await request(page, { from, to, type: 'Congés payés', comment });
|
||||
|
||||
const pending = page
|
||||
.locator('section')
|
||||
.filter({ hasText: 'Demandes en attente' });
|
||||
const row = pending.locator('li').filter({ hasText: comment });
|
||||
await row.getByRole('button', { name: 'Accepter' }).click();
|
||||
await expect(pending.locator('li').filter({ hasText: comment })).toHaveCount(
|
||||
0,
|
||||
);
|
||||
|
||||
// Annuler contre-passe la prise : le solde revient au même chiffre, sans
|
||||
// qu'aucune écriture ait été effacée.
|
||||
const month = page.locator('section').filter({ hasText: 'Absences du mois' });
|
||||
const accepted = month.locator('li').filter({ hasText: 'Acceptée' }).first();
|
||||
if (await accepted.isVisible()) {
|
||||
await accepted.getByRole('button', { name: 'Annuler' }).click();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,147 @@
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
|
||||
/**
|
||||
* Immutabilité du registre — critère d'acceptation de WP-06.
|
||||
*
|
||||
* La règle est **en base**, pas seulement dans l'application : une règle
|
||||
* applicative finit par être contournée par un script de reprise, une console
|
||||
* d'administration ou une migration pressée. Ces tests écrivent directement,
|
||||
* sans passer par les Server Actions, pour le prouver.
|
||||
*/
|
||||
|
||||
const enabled = (process.env.DATABASE_URL ?? '').length > 0;
|
||||
const describeIfDb = enabled ? describe : describe.skip;
|
||||
|
||||
const suffix = `ledger-${Date.now()}`;
|
||||
const accountId = `${suffix}-account`;
|
||||
const membershipId = `${suffix}-member`;
|
||||
|
||||
let unscoped: typeof import('@/server/tenant').unscoped;
|
||||
let counterId = '';
|
||||
let operationId = '';
|
||||
|
||||
describeIfDb('registre des compteurs', () => {
|
||||
beforeAll(async () => {
|
||||
process.env.ENCRYPTION_KEY ??= Buffer.alloc(32, 3).toString('base64');
|
||||
({ unscoped } = await import('@/server/tenant'));
|
||||
const db = unscoped();
|
||||
|
||||
await db.account.create({
|
||||
data: { id: accountId, name: `Compte ${suffix}` },
|
||||
});
|
||||
const role = await db.role.create({
|
||||
data: { accountId, key: 'employee', name: 'Employé' },
|
||||
});
|
||||
await db.membership.create({
|
||||
data: {
|
||||
id: membershipId,
|
||||
accountId,
|
||||
roleId: role.id,
|
||||
employeeNumber: 'L0001',
|
||||
status: 'ACTIVE',
|
||||
},
|
||||
});
|
||||
|
||||
const counter = await db.counter.create({
|
||||
data: {
|
||||
accountId,
|
||||
membershipId,
|
||||
counterType: 'PAID_LEAVE',
|
||||
acquisitionPeriodStart: new Date('2026-06-01'),
|
||||
acquisitionPeriodEnd: new Date('2027-05-31'),
|
||||
},
|
||||
});
|
||||
counterId = counter.id;
|
||||
|
||||
const operation = await db.ledgerOperation.create({
|
||||
data: {
|
||||
accountId,
|
||||
counterId,
|
||||
kind: 'ACCRUAL',
|
||||
quantity: 25,
|
||||
unit: 'DAY',
|
||||
effectiveDate: new Date('2026-06-01'),
|
||||
sourceType: 'SYSTEM',
|
||||
},
|
||||
});
|
||||
operationId = operation.id;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (!enabled) return;
|
||||
const db = unscoped();
|
||||
// Le trigger interdit DELETE sur les écritures : la suppression du compte
|
||||
// ne peut donc pas cascader. On le désactive le temps du ménage.
|
||||
await db.$executeRawUnsafe(
|
||||
'ALTER TABLE "LedgerOperation" DISABLE TRIGGER ledger_operation_append_only',
|
||||
);
|
||||
await db.account.delete({ where: { id: accountId } });
|
||||
await db.$executeRawUnsafe(
|
||||
'ALTER TABLE "LedgerOperation" ENABLE TRIGGER ledger_operation_append_only',
|
||||
);
|
||||
});
|
||||
|
||||
it('refuse toute modification d’écriture', async () => {
|
||||
await expect(
|
||||
unscoped().ledgerOperation.update({
|
||||
where: { id: operationId },
|
||||
data: { quantity: 999 },
|
||||
}),
|
||||
).rejects.toThrow(/append-only/);
|
||||
});
|
||||
|
||||
it('refuse toute suppression d’écriture', async () => {
|
||||
await expect(
|
||||
unscoped().ledgerOperation.delete({ where: { id: operationId } }),
|
||||
).rejects.toThrow(/append-only/);
|
||||
});
|
||||
|
||||
it('accepte une contre-passation, qui laisse les deux écritures', async () => {
|
||||
// Une correction s'écrit ; elle ne se réécrit pas. Les deux lignes
|
||||
// coexistent, et le solde redevient juste par addition.
|
||||
const db = unscoped();
|
||||
await db.ledgerOperation.create({
|
||||
data: {
|
||||
accountId,
|
||||
counterId,
|
||||
kind: 'REGULARISATION',
|
||||
quantity: -25,
|
||||
unit: 'DAY',
|
||||
effectiveDate: new Date('2026-07-01'),
|
||||
sourceType: 'MANUAL',
|
||||
reason: 'Correction du solde d’ouverture',
|
||||
reversesId: operationId,
|
||||
},
|
||||
});
|
||||
|
||||
const operations = await db.ledgerOperation.findMany({
|
||||
where: { counterId },
|
||||
});
|
||||
expect(operations).toHaveLength(2);
|
||||
|
||||
const balance = operations.reduce(
|
||||
(sum, operation) => sum + Number(operation.quantity.toString()),
|
||||
0,
|
||||
);
|
||||
expect(balance).toBe(0);
|
||||
});
|
||||
|
||||
it('n’accepte qu’une seule contre-passation par écriture', async () => {
|
||||
// `reversesId` est unique : contre-passer deux fois la même écriture
|
||||
// doublerait la correction, et le solde partirait dans l'autre sens.
|
||||
await expect(
|
||||
unscoped().ledgerOperation.create({
|
||||
data: {
|
||||
accountId,
|
||||
counterId,
|
||||
kind: 'REGULARISATION',
|
||||
quantity: -25,
|
||||
unit: 'DAY',
|
||||
effectiveDate: new Date('2026-07-02'),
|
||||
sourceType: 'MANUAL',
|
||||
reversesId: operationId,
|
||||
},
|
||||
}),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,349 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
absenceDayCount,
|
||||
countAbsenceDays,
|
||||
datesBetween,
|
||||
findAbsenceOverlaps,
|
||||
isRetroactive,
|
||||
isoWeekday,
|
||||
respectsNotice,
|
||||
} from '@/domain/absences/count';
|
||||
import {
|
||||
accrualForMonth,
|
||||
balanceAt,
|
||||
balanceOf,
|
||||
counterView,
|
||||
reversalOf,
|
||||
type LedgerEntry,
|
||||
} from '@/domain/absences/ledger';
|
||||
|
||||
/** Août 2026 : le 15 est un samedi férié, le 14 un vendredi. */
|
||||
const AOUT_2026 = ['2026-08-15'];
|
||||
|
||||
describe('endDate est le dernier jour d’absence', () => {
|
||||
it('décompte les deux bornes', () => {
|
||||
// Du lundi 10 au vendredi 14 : cinq jours, pas quatre. La confusion avec
|
||||
// la date de reprise décompte un jour de trop ou de trop peu à chaque
|
||||
// demande, et le salarié s'en aperçoit au solde, des mois plus tard.
|
||||
expect(
|
||||
absenceDayCount(
|
||||
{ startDate: '2026-08-10', endDate: '2026-08-14' },
|
||||
{ holidays: [] },
|
||||
),
|
||||
).toBe(5);
|
||||
});
|
||||
|
||||
it('compte un jour pour une absence d’une journée', () => {
|
||||
expect(
|
||||
absenceDayCount(
|
||||
{ startDate: '2026-08-10', endDate: '2026-08-10' },
|
||||
{ holidays: [] },
|
||||
),
|
||||
).toBe(1);
|
||||
});
|
||||
|
||||
it('ne décompte pas le jour de reprise', () => {
|
||||
// Reprise le lundi 17 : l'absence s'arrête au vendredi 14.
|
||||
const withReturnDate = absenceDayCount(
|
||||
{ startDate: '2026-08-10', endDate: '2026-08-17' },
|
||||
{ holidays: [] },
|
||||
);
|
||||
const correct = absenceDayCount(
|
||||
{ startDate: '2026-08-10', endDate: '2026-08-14' },
|
||||
{ holidays: [] },
|
||||
);
|
||||
expect(correct).toBe(5);
|
||||
expect(withReturnDate).toBe(6);
|
||||
});
|
||||
|
||||
it('rend zéro sur une période inversée', () => {
|
||||
expect(
|
||||
absenceDayCount(
|
||||
{ startDate: '2026-08-14', endDate: '2026-08-10' },
|
||||
{ holidays: [] },
|
||||
),
|
||||
).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('jours fériés', () => {
|
||||
it('retire le férié sans exiger de scinder la demande', () => {
|
||||
// Du lundi 10 au lundi 17, le samedi 15 est férié. En ouvrables :
|
||||
// 10, 11, 12, 13, 14, 17 comptent ; le 15 est férié, le 16 dimanche.
|
||||
const { total, days } = countAbsenceDays(
|
||||
{ startDate: '2026-08-10', endDate: '2026-08-17' },
|
||||
{ holidays: AOUT_2026 },
|
||||
);
|
||||
expect(total).toBe(6);
|
||||
expect(days.find((day) => day.isoDate === '2026-08-15')?.reason).toBe(
|
||||
'HOLIDAY',
|
||||
);
|
||||
});
|
||||
|
||||
it('ne décompte rien sur un congé entièrement férié', () => {
|
||||
expect(
|
||||
absenceDayCount(
|
||||
{ startDate: '2026-08-15', endDate: '2026-08-15' },
|
||||
{ holidays: AOUT_2026 },
|
||||
),
|
||||
).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('base de décompte', () => {
|
||||
it('compte le samedi en ouvrables', () => {
|
||||
// Du lundi 10 au samedi 15 sans férié : six jours ouvrables.
|
||||
expect(
|
||||
absenceDayCount(
|
||||
{ startDate: '2026-08-10', endDate: '2026-08-15' },
|
||||
{ holidays: [], basis: 'OUVRABLES', workingWeekdays: [1, 2, 3, 4, 5, 6] },
|
||||
),
|
||||
).toBe(6);
|
||||
});
|
||||
|
||||
it('exclut le samedi en ouvrés', () => {
|
||||
expect(
|
||||
absenceDayCount(
|
||||
{ startDate: '2026-08-10', endDate: '2026-08-15' },
|
||||
{ holidays: [], basis: 'OUVRES', workingWeekdays: [1, 2, 3, 4, 5, 6] },
|
||||
),
|
||||
).toBe(5);
|
||||
});
|
||||
|
||||
it('respecte le rythme du contrat', () => {
|
||||
// Un temps partiel qui ne travaille jamais le mercredi ne consomme pas de
|
||||
// congé ce jour-là.
|
||||
const { total, days } = countAbsenceDays(
|
||||
{ startDate: '2026-08-10', endDate: '2026-08-14' },
|
||||
{ holidays: [], workingWeekdays: [1, 2, 4, 5] },
|
||||
);
|
||||
expect(total).toBe(4);
|
||||
expect(days.find((day) => day.isoDate === '2026-08-12')?.reason).toBe(
|
||||
'NOT_WORKED',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('demi-journées', () => {
|
||||
it('retire une demi-journée à chaque borne marquée', () => {
|
||||
expect(
|
||||
absenceDayCount(
|
||||
{
|
||||
startDate: '2026-08-10',
|
||||
endDate: '2026-08-14',
|
||||
startHalfDay: true,
|
||||
endHalfDay: true,
|
||||
},
|
||||
{ holidays: [] },
|
||||
),
|
||||
).toBe(4);
|
||||
});
|
||||
|
||||
it('compte une demi-journée pour une absence d’une demi-journée', () => {
|
||||
expect(
|
||||
absenceDayCount(
|
||||
{
|
||||
startDate: '2026-08-10',
|
||||
endDate: '2026-08-10',
|
||||
startHalfDay: true,
|
||||
},
|
||||
{ holidays: [] },
|
||||
),
|
||||
).toBe(0.5);
|
||||
});
|
||||
|
||||
it('ne retire rien de plus sur une borne fériée', () => {
|
||||
// Une demi-journée posée sur un férié ne peut pas rendre le décompte
|
||||
// négatif : le férié ne se décompte déjà pas.
|
||||
expect(
|
||||
absenceDayCount(
|
||||
{
|
||||
startDate: '2026-08-15',
|
||||
endDate: '2026-08-17',
|
||||
startHalfDay: true,
|
||||
},
|
||||
{ holidays: AOUT_2026 },
|
||||
),
|
||||
).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('chevauchements', () => {
|
||||
const existing = [
|
||||
{ id: 'a', startDate: '2026-08-10', endDate: '2026-08-14' },
|
||||
];
|
||||
|
||||
it('détecte un recouvrement partiel', () => {
|
||||
expect(
|
||||
findAbsenceOverlaps(
|
||||
{ startDate: '2026-08-13', endDate: '2026-08-20' },
|
||||
existing,
|
||||
),
|
||||
).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('détecte deux congés qui se touchent le même jour', () => {
|
||||
// Bornes inclusives des deux côtés : `endDate` est un jour d'absence, donc
|
||||
// une comparaison exclusive laisserait passer ce cas.
|
||||
expect(
|
||||
findAbsenceOverlaps(
|
||||
{ startDate: '2026-08-14', endDate: '2026-08-18' },
|
||||
existing,
|
||||
),
|
||||
).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('accepte deux congés consécutifs sans recouvrement', () => {
|
||||
expect(
|
||||
findAbsenceOverlaps(
|
||||
{ startDate: '2026-08-15', endDate: '2026-08-18' },
|
||||
existing,
|
||||
),
|
||||
).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('ignore l’absence en cours de modification', () => {
|
||||
expect(
|
||||
findAbsenceOverlaps(
|
||||
{ startDate: '2026-08-10', endDate: '2026-08-14' },
|
||||
existing,
|
||||
'a',
|
||||
),
|
||||
).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('préavis et rétroactivité', () => {
|
||||
it('accepte une demande respectant le préavis', () => {
|
||||
expect(
|
||||
respectsNotice({ startDate: '2026-08-20', endDate: '2026-08-21' }, '2026-08-10', 7),
|
||||
).toBe(true);
|
||||
expect(
|
||||
respectsNotice({ startDate: '2026-08-16', endDate: '2026-08-17' }, '2026-08-10', 7),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('n’exige rien quand aucun préavis n’est paramétré', () => {
|
||||
expect(
|
||||
respectsNotice({ startDate: '2026-08-11', endDate: '2026-08-11' }, '2026-08-10', null),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('signale une demande rétroactive sans l’interdire', () => {
|
||||
// Un arrêt maladie se déclare toujours après coup : c'est l'écran qui
|
||||
// avertit, pas le domaine qui bloque.
|
||||
expect(
|
||||
isRetroactive({ startDate: '2026-08-01', endDate: '2026-08-05' }, '2026-08-10'),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('utilitaires de dates', () => {
|
||||
it('numérote lundi 1 et dimanche 7', () => {
|
||||
expect(isoWeekday('2026-08-10')).toBe(1);
|
||||
expect(isoWeekday('2026-08-16')).toBe(7);
|
||||
});
|
||||
|
||||
it('énumère les bornes incluses', () => {
|
||||
expect(datesBetween('2026-08-10', '2026-08-12')).toEqual([
|
||||
'2026-08-10',
|
||||
'2026-08-11',
|
||||
'2026-08-12',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('registre des compteurs', () => {
|
||||
const entries: LedgerEntry[] = [
|
||||
{ kind: 'ACCRUAL', quantity: 2.5, unit: 'DAY', effectiveDate: '2026-01-31' },
|
||||
{ kind: 'ACCRUAL', quantity: 2.5, unit: 'DAY', effectiveDate: '2026-02-28' },
|
||||
{ kind: 'TAKEN', quantity: -3, unit: 'DAY', effectiveDate: '2026-02-10' },
|
||||
];
|
||||
|
||||
it('somme les écritures plutôt que de stocker un solde', () => {
|
||||
// Un solde stocké se désynchronise, et la désynchronisation ne se voit
|
||||
// qu'au moment où un salarié conteste.
|
||||
expect(balanceOf(entries)).toBe(2);
|
||||
});
|
||||
|
||||
it('donne le solde à une date passée', () => {
|
||||
expect(balanceAt(entries, '2026-01-31')).toBe(2.5);
|
||||
expect(balanceAt(entries, '2026-02-10')).toBe(-0.5);
|
||||
});
|
||||
|
||||
it('projette les acquisitions restantes', () => {
|
||||
const view = counterView(entries, 25);
|
||||
expect(view.accrued).toBe(5);
|
||||
expect(view.taken).toBe(3);
|
||||
expect(view.balance).toBe(2);
|
||||
expect(view.projected).toBe(27);
|
||||
});
|
||||
|
||||
it('contre-passe à la date de la correction, pas à celle de l’écriture', () => {
|
||||
// Antidater masquerait la correction dans les soldes déjà communiqués.
|
||||
const original = entries[2] as LedgerEntry;
|
||||
const reversal = reversalOf(original, '2026-03-15');
|
||||
expect(reversal.quantity).toBe(3);
|
||||
expect(reversal.effectiveDate).toBe('2026-03-15');
|
||||
expect(balanceOf([...entries, reversal])).toBe(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe('acquisition de congés payés', () => {
|
||||
it('acquiert 2,5 jours par mois travaillé', () => {
|
||||
expect(accrualForMonth(true, 0)).toBe(2.5);
|
||||
});
|
||||
|
||||
it('continue d’acquérir pendant un arrêt maladie', () => {
|
||||
// Droit issu de la réforme de 2024 : l'oublier prive le salarié d'un droit
|
||||
// acquis, sans que rien ne le signale.
|
||||
expect(accrualForMonth(false, 0)).toBe(2);
|
||||
expect(accrualForMonth(false, 5)).toBe(2);
|
||||
});
|
||||
|
||||
it('plafonne l’acquisition maladie à 24 jours par an', () => {
|
||||
// Douze mois d'arrêt à 2 jours feraient 24 : le treizième n'acquiert rien.
|
||||
expect(accrualForMonth(false, 11)).toBe(2);
|
||||
expect(accrualForMonth(false, 12)).toBe(0);
|
||||
});
|
||||
|
||||
it('acquiert le reliquat exact au dernier mois sous plafond', () => {
|
||||
expect(accrualForMonth(false, 11.5)).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('jours fériés légaux', () => {
|
||||
it('calcule Pâques sur des années de référence', async () => {
|
||||
const { easterSunday } = await import('@/domain/absences/holidays');
|
||||
// Une table écrite à la main est juste l'année où on l'écrit et fausse dès
|
||||
// la suivante : trois jours fériés dépendent de cette date.
|
||||
expect(easterSunday(2026).toISOString().slice(0, 10)).toBe('2026-04-05');
|
||||
expect(easterSunday(2027).toISOString().slice(0, 10)).toBe('2027-03-28');
|
||||
expect(easterSunday(2024).toISOString().slice(0, 10)).toBe('2024-03-31');
|
||||
expect(easterSunday(2038).toISOString().slice(0, 10)).toBe('2038-04-25');
|
||||
});
|
||||
|
||||
it('donne onze jours fériés par an', async () => {
|
||||
const { frenchHolidays } = await import('@/domain/absences/holidays');
|
||||
const holidays = frenchHolidays(2026);
|
||||
expect(holidays).toHaveLength(11);
|
||||
expect(holidays.map((entry) => entry.isoDate)).toContain('2026-04-06');
|
||||
expect(holidays.map((entry) => entry.isoDate)).toContain('2026-05-14');
|
||||
expect(holidays.map((entry) => entry.isoDate)).toContain('2026-05-25');
|
||||
});
|
||||
|
||||
it('reste juste l’année suivante', async () => {
|
||||
const { frenchHolidays } = await import('@/domain/absences/holidays');
|
||||
const dates = frenchHolidays(2027).map((entry) => entry.isoDate);
|
||||
expect(dates).toContain('2027-03-29');
|
||||
expect(dates).toContain('2027-05-06');
|
||||
expect(dates).toContain('2027-05-17');
|
||||
});
|
||||
|
||||
it('n’identifie que le 1er mai comme chômé de droit', async () => {
|
||||
const { isLabourDay } = await import('@/domain/absences/holidays');
|
||||
expect(isLabourDay('2027-05-01')).toBe(true);
|
||||
expect(isLabourDay('2027-07-14')).toBe(false);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user