Files
echo-du-huit/ics-categories.test.ts
T
Pierre Martin 3248b28e1f feat: read and write the mairie status carried by CATEGORIES
Two pure modules: statut-mairie.js (string[] -> string[] status logic) and
ics-categories.js, which rewrites CATEGORIES in an iCalendar resource behind a
structural guard: nothing is written if anything but CATEGORIES changed.
2026-08-13 16:50:16 +02:00

264 lines
9.6 KiB
TypeScript

import { describe, expect, test } from "bun:test";
import ICAL from "./extension/vendor/ical.js";
import {
appliquerAuxCategories,
seulesLesCategoriesOntChange,
} from "./extension/ics-categories.js";
import { poserIgnore } from "./extension/statut-mairie.js";
const UID = "db2d146b-8e18-4df6-929f-a651d04245df";
function ressource(corpsVevent: string, extra = "") {
return [
"BEGIN:VCALENDAR",
"VERSION:2.0",
"PRODID:-//IDN nextcloud.com//Calendar app 5.2.2//EN",
extra,
"BEGIN:VEVENT",
`UID:${UID}`,
"DTSTAMP:20250828T095756Z",
"DTSTART;TZID=Europe/Paris:20260907T090000",
"DTEND;TZID=Europe/Paris:20260907T130000",
"SUMMARY:Forum des associations",
corpsVevent,
"END:VEVENT",
"END:VCALENDAR",
"",
]
.filter((ligne) => ligne !== "")
.join("\r\n");
}
const VTIMEZONE = [
"BEGIN:VTIMEZONE",
"TZID:Europe/Paris",
"BEGIN:DAYLIGHT",
"TZOFFSETFROM:+0100",
"TZOFFSETTO:+0200",
"TZNAME:CEST",
"DTSTART:19700329T020000",
"RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU",
"END:DAYLIGHT",
"BEGIN:STANDARD",
"TZOFFSETFROM:+0200",
"TZOFFSETTO:+0100",
"TZNAME:CET",
"DTSTART:19701025T030000",
"RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU",
"END:STANDARD",
"END:VTIMEZONE",
].join("\r\n");
// Relit la ressource produite : on vérifie des VALEURS, jamais du texte brut.
function relire(ics: string) {
const racine = new ICAL.Component(ICAL.parse(ics));
const vevents = racine.getAllSubcomponents("vevent");
const maitre = vevents.find((v) => !v.hasProperty("recurrence-id"));
if (!maitre) throw new Error("VEVENT maître introuvable dans la ressource produite");
return {
racine,
maitre,
exception: vevents.find((v) => v.hasProperty("recurrence-id")),
};
}
function categoriesDe(vevent: any) {
return vevent.getAllProperties("categories").flatMap((p) => p.getValues());
}
const ajouterTag = (categories: string[]) => poserIgnore(categories);
describe("appliquerAuxCategories", () => {
test("CATEGORIES absent → ajouté, le reste de l'événement intact", () => {
const avant = ressource("DESCRIPTION:Venez nombreux\r\nRRULE:FREQ=WEEKLY;COUNT=3");
const res = appliquerAuxCategories(avant, UID, ajouterTag);
expect(res.ok).toBe(true);
const { maitre } = relire(res.ics);
expect(categoriesDe(maitre)).toEqual(["mairie:ignoré"]);
expect(maitre.getFirstPropertyValue("summary")).toBe("Forum des associations");
expect(maitre.getFirstPropertyValue("description")).toBe("Venez nombreux");
expect(maitre.getFirstPropertyValue("dtstart").toString()).toBe("2026-09-07T09:00:00");
expect(maitre.getFirstProperty("dtstart").getParameter("tzid")).toBe("Europe/Paris");
expect(maitre.getFirstPropertyValue("rrule").toString()).toBe("FREQ=WEEKLY;COUNT=3");
});
test("CATEGORIES existant → catégories des bénévoles préservées, tag remplacé", () => {
const avant = ressource("CATEGORIES:Concert,mairie:soumis:2026-08-01");
const res = appliquerAuxCategories(avant, UID, ajouterTag);
expect(res.categories).toEqual(["Concert", "mairie:ignoré"]);
expect(categoriesDe(relire(res.ics).maitre)).toEqual(["Concert", "mairie:ignoré"]);
});
test("deux propriétés CATEGORIES répétées → fusionnées, aucune valeur perdue", () => {
const avant = ressource("CATEGORIES:Concert\r\nCATEGORIES:Jazz");
const res = appliquerAuxCategories(avant, UID, ajouterTag);
const { maitre } = relire(res.ics);
expect(maitre.getAllProperties("categories")).toHaveLength(1);
expect(categoriesDe(maitre)).toEqual(["Concert", "Jazz", "mairie:ignoré"]);
});
test("valeur contenant une virgule → échappement correct au round-trip", () => {
const avant = ressource("CATEGORIES:Concert\\, jazz");
const res = appliquerAuxCategories(avant, UID, ajouterTag);
expect(res.categories).toEqual(["Concert, jazz", "mairie:ignoré"]);
expect(categoriesDe(relire(res.ics).maitre)).toEqual(["Concert, jazz", "mairie:ignoré"]);
});
test("accents fidèles au round-trip", () => {
const avant = ressource("CATEGORIES:Fête de quartier");
const res = appliquerAuxCategories(avant, UID, ajouterTag);
expect(res.ics).toContain("mairie:ignoré");
expect(categoriesDe(relire(res.ics).maitre)).toEqual(["Fête de quartier", "mairie:ignoré"]);
});
test("ligne repliée (> 75 octets) → contenu identique après round-trip", () => {
const longue = "Une description très longue ".repeat(8).trim();
const avant = ressource(`DESCRIPTION:${longue}`);
const res = appliquerAuxCategories(avant, UID, ajouterTag);
expect(res.ok).toBe(true);
expect(relire(res.ics).maitre.getFirstPropertyValue("description")).toBe(longue);
});
test("VTIMEZONE + maître + exception → seul le maître change", () => {
const avant = ressource(
[
"END:VEVENT",
"BEGIN:VEVENT",
`UID:${UID}`,
"RECURRENCE-ID;TZID=Europe/Paris:20260914T090000",
"DTSTAMP:20250828T095756Z",
"DTSTART;TZID=Europe/Paris:20260914T100000",
"SUMMARY:Forum (reporté)",
"CATEGORIES:Exception",
].join("\r\n"),
VTIMEZONE,
);
const res = appliquerAuxCategories(avant, UID, ajouterTag);
expect(res.ok).toBe(true);
const { racine, maitre, exception } = relire(res.ics);
expect(categoriesDe(maitre)).toEqual(["mairie:ignoré"]);
expect(categoriesDe(exception!)).toEqual(["Exception"]);
expect(exception!.getFirstPropertyValue("summary")).toBe("Forum (reporté)");
expect(racine.getAllSubcomponents("vtimezone")).toHaveLength(1);
});
test("transformer rendant [] → propriété CATEGORIES supprimée", () => {
const avant = ressource("CATEGORIES:mairie:ignoré");
const res = appliquerAuxCategories(avant, UID, () => []);
expect(res.categories).toEqual([]);
expect(res.ics).not.toContain("CATEGORIES");
expect(relire(res.ics).maitre.getAllProperties("categories")).toHaveLength(0);
});
test("uid absent de la ressource → vevent-introuvable", () => {
const res = appliquerAuxCategories(ressource("SEQUENCE:1"), "uid-inconnu", ajouterTag);
expect(res).toEqual({ ok: false, erreur: "vevent-introuvable" });
});
test("ICS illisible → ics-illisible, jamais d'exception", () => {
const res = appliquerAuxCategories("BEGIN:VCALENDAR\r\nVERSION:2.0", UID, ajouterTag);
expect(res).toEqual({ ok: false, erreur: "ics-illisible" });
});
// ICAL.parse("") ne lève pas : il rend [] et le composant vide explose plus
// tard. Ces deux cas sont atteignables (calendar-data vide côté serveur).
for (const [nom, ics] of [
["chaîne vide", ""],
["blancs seuls", " \r\n "],
["texte quelconque", "pas du tout un calendrier"],
] as [string, string][]) {
test(`${nom} → ics-illisible, jamais d'exception`, () => {
expect(appliquerAuxCategories(ics, UID, ajouterTag)).toEqual({
ok: false,
erreur: "ics-illisible",
});
});
}
// Formes exotiques rencontrées dans de vrais calendriers : elles doivent
// passer le garde-fou, sinon il bloquerait l'écriture pour rien.
const formesExotiques: [string, string][] = [
["paramètre quoté à virgule", 'ATTENDEE;CN="Doe, John";PARTSTAT=ACCEPTED:mailto:a@b.c'],
["propriété inconnue", "X-BIDULE;FOO=bar:valeur;avec;points-virgules"],
["pièce jointe binaire", "ATTACH;VALUE=BINARY;ENCODING=BASE64:aGVsbG8="],
["EXDATE multiple", "EXDATE;TZID=Europe/Paris:20260914T090000,20260921T090000"],
["accent sur la frontière de repliement", `DESCRIPTION:${"a".repeat(74)}é${"b".repeat(20)}`],
];
for (const [nom, ligne] of formesExotiques) {
test(`${nom} → le garde-fou laisse passer`, () => {
const res = appliquerAuxCategories(ressource(ligne, VTIMEZONE), UID, ajouterTag);
expect(res.ok).toBe(true);
});
}
});
describe("seulesLesCategoriesOntChange", () => {
const base = ressource("DESCRIPTION:Venez nombreux\r\nCATEGORIES:Concert", VTIMEZONE);
test("seul CATEGORIES diffère → true", () => {
const autre = ressource("DESCRIPTION:Venez nombreux\r\nCATEGORIES:Jazz", VTIMEZONE);
expect(seulesLesCategoriesOntChange(base, autre)).toBe(true);
});
test("CATEGORIES supprimé → true", () => {
const autre = ressource("DESCRIPTION:Venez nombreux", VTIMEZONE);
expect(seulesLesCategoriesOntChange(base, autre)).toBe(true);
});
test("repliement et espacement différents → true (comparaison structurelle)", () => {
const replie = base.replace(
"DESCRIPTION:Venez nombreux",
"DESCRIPTION:Venez\r\n nombreux",
);
expect(seulesLesCategoriesOntChange(base, replie)).toBe(true);
});
test("DESCRIPTION différente → false", () => {
const autre = ressource("DESCRIPTION:Annulé\r\nCATEGORIES:Concert", VTIMEZONE);
expect(seulesLesCategoriesOntChange(base, autre)).toBe(false);
});
test("TZID différent → false", () => {
expect(
seulesLesCategoriesOntChange(base, base.replace("TZID=Europe/Paris:20260907", "TZID=Europe/Lisbon:20260907")),
).toBe(false);
});
test("RRULE ajoutée → false", () => {
const autre = ressource(
"DESCRIPTION:Venez nombreux\r\nCATEGORIES:Concert\r\nRRULE:FREQ=WEEKLY",
VTIMEZONE,
);
expect(seulesLesCategoriesOntChange(base, autre)).toBe(false);
});
test("VTIMEZONE modifié → false", () => {
expect(
seulesLesCategoriesOntChange(base, base.replace("TZNAME:CEST", "TZNAME:XXXX")),
).toBe(false);
});
test("ICS illisible d'un côté → false (on n'écrit pas dans le doute)", () => {
expect(seulesLesCategoriesOntChange(base, "n'importe quoi")).toBe(false);
expect(seulesLesCategoriesOntChange(base, "")).toBe(false);
expect(seulesLesCategoriesOntChange("", "")).toBe(false);
});
});