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); }); });