Die gesetzlichen Fristen entstehen von selbst, sobald das Bestellungsdatum in den Stammdaten steht - Vermoegensverzeichnis und Anfangsbericht nach drei Monaten, Jahresbericht und Vermoegensuebersicht zum Ende jedes Betreuungsjahres, Aufwandspauschale, Landespflegegeld, Zuzahlungsbefreiung, Rueckkaufswerte. Zwei Punkte, an denen es sonst schiefgeht: Das Betreuungsjahr endet am Tag VOR dem Jahrestag (§ 188 Abs. 2 BGB), bei Bestellung am 29. Februar am 28. (§ 188 Abs. 3). Ohne diese Regel verrutscht die ganze Kette um einen Tag. Faellt eine Frist auf Samstag, Sonntag oder Feiertag, wird sie NICHT verschoben. § 193 BGB verschiebt zwar den Ablauf, aber als Betreuer will ich frueher fertig sein, nicht spaeter. Stattdessen steht ein Hinweis in der Beschreibung. Bayerische Feiertage inklusive, ueber die Osterformel berechnet. Pflichtfristen tragen bewusst keine RRULE: sonst entstuende beim Erledigen eine Dublette neben der ohnehin abgeleiteten Folgefrist. Beim erneuten Ableiten werden nur offene Fristen nachgezogen, Erledigtes bleibt unangetastet. Der ICS-Feed kommt ohne Token-Feld im Schema aus - die Signatur ist ein HMAC ueber betreuteId und userId. VALARM je Vorwarnstufe, Zeilenfaltung nach RFC 5545. 51 Tests. Etappe: 2 Phase: Alltag
573 lines
21 KiB
TypeScript
573 lines
21 KiB
TypeScript
import { describe, expect, it } from 'vitest'
|
|
import { fristAmpel } from '@/lib/fristen/ampel'
|
|
import {
|
|
alsIsoTag,
|
|
ausIsoTag,
|
|
feiertagName,
|
|
istWochenende,
|
|
nichtWerktagHinweis,
|
|
plusMonate,
|
|
plusTage,
|
|
tag,
|
|
} from '@/lib/fristen/datum'
|
|
import {
|
|
alsIcsDatum,
|
|
icsErzeugen,
|
|
signaturErzeugen,
|
|
signaturPruefen,
|
|
textEscapen,
|
|
zeileFalten,
|
|
} from '@/lib/fristen/ics'
|
|
import {
|
|
betreuungsjahrBeginn,
|
|
betreuungsjahrEnde,
|
|
laufendesBetreuungsjahr,
|
|
pflichtfristenAbleiten,
|
|
type Pflichtfrist,
|
|
} from '@/lib/fristen/pflichtfristen'
|
|
import { naechsteFristAusErledigter, naechsterTermin, rruleGueltig } from '@/lib/fristen/wiederholung'
|
|
|
|
/**
|
|
* Diese Tests beschreiben, was fachlich gelten soll - nicht, wie es
|
|
* programmiert ist. Sie kommen ohne Datenbank aus: geprueft wird die reine
|
|
* Rechenlogik, an der im Betreuungsalltag Geld und Ansprueche haengen.
|
|
*/
|
|
|
|
const STAMMDATEN = {
|
|
befreiterBetreuer: true,
|
|
ehrenamtlich: true,
|
|
pflegegrad: 3,
|
|
}
|
|
|
|
function schluessel(fristen: Pflichtfrist[]): string[] {
|
|
return fristen.map((f) => f.systemSchluessel)
|
|
}
|
|
|
|
function finde(fristen: Pflichtfrist[], gesucht: string): Pflichtfrist {
|
|
const treffer = fristen.find((f) => f.systemSchluessel === gesucht)
|
|
if (!treffer) throw new Error(`Frist "${gesucht}" wurde nicht abgeleitet.`)
|
|
return treffer
|
|
}
|
|
|
|
// -----------------------------------------------------------------------------
|
|
|
|
describe('Betreuungsjahr', () => {
|
|
it('läuft ab dem Tag der Bestellung, nicht ab dem Kalenderjahr', () => {
|
|
const bestellung = tag(2025, 3, 15)
|
|
expect(alsIsoTag(betreuungsjahrBeginn(bestellung, 1))).toBe('2025-03-15')
|
|
expect(alsIsoTag(betreuungsjahrEnde(bestellung, 1))).toBe('2026-03-14')
|
|
expect(alsIsoTag(betreuungsjahrBeginn(bestellung, 2))).toBe('2026-03-15')
|
|
expect(alsIsoTag(betreuungsjahrEnde(bestellung, 2))).toBe('2027-03-14')
|
|
})
|
|
|
|
it('reicht über den Jahreswechsel, ohne auf den 31.12. zu springen', () => {
|
|
const bestellung = tag(2025, 11, 20)
|
|
expect(alsIsoTag(betreuungsjahrEnde(bestellung, 1))).toBe('2026-11-19')
|
|
expect(alsIsoTag(betreuungsjahrBeginn(bestellung, 3))).toBe('2027-11-20')
|
|
expect(alsIsoTag(betreuungsjahrEnde(bestellung, 3))).toBe('2028-11-19')
|
|
})
|
|
|
|
it('endet bei Bestellung am 29. Februar im Folgejahr am 28. Februar', () => {
|
|
// § 188 Abs. 3 BGB: Fehlt der massgebende Tag im letzten Monat, endet
|
|
// die Frist mit dem letzten Tag dieses Monats.
|
|
const bestellung = tag(2024, 2, 29)
|
|
expect(alsIsoTag(betreuungsjahrEnde(bestellung, 1))).toBe('2025-02-28')
|
|
expect(alsIsoTag(betreuungsjahrBeginn(bestellung, 2))).toBe('2025-03-01')
|
|
expect(alsIsoTag(betreuungsjahrEnde(bestellung, 2))).toBe('2026-02-28')
|
|
})
|
|
|
|
it('lässt kein Datum zwischen zwei Betreuungsjahren durchfallen', () => {
|
|
const bestellung = tag(2024, 2, 29)
|
|
for (let n = 1; n <= 8; n++) {
|
|
const ende = betreuungsjahrEnde(bestellung, n)
|
|
const naechsterBeginn = betreuungsjahrBeginn(bestellung, n + 1)
|
|
expect(alsIsoTag(naechsterBeginn)).toBe(alsIsoTag(plusTage(ende, 1)))
|
|
}
|
|
})
|
|
|
|
it('weiß, in welchem Betreuungsjahr ein Tag liegt', () => {
|
|
const bestellung = tag(2025, 3, 15)
|
|
expect(laufendesBetreuungsjahr(bestellung, tag(2025, 3, 15))).toBe(1)
|
|
expect(laufendesBetreuungsjahr(bestellung, tag(2026, 3, 14))).toBe(1)
|
|
expect(laufendesBetreuungsjahr(bestellung, tag(2026, 3, 15))).toBe(2)
|
|
expect(laufendesBetreuungsjahr(bestellung, tag(2030, 1, 1))).toBe(5)
|
|
// Vor der Bestellung gibt es noch kein abgelaufenes Jahr.
|
|
expect(laufendesBetreuungsjahr(bestellung, tag(2024, 1, 1))).toBe(1)
|
|
})
|
|
})
|
|
|
|
// -----------------------------------------------------------------------------
|
|
|
|
describe('Ausschlussfrist der Aufwandspauschale', () => {
|
|
const bestellung = tag(2025, 3, 15)
|
|
|
|
it('verfällt sechs Monate nach Ablauf des Betreuungsjahres', () => {
|
|
// Betreuungsjahr 1: 15.03.2025 bis 14.03.2026. Sechs Monate nach dessen
|
|
// Ablauf ist der 14.09.2026 - § 1878 Abs. 2 BGB.
|
|
const fristen = pflichtfristenAbleiten({
|
|
...STAMMDATEN,
|
|
bestellungAm: bestellung,
|
|
heute: tag(2025, 6, 1),
|
|
})
|
|
const pauschale = finde(fristen, 'aufwandspauschale-2026')
|
|
expect(alsIsoTag(pauschale.datum)).toBe('2026-09-14')
|
|
expect(pauschale.ausschlussfrist).toBe(true)
|
|
expect(pauschale.rechtsgrundlage).toBe('§ 1878 BGB')
|
|
})
|
|
|
|
it('warnt früher vor als eine gewöhnliche Frist', () => {
|
|
const fristen = pflichtfristenAbleiten({
|
|
...STAMMDATEN,
|
|
bestellungAm: bestellung,
|
|
heute: tag(2025, 6, 1),
|
|
})
|
|
expect(finde(fristen, 'aufwandspauschale-2026').vorwarnungTage).toEqual([90, 60, 30, 14, 7, 1])
|
|
expect(finde(fristen, 'jahresbericht-2026').vorwarnungTage).toEqual([60, 30, 7, 1])
|
|
})
|
|
|
|
it('bleibt für das eben abgelaufene Betreuungsjahr stehen, solange sie läuft', () => {
|
|
// Am 01.06.2026 ist das erste Betreuungsjahr vorbei, die Pauschale
|
|
// dafür aber noch bis 14.09.2026 zu holen. Genau dieses Geld darf die
|
|
// Anwendung nicht verschweigen.
|
|
const fristen = pflichtfristenAbleiten({
|
|
...STAMMDATEN,
|
|
bestellungAm: bestellung,
|
|
heute: tag(2026, 6, 1),
|
|
})
|
|
expect(schluessel(fristen)).toContain('aufwandspauschale-2026')
|
|
})
|
|
|
|
it('verschwindet, sobald sie endgültig verfallen ist', () => {
|
|
const fristen = pflichtfristenAbleiten({
|
|
...STAMMDATEN,
|
|
bestellungAm: bestellung,
|
|
heute: tag(2026, 9, 15),
|
|
})
|
|
expect(schluessel(fristen)).not.toContain('aufwandspauschale-2026')
|
|
})
|
|
|
|
it('entsteht gar nicht, wenn die Betreuung berufsmäßig geführt wird', () => {
|
|
const fristen = pflichtfristenAbleiten({
|
|
...STAMMDATEN,
|
|
ehrenamtlich: false,
|
|
bestellungAm: bestellung,
|
|
heute: tag(2025, 6, 1),
|
|
})
|
|
expect(schluessel(fristen).filter((s) => s.startsWith('aufwandspauschale'))).toHaveLength(0)
|
|
})
|
|
})
|
|
|
|
// -----------------------------------------------------------------------------
|
|
|
|
describe('Ableitung der Pflichtfristen', () => {
|
|
const bestellung = tag(2025, 3, 15)
|
|
const heute = tag(2025, 6, 1)
|
|
|
|
it('bringt Vermögensverzeichnis und Anfangsbericht drei Monate nach der Bestellung', () => {
|
|
const fristen = pflichtfristenAbleiten({ ...STAMMDATEN, bestellungAm: bestellung, heute })
|
|
expect(alsIsoTag(finde(fristen, 'vermoegensverzeichnis').datum)).toBe('2025-06-15')
|
|
expect(alsIsoTag(finde(fristen, 'anfangsbericht').datum)).toBe('2025-06-15')
|
|
expect(finde(fristen, 'anfangsbericht').rechtsgrundlage).toBe('§ 1863 Abs. 1 BGB')
|
|
})
|
|
|
|
it('liefert bei gleichen Stammdaten zweimal genau dasselbe (Idempotenz)', () => {
|
|
const eingabe = { ...STAMMDATEN, bestellungAm: bestellung, heute }
|
|
const ersteLauf = pflichtfristenAbleiten(eingabe)
|
|
const zweiterLauf = pflichtfristenAbleiten(eingabe)
|
|
|
|
expect(schluessel(zweiterLauf)).toEqual(schluessel(ersteLauf))
|
|
expect(zweiterLauf.map((f) => alsIsoTag(f.datum))).toEqual(
|
|
ersteLauf.map((f) => alsIsoTag(f.datum)),
|
|
)
|
|
})
|
|
|
|
it('vergibt jeden Systemschlüssel nur einmal — sonst gäbe es Dubletten', () => {
|
|
const fristen = pflichtfristenAbleiten({ ...STAMMDATEN, bestellungAm: bestellung, heute })
|
|
expect(new Set(schluessel(fristen)).size).toBe(fristen.length)
|
|
})
|
|
|
|
it('erzeugt standardmäßig drei Jahre im Voraus', () => {
|
|
const fristen = pflichtfristenAbleiten({ ...STAMMDATEN, bestellungAm: bestellung, heute })
|
|
expect(schluessel(fristen).filter((s) => s.startsWith('jahresbericht-'))).toHaveLength(3)
|
|
expect(schluessel(fristen).filter((s) => s.startsWith('landespflegegeld-'))).toHaveLength(3)
|
|
})
|
|
|
|
it('richtet sich nach dem Parameter jahreVoraus', () => {
|
|
const fristen = pflichtfristenAbleiten({
|
|
...STAMMDATEN,
|
|
bestellungAm: bestellung,
|
|
heute,
|
|
jahreVoraus: 1,
|
|
})
|
|
expect(schluessel(fristen).filter((s) => s.startsWith('jahresbericht-'))).toHaveLength(1)
|
|
})
|
|
|
|
it('leitet ohne Bestellungsdatum gar nichts ab', () => {
|
|
expect(pflichtfristenAbleiten({ ...STAMMDATEN, bestellungAm: null, heute })).toEqual([])
|
|
})
|
|
|
|
it('lässt Landespflegegeld unter Pflegegrad 2 weg', () => {
|
|
const mitPflegegrad = pflichtfristenAbleiten({
|
|
...STAMMDATEN,
|
|
pflegegrad: 2,
|
|
bestellungAm: bestellung,
|
|
heute,
|
|
})
|
|
const ohne = pflichtfristenAbleiten({
|
|
...STAMMDATEN,
|
|
pflegegrad: 1,
|
|
bestellungAm: bestellung,
|
|
heute,
|
|
})
|
|
expect(schluessel(mitPflegegrad).some((s) => s.startsWith('landespflegegeld-'))).toBe(true)
|
|
expect(schluessel(ohne).some((s) => s.startsWith('landespflegegeld-'))).toBe(false)
|
|
})
|
|
|
|
it('nennt beim befreiten Betreuer die Vermögensübersicht, sonst die Rechnungslegung', () => {
|
|
const befreit = pflichtfristenAbleiten({ ...STAMMDATEN, bestellungAm: bestellung, heute })
|
|
const voll = pflichtfristenAbleiten({
|
|
...STAMMDATEN,
|
|
befreiterBetreuer: false,
|
|
bestellungAm: bestellung,
|
|
heute,
|
|
})
|
|
expect(finde(befreit, 'vermoegensuebersicht-2026').rechtsgrundlage).toBe('§ 1859 Abs. 1 BGB')
|
|
expect(finde(voll, 'vermoegensuebersicht-2026').rechtsgrundlage).toBe('§ 1865 BGB')
|
|
// Der Schluessel bleibt gleich, damit ein Wechsel der Einstufung keine
|
|
// zweite Frist neben der bestehenden erzeugt.
|
|
expect(finde(voll, 'vermoegensuebersicht-2026').titel).toContain('Rechnungslegung')
|
|
})
|
|
|
|
it('weist auf Stichtage hin, die auf ein Wochenende fallen — ohne sie zu verschieben', () => {
|
|
// Der 14.09.2025 ist ein Sonntag; Bestellung am 14.06.2025 legt den
|
|
// Anfangsbericht genau dorthin.
|
|
const fristen = pflichtfristenAbleiten({
|
|
...STAMMDATEN,
|
|
bestellungAm: tag(2025, 6, 14),
|
|
heute: tag(2025, 6, 20),
|
|
})
|
|
const bericht = finde(fristen, 'anfangsbericht')
|
|
expect(alsIsoTag(bericht.datum)).toBe('2025-09-14')
|
|
expect(istWochenende(bericht.datum)).toBe(true)
|
|
expect(bericht.beschreibung).toContain('Sonntag')
|
|
expect(bericht.beschreibung).toContain('§ 193 BGB')
|
|
})
|
|
|
|
it('ist chronologisch sortiert', () => {
|
|
const fristen = pflichtfristenAbleiten({ ...STAMMDATEN, bestellungAm: bestellung, heute })
|
|
const daten = fristen.map((f) => f.datum.getTime())
|
|
expect([...daten].sort((a, b) => a - b)).toEqual(daten)
|
|
})
|
|
})
|
|
|
|
// -----------------------------------------------------------------------------
|
|
|
|
describe('Wiederkehrende Fristen', () => {
|
|
it('findet den nächsten Jahrestermin', () => {
|
|
const naechster = naechsterTermin('FREQ=YEARLY;INTERVAL=1', tag(2026, 3, 14))
|
|
expect(alsIsoTag(naechster!)).toBe('2027-03-14')
|
|
})
|
|
|
|
it('gibt nie den Tag zurück, der gerade erledigt wurde', () => {
|
|
const naechster = naechsterTermin('FREQ=MONTHLY;INTERVAL=1', tag(2026, 1, 31))
|
|
expect(alsIsoTag(naechster!)).not.toBe('2026-01-31')
|
|
// Der Februar hat keinen 31. - die naechste Wiederholung ist der 31.03.
|
|
expect(alsIsoTag(naechster!)).toBe('2026-03-31')
|
|
})
|
|
|
|
it('rechnet vierteljährlich in Dreimonatsschritten', () => {
|
|
expect(alsIsoTag(naechsterTermin('FREQ=MONTHLY;INTERVAL=3', tag(2026, 2, 10))!)).toBe(
|
|
'2026-05-10',
|
|
)
|
|
})
|
|
|
|
it('endet, wenn die Regel erschöpft ist', () => {
|
|
expect(naechsterTermin('FREQ=YEARLY;COUNT=1', tag(2026, 3, 14))).toBeNull()
|
|
expect(naechsterTermin('FREQ=MONTHLY;UNTIL=20260201T000000Z', tag(2026, 1, 15))).toBeNull()
|
|
})
|
|
|
|
it('erkennt unbrauchbare Regeln, statt sie stillschweigend zu schlucken', () => {
|
|
expect(rruleGueltig('FREQ=YEARLY;INTERVAL=1')).toBe(true)
|
|
expect(rruleGueltig('jeden Montag')).toBe(false)
|
|
expect(naechsterTermin('Unsinn', tag(2026, 1, 1))).toBeNull()
|
|
})
|
|
|
|
it('verkettet die Nachfolgefrist mit der erledigten', () => {
|
|
const nachfolger = naechsteFristAusErledigter({
|
|
id: 'frist-1',
|
|
betreuteId: 'betreute-1',
|
|
vorgangId: 'vorgang-9',
|
|
titel: 'Kontoauszüge holen',
|
|
beschreibung: 'Bei der Sparkasse abholen.',
|
|
datum: tag(2026, 1, 15),
|
|
typ: 'WIEDERVORLAGE',
|
|
rechtsgrundlage: null,
|
|
vorwarnungTage: [7, 1],
|
|
wiederholungRrule: 'FREQ=MONTHLY;INTERVAL=1',
|
|
ausschlussfrist: false,
|
|
})
|
|
|
|
expect(nachfolger).not.toBeNull()
|
|
expect(nachfolger!.vorgaengerFristId).toBe('frist-1')
|
|
expect(alsIsoTag(nachfolger!.datum)).toBe('2026-02-15')
|
|
expect(nachfolger!.titel).toBe('Kontoauszüge holen')
|
|
expect(nachfolger!.vorgangId).toBe('vorgang-9')
|
|
expect(nachfolger!.wiederholungRrule).toBe('FREQ=MONTHLY;INTERVAL=1')
|
|
})
|
|
|
|
it('rechnet ab dem Solltermin, nicht ab dem Tag der Erledigung', () => {
|
|
// Wer drei Tage zu spaet abhakt, soll nicht dauerhaft nachrutschen.
|
|
const nachfolger = naechsteFristAusErledigter({
|
|
id: 'frist-2',
|
|
betreuteId: 'betreute-1',
|
|
titel: 'Heimkosten prüfen',
|
|
datum: tag(2026, 1, 1),
|
|
typ: 'WIEDERVORLAGE',
|
|
vorwarnungTage: [7],
|
|
wiederholungRrule: 'FREQ=MONTHLY;INTERVAL=1',
|
|
ausschlussfrist: false,
|
|
})
|
|
expect(alsIsoTag(nachfolger!.datum)).toBe('2026-02-01')
|
|
})
|
|
|
|
it('legt ohne Wiederholungsregel keine Nachfolgefrist an', () => {
|
|
const nachfolger = naechsteFristAusErledigter({
|
|
id: 'frist-3',
|
|
betreuteId: 'betreute-1',
|
|
titel: 'Einmalige Sache',
|
|
datum: tag(2026, 1, 1),
|
|
typ: 'SELBST_GESETZT',
|
|
vorwarnungTage: [],
|
|
wiederholungRrule: null,
|
|
ausschlussfrist: false,
|
|
})
|
|
expect(nachfolger).toBeNull()
|
|
})
|
|
})
|
|
|
|
// -----------------------------------------------------------------------------
|
|
|
|
describe('Fristenampel', () => {
|
|
const heute = tag(2026, 5, 20)
|
|
|
|
it('färbt Überfälliges rot und sagt seit wann', () => {
|
|
const gestern = fristAmpel(tag(2026, 5, 19), 'OFFEN', heute)
|
|
expect(gestern.stufe).toBe('ueberfaellig')
|
|
expect(gestern.variante).toBe('rot')
|
|
expect(gestern.text).toBe('Überfällig seit gestern')
|
|
|
|
const vorheriger = fristAmpel(tag(2026, 5, 10), 'OFFEN', heute)
|
|
expect(vorheriger.text).toBe('Überfällig seit 10 Tagen')
|
|
})
|
|
|
|
it('zählt heute und morgen zur laufenden Woche', () => {
|
|
expect(fristAmpel(heute, 'OFFEN', heute)).toMatchObject({
|
|
stufe: 'diese_woche',
|
|
variante: 'orange',
|
|
text: 'Heute fällig',
|
|
})
|
|
expect(fristAmpel(tag(2026, 5, 21), 'OFFEN', heute)).toMatchObject({
|
|
stufe: 'diese_woche',
|
|
text: 'Morgen fällig',
|
|
})
|
|
})
|
|
|
|
it('trennt „diese Woche“ von „dieser Monat“ genau nach sieben Tagen', () => {
|
|
const siebter = fristAmpel(tag(2026, 5, 27), 'OFFEN', heute)
|
|
const achter = fristAmpel(tag(2026, 5, 28), 'OFFEN', heute)
|
|
|
|
expect(siebter.tage).toBe(7)
|
|
expect(siebter.stufe).toBe('diese_woche')
|
|
expect(siebter.variante).toBe('orange')
|
|
|
|
expect(achter.tage).toBe(8)
|
|
expect(achter.stufe).toBe('dieser_monat')
|
|
expect(achter.variante).toBe('gelb')
|
|
})
|
|
|
|
it('schaltet nach 31 Tagen auf „später“', () => {
|
|
expect(fristAmpel(tag(2026, 6, 20), 'OFFEN', heute).stufe).toBe('dieser_monat')
|
|
expect(fristAmpel(tag(2026, 6, 21), 'OFFEN', heute).stufe).toBe('spaeter')
|
|
expect(fristAmpel(tag(2026, 6, 21), 'OFFEN', heute).variante).toBe('gruen')
|
|
})
|
|
|
|
it('nimmt Abgeschlossenes aus der Ampel heraus', () => {
|
|
for (const status of ['ERLEDIGT', 'VERSCHOBEN', 'ENTFALLEN'] as const) {
|
|
const ampel = fristAmpel(tag(2026, 1, 1), status, heute)
|
|
expect(ampel.stufe).toBe('erledigt')
|
|
expect(ampel.variante).toBe('secondary')
|
|
}
|
|
})
|
|
|
|
it('liefert zu jeder Stufe einen Text — Farbe allein ist keine Information', () => {
|
|
const tage = [-30, -1, 0, 1, 5, 7, 8, 20, 31, 32, 400]
|
|
for (const versatz of tage) {
|
|
const ampel = fristAmpel(plusTage(heute, versatz), 'OFFEN', heute)
|
|
expect(ampel.text.length).toBeGreaterThan(3)
|
|
}
|
|
})
|
|
})
|
|
|
|
// -----------------------------------------------------------------------------
|
|
|
|
describe('Tagesdatum und Feiertage', () => {
|
|
it('rechnet Monatsschritte kalendarisch, nicht in 30 Tagen', () => {
|
|
expect(alsIsoTag(plusMonate(tag(2026, 1, 31), 1))).toBe('2026-02-28')
|
|
expect(alsIsoTag(plusMonate(tag(2024, 1, 31), 1))).toBe('2024-02-29')
|
|
expect(alsIsoTag(plusMonate(tag(2026, 3, 15), 6))).toBe('2026-09-15')
|
|
})
|
|
|
|
it('liest und schreibt ISO-Tagesdaten verlustfrei', () => {
|
|
expect(alsIsoTag(ausIsoTag('2026-09-14')!)).toBe('2026-09-14')
|
|
expect(ausIsoTag('2026-02-31')).toBeNull()
|
|
expect(ausIsoTag('14.09.2026')).toBeNull()
|
|
expect(ausIsoTag('')).toBeNull()
|
|
})
|
|
|
|
it('kennt die beweglichen bayerischen Feiertage', () => {
|
|
// Ostersonntag 2026 ist der 05.04.
|
|
expect(feiertagName(tag(2026, 4, 3))).toBe('Karfreitag')
|
|
expect(feiertagName(tag(2026, 4, 6))).toBe('Ostermontag')
|
|
expect(feiertagName(tag(2026, 5, 14))).toBe('Christi Himmelfahrt')
|
|
expect(feiertagName(tag(2026, 6, 4))).toBe('Fronleichnam')
|
|
expect(feiertagName(tag(2026, 1, 6))).toBe('Heilige Drei Könige')
|
|
expect(feiertagName(tag(2026, 7, 7))).toBeNull()
|
|
})
|
|
|
|
it('warnt vor arbeitsfreien Stichtagen, ohne das Datum zu ändern', () => {
|
|
const hinweis = nichtWerktagHinweis(tag(2026, 4, 3))
|
|
expect(hinweis).toContain('Karfreitag')
|
|
expect(hinweis).toContain('§ 193 BGB')
|
|
expect(nichtWerktagHinweis(tag(2026, 7, 7))).toBeNull()
|
|
})
|
|
})
|
|
|
|
// -----------------------------------------------------------------------------
|
|
|
|
describe('ICS-Kalender', () => {
|
|
const frist = {
|
|
id: 'abc123',
|
|
titel: 'Jahresbericht 2026 an das Betreuungsgericht',
|
|
beschreibung: 'Bericht über das Betreuungsjahr; mit Vermögensübersicht, Belegen.',
|
|
datum: tag(2026, 3, 14),
|
|
rechtsgrundlage: '§ 1863 Abs. 3 BGB',
|
|
typ: 'GESETZLICH',
|
|
ausschlussfrist: false,
|
|
vorwarnungTage: [60, 30, 7, 1],
|
|
}
|
|
|
|
const kalender = () =>
|
|
icsErzeugen({
|
|
kalendername: 'Betreuung EM — Fristen',
|
|
fristen: [frist],
|
|
jetzt: new Date(Date.UTC(2026, 1, 1, 10, 15, 0)),
|
|
})
|
|
|
|
it('faltet keine Zeile über 75 Oktette hinaus', () => {
|
|
const lang =
|
|
'SUMMARY:Vermögensübersicht 2027 einreichen — Kontostände, Rückkaufswerte, ' +
|
|
'Verbindlichkeiten, jeweils mit Nachweis für das Betreuungsgericht'
|
|
const gefaltet = zeileFalten(lang)
|
|
|
|
for (const zeile of gefaltet.split('\r\n')) {
|
|
expect(Buffer.byteLength(zeile, 'utf8')).toBeLessThanOrEqual(75)
|
|
}
|
|
// Jede Folgezeile beginnt mit genau einem Leerzeichen.
|
|
for (const zeile of gefaltet.split('\r\n').slice(1)) {
|
|
expect(zeile.startsWith(' ')).toBe(true)
|
|
}
|
|
// Entfaltet muss wieder das Original herauskommen.
|
|
expect(gefaltet.split('\r\n ').join('')).toBe(lang)
|
|
})
|
|
|
|
it('zerschneidet keine Umlaute beim Falten', () => {
|
|
const zeile = `DESCRIPTION:${'ä'.repeat(120)}`
|
|
const gefaltet = zeileFalten(zeile)
|
|
expect(gefaltet).not.toContain('�')
|
|
expect(gefaltet.split('\r\n ').join('')).toBe(zeile)
|
|
for (const teil of gefaltet.split('\r\n')) {
|
|
expect(Buffer.byteLength(teil, 'utf8')).toBeLessThanOrEqual(75)
|
|
}
|
|
})
|
|
|
|
it('lässt kurze Zeilen unangetastet', () => {
|
|
expect(zeileFalten('VERSION:2.0')).toBe('VERSION:2.0')
|
|
})
|
|
|
|
it('maskiert Sonderzeichen nach RFC 5545', () => {
|
|
expect(textEscapen('a;b,c\\d')).toBe('a\\;b\\,c\\\\d')
|
|
expect(textEscapen('Zeile 1\nZeile 2')).toBe('Zeile 1\\nZeile 2')
|
|
})
|
|
|
|
it('baut ein gültiges Gerüst mit CRLF-Zeilenenden', () => {
|
|
const ics = kalender()
|
|
expect(ics.startsWith('BEGIN:VCALENDAR\r\n')).toBe(true)
|
|
expect(ics.endsWith('END:VCALENDAR\r\n')).toBe(true)
|
|
expect(ics).toContain('VERSION:2.0')
|
|
// Kein nacktes Zeilenende ohne Wagenruecklauf.
|
|
expect(/[^\r]\n/.test(ics)).toBe(false)
|
|
})
|
|
|
|
it('schreibt ganztägige Termine mit Ende am Folgetag', () => {
|
|
const ics = kalender()
|
|
expect(ics).toContain('DTSTART;VALUE=DATE:20260314')
|
|
expect(ics).toContain('DTEND;VALUE=DATE:20260315')
|
|
expect(alsIcsDatum(tag(2026, 3, 14))).toBe('20260314')
|
|
})
|
|
|
|
it('macht aus jeder Vorwarnstufe einen Wecker', () => {
|
|
const ics = kalender()
|
|
for (const tage of [60, 30, 7, 1]) {
|
|
expect(ics).toContain(`TRIGGER;RELATED=START:-P${tage}D`)
|
|
}
|
|
expect(ics).not.toContain('-P0D')
|
|
})
|
|
|
|
it('markiert Ausschlussfristen schon im Titel', () => {
|
|
const ics = icsErzeugen({
|
|
kalendername: 'Test',
|
|
fristen: [{ ...frist, ausschlussfrist: true }],
|
|
jetzt: new Date(Date.UTC(2026, 1, 1)),
|
|
})
|
|
expect(ics).toContain('Ausschlussfrist!')
|
|
expect(ics).toContain('PRIORITY:1')
|
|
})
|
|
})
|
|
|
|
// -----------------------------------------------------------------------------
|
|
|
|
describe('Signatur des Kalender-Abonnements', () => {
|
|
it('lässt sich erzeugen und wieder auflösen', () => {
|
|
const signatur = signaturErzeugen('betreute-1', 'user-1')
|
|
expect(signaturPruefen(signatur)).toEqual({ betreuteId: 'betreute-1', userId: 'user-1' })
|
|
})
|
|
|
|
it('enthält die Kennungen nicht im Klartext in der Adresse', () => {
|
|
expect(signaturErzeugen('betreute-1', 'user-1')).not.toContain('betreute-1')
|
|
})
|
|
|
|
it('weist eine veränderte Signatur ab', () => {
|
|
const echt = signaturErzeugen('betreute-1', 'user-1')
|
|
const gefaelscht = Buffer.from('betreute-2.user-1.irgendwas', 'utf8').toString('base64url')
|
|
|
|
expect(signaturPruefen(gefaelscht)).toBeNull()
|
|
expect(signaturPruefen(`${echt}xyz`)).toBeNull()
|
|
expect(signaturPruefen('kein-base64url-inhalt')).toBeNull()
|
|
})
|
|
|
|
it('gilt nur für genau dieses Paar aus Person und Benutzer', () => {
|
|
const fuerA = signaturErzeugen('betreute-1', 'user-1')
|
|
const fuerB = signaturErzeugen('betreute-1', 'user-2')
|
|
expect(fuerA).not.toBe(fuerB)
|
|
expect(signaturPruefen(fuerB)!.userId).toBe('user-2')
|
|
})
|
|
|
|
it('verträgt die Endung .ics an der Adresse', () => {
|
|
const signatur = signaturErzeugen('betreute-1', 'user-1')
|
|
expect(signaturPruefen(`${signatur}.ics`)).toEqual({
|
|
betreuteId: 'betreute-1',
|
|
userId: 'user-1',
|
|
})
|
|
})
|
|
})
|