import { describe, expect, it } from "vitest"; import { TerminArt } from "@prisma/client"; import { icsErzeugen, type IcsTermin } from "@/lib/termin/ics"; /** * Die Kalenderdatei. * * Ein selbst geschriebener Formatierer ist nur so viel wert wie seine Tests: * ein nicht maskiertes Komma zerlegt beim Empfänger den ganzen Eintrag, und * ein fehlendes CRLF lässt manche Kalender die Datei gar nicht erst annehmen. * Beides sieht man der Ausgabe beim Drüberschauen nicht an. */ function termin(felder: Partial = {}): IcsTermin { return { id: "11111111-2222-3333-4444-555555555555", titel: "Übungsabend", beschreibung: null, ort: "Gerätehaus", beginn: new Date(Date.UTC(2026, 8, 15, 17, 30)), ende: new Date(Date.UTC(2026, 8, 15, 19, 30)), ganztaegig: false, art: TerminArt.UEBUNG, abgesagt: false, geaendertAm: new Date(Date.UTC(2026, 8, 1, 10, 0)), ...felder, }; } function erzeugen(termine: IcsTermin[]): string { return icsErzeugen({ name: "FF Musterhausen", termine, adresse: "https://florian.example" }); } describe("icsErzeugen", () => { it("baut ein vollständiges Kalendergerüst", () => { const datei = erzeugen([termin()]); expect(datei).toContain("BEGIN:VCALENDAR"); expect(datei).toContain("VERSION:2.0"); expect(datei).toContain("END:VCALENDAR"); expect(datei).toContain("BEGIN:VEVENT"); expect(datei).toContain("END:VEVENT"); }); /** * RFC 5545 verlangt CRLF. Manche Kalender nehmen es genau — und wer das * versehentlich auf LF ändert, merkt es erst, wenn sich jemand beschwert, * dass sein iPhone die Datei nicht öffnet. */ it("beendet jede Zeile mit CRLF", () => { const datei = erzeugen([termin()]); expect(datei.endsWith("\r\n")).toBe(true); // Kein einziges nacktes LF. expect(datei.replace(/\r\n/g, "")).not.toContain("\n"); }); it("schreibt Zeitstempel in UTC ohne Trennzeichen", () => { const datei = erzeugen([termin()]); expect(datei).toContain("DTSTART:20260915T173000Z"); expect(datei).toContain("DTEND:20260915T193000Z"); expect(datei).toContain("DTSTAMP:20260901T100000Z"); }); it("macht aus einem ganztägigen Termin ein Datum mit ausschließendem Ende", () => { const datei = erzeugen([ termin({ ganztaegig: true, beginn: new Date(Date.UTC(2026, 8, 15, 0, 0)), ende: new Date(Date.UTC(2026, 8, 15, 0, 0)), }), ]); expect(datei).toContain("DTSTART;VALUE=DATE:20260915"); // Das Ende ist ausschließend: ein Tag mehr, sonst fehlt der Termin im // Kalender ganz oder dauert null Tage. expect(datei).toContain("DTEND;VALUE=DATE:20260916"); }); it("maskiert Komma, Semikolon und Backslash im Titel", () => { const datei = erzeugen([termin({ titel: "Übung: Keller, Dach; Hof \\ Halle" })]); expect(datei).toContain("Keller\\, Dach\\; Hof \\\\ Halle"); }); it("macht aus Zeilenumbrüchen in der Beschreibung \\n", () => { const datei = erzeugen([termin({ beschreibung: "Erste Zeile\nZweite Zeile" })]); expect(datei).toContain("Erste Zeile\\nZweite Zeile"); // Der Umbruch darf nicht als echter Zeilenumbruch im Feld landen. expect(datei).not.toContain("DESCRIPTION:Erste Zeile\r\nZweite"); }); /** * Lange Zeilen werden mit einem führenden Leerzeichen fortgesetzt. Ohne das * lehnen strenge Kalender die Datei ab. */ it("bricht Zeilen über 75 Oktette um", () => { const datei = erzeugen([termin({ ort: "A".repeat(200) })]); for (const zeile of datei.split("\r\n")) { // Fortsetzungszeilen beginnen mit einem Leerzeichen und zählen ihr // eigenes Limit — geprüft wird, dass keine Zeile zu lang ist. expect(zeile.length).toBeLessThanOrEqual(75); } expect(datei).toContain("\r\n A"); }); it("kennzeichnet einen abgesagten Termin doppelt", () => { const datei = erzeugen([termin({ abgesagt: true })]); // Einmal maschinenlesbar … expect(datei).toContain("STATUS:CANCELLED"); // … und einmal im Titel, weil viele Kalender den Status ignorieren und // der Termin sonst ganz normal aussieht. expect(datei).toContain("SUMMARY:ABGESAGT: Übungsabend"); }); it("setzt CONFIRMED, solange nichts abgesagt ist", () => { expect(erzeugen([termin()])).toContain("STATUS:CONFIRMED"); }); it("nennt den eigenen Stand in der Beschreibung", () => { expect(erzeugen([termin({ eigenerStand: "ZUGESAGT" })])).toContain("Du bist zugesagt."); expect(erzeugen([termin({ eigenerStand: "WARTELISTE" })])).toContain( "Du stehst auf der Warteliste.", ); }); it("gibt jedem Termin eine eindeutige Kennung", () => { const datei = erzeugen([ termin({ id: "aaaa" }), termin({ id: "bbbb", titel: "Arbeitsdienst" }), ]); expect(datei).toContain("UID:aaaa@florian"); expect(datei).toContain("UID:bbbb@florian"); expect(datei.match(/BEGIN:VEVENT/g)).toHaveLength(2); }); it("kommt mit einem leeren Kalender zurecht", () => { const datei = erzeugen([]); expect(datei).toContain("BEGIN:VCALENDAR"); expect(datei).toContain("END:VCALENDAR"); expect(datei).not.toContain("BEGIN:VEVENT"); }); });