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.
This commit is contained in:
@@ -0,0 +1,263 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user