Rotatory anlegen: Morning-Meeting-Moderation auslosen und nach Nextcloud schreiben

Express-App mit JSON-Speicher unter VCH_DATA_DIR, Monatskalender mit Fotos,
Personen mit Rollen und Teilzeit-Freitagen, Abwesenheiten, Feiertage je
Bundesland, faire Zufallsauslosung mit Fixieren und Neu-Auslosen.

Nextcloud: CalDAV-Discovery, Zielkalender wählbar, Termine je Werktag
schreiben und aktualisieren, Abwesenheiten aus Kalender importieren,
Personen samt Fotos aus dem Systemadressbuch übernehmen.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
ferdi2goandClaude Fable 5.1 committed 2026-09-16 06:39:37 +00:00
commit 95756778d8
26 files changed
+4697

No files matched your search

+22
View File
@@ -0,0 +1,22 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { loadEnvFile } from '../server/env.js';
test('.env laden ohne bestehende Variablen zu überschreiben', () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'rot-'));
const file = path.join(dir, '.env');
fs.writeFileSync(file, '# Kommentar\nROT_A=eins\nROT_B="zwei drei" \nROT_C=vier # optional\nexport ROT_D=\'fünf\'\nROT_E=\nROT_F= # optional Erklärung\nROT_G=wert # Kommentar\n');
process.env.ROT_A = 'vorher';
assert.equal(loadEnvFile(file), true);
assert.equal(process.env.ROT_A, 'vorher');
assert.equal(process.env.ROT_B, 'zwei drei');
assert.equal(process.env.ROT_C, 'vier');
assert.equal(process.env.ROT_D, 'fünf');
assert.equal(process.env.ROT_E, '');
assert.equal(process.env.ROT_F, '', 'Kommentar nach leerem Wert ist kein Wert');
assert.equal(process.env.ROT_G, 'wert');
assert.equal(loadEnvFile(path.join(dir, 'fehlt')), false);
});
+31
View File
@@ -0,0 +1,31 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { easterSunday, germanHolidays } from '../server/holidays.js';
test('Ostersonntag bekannter Jahre', () => {
assert.equal(easterSunday(2024), '2024-03-31');
assert.equal(easterSunday(2025), '2025-04-20');
assert.equal(easterSunday(2026), '2026-04-05');
assert.equal(easterSunday(2027), '2027-03-28');
});
test('bundesweite Feiertage 2026', () => {
const dates = Object.fromEntries(germanHolidays(2026).map((h) => [h.name, h.date]));
assert.equal(dates['Karfreitag'], '2026-04-03');
assert.equal(dates['Ostermontag'], '2026-04-06');
assert.equal(dates['Christi Himmelfahrt'], '2026-05-14');
assert.equal(dates['Pfingstmontag'], '2026-05-25');
assert.equal(dates['Tag der Deutschen Einheit'], '2026-10-03');
assert.equal(Object.keys(dates).length, 9);
assert.ok(!('Fronleichnam' in dates));
});
test('Landesfeiertage nur im passenden Bundesland', () => {
const nw = germanHolidays(2026, 'NW').map((h) => h.name);
assert.ok(nw.includes('Fronleichnam'));
assert.ok(nw.includes('Allerheiligen'));
assert.ok(!nw.includes('Reformationstag'));
const sn = Object.fromEntries(germanHolidays(2026, 'SN').map((h) => [h.name, h.date]));
assert.equal(sn['Buß- und Bettag'], '2026-11-18');
assert.ok('Reformationstag' in sn);
});
+114
View File
@@ -0,0 +1,114 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { unescapeText, unfold, parseLine, parseEvents, parseVCards, parseAbsenceSummary, matchPerson, mergeAbsences, toLocalDate } from '../server/ical.js';
test('unfold und parseLine', () => {
const lines = unfold('SUMMARY:Ein sehr\r\n langer Titel\r\nDTSTART;TZID=Europe/Berlin:20261005T093000\r\n');
assert.deepEqual(lines, ['SUMMARY:Ein sehr langer Titel', 'DTSTART;TZID=Europe/Berlin:20261005T093000']);
const p = parseLine('ATTENDEE;CN="Max, Mustermann";ROLE=REQ-PARTICIPANT:mailto:max@example.de');
assert.equal(p.name, 'ATTENDEE');
assert.equal(p.params.CN, 'Max, Mustermann');
assert.equal(p.value, 'mailto:max@example.de');
});
test('unescapeText löst \\; \\, \\n und \\\\ auf', () => {
assert.equal(unescapeText('a\\;b\\,c\\nd\\\\e'), 'a;b,c\nd\\e');
assert.equal(unescapeText('x\\\\ny'), 'x\\ny', 'maskierter Backslash gefolgt von n ist kein Zeilenumbruch');
});
test('toLocalDate: Datum, UTC nach Berlin, Wandzeit', () => {
assert.deepEqual(toLocalDate('20261005', { VALUE: 'DATE' }).date, '2026-10-05');
assert.equal(toLocalDate('20261005T220000Z').date, '2026-10-06'); // 22:00 UTC = 00:00 MESZ am Folgetag
assert.equal(toLocalDate('20261205T230000Z').date, '2026-12-06'); // Winterzeit
assert.equal(toLocalDate('20261005T093000', { TZID: 'Europe/Berlin' }).date, '2026-10-05');
assert.equal(toLocalDate('kaputt'), null);
});
const ICS = (body) => `BEGIN:VCALENDAR\r\nVERSION:2.0\r\n${body}\r\nEND:VCALENDAR\r\n`;
test('ganztägige Urlaubswoche: DTEND exklusiv', () => {
const [ev] = parseEvents(ICS('BEGIN:VEVENT\r\nUID:a\r\nSUMMARY:Stefan Frühmorgen (Urlaub)\r\nDTSTART;VALUE=DATE:20260914\r\nDTEND;VALUE=DATE:20260919\r\nEND:VEVENT'));
assert.equal(ev.from, '2026-09-14');
assert.equal(ev.to, '2026-09-18');
assert.equal(ev.allDay, true);
assert.equal(ev.summary, 'Stefan Frühmorgen (Urlaub)');
});
test('Termin mit Uhrzeit, Termin bis Mitternacht, DURATION, mehrere VEVENTs', () => {
const evs = parseEvents(ICS([
'BEGIN:VEVENT\r\nUID:b\r\nSUMMARY:Libor Herudek (Urlaub)\r\nDTSTART;TZID=Europe/Berlin:20260918T120000\r\nDTEND;TZID=Europe/Berlin:20260918T160000\r\nEND:VEVENT',
'BEGIN:VEVENT\r\nUID:c\r\nSUMMARY:X (krank)\r\nDTSTART:20260917T220000Z\r\nDTEND:20260918T220000Z\r\nEND:VEVENT',
'BEGIN:VEVENT\r\nUID:d\r\nSUMMARY:Y (frei)\r\nDTSTART;VALUE=DATE:20260921\r\nDURATION:P2D\r\nEND:VEVENT',
'BEGIN:VEVENT\r\nUID:e\r\nSUMMARY:Z\r\nDTSTART;VALUE=DATE:20260922\r\nEND:VEVENT',
].join('\r\n')));
assert.equal(evs.length, 4);
assert.deepEqual([evs[0].from, evs[0].to], ['2026-09-18', '2026-09-18']);
assert.deepEqual([evs[1].from, evs[1].to], ['2026-09-18', '2026-09-18']); // 00:00–24:00 MESZ
assert.deepEqual([evs[2].from, evs[2].to], ['2026-09-21', '2026-09-22']);
assert.deepEqual([evs[3].from, evs[3].to], ['2026-09-22', '2026-09-22']);
});
test('RRULE und RECURRENCE-ID werden durchgereicht, Alarme stören nicht', () => {
const [ev] = parseEvents(ICS('BEGIN:VEVENT\r\nUID:r\r\nSUMMARY:A (frei)\r\nDTSTART;VALUE=DATE:20260914\r\nRRULE:FREQ=WEEKLY;BYDAY=MO\r\nBEGIN:VALARM\r\nTRIGGER:-PT15M\r\nACTION:DISPLAY\r\nDESCRIPTION:x\r\nEND:VALARM\r\nEND:VEVENT'));
assert.equal(ev.rrule, 'FREQ=WEEKLY;BYDAY=MO');
assert.equal(ev.description, '');
});
test('vCard 3.0 mit Base64-Foto und vCard 4.0 mit data-URI', () => {
const v3 = 'BEGIN:VCARD\r\nVERSION:3.0\r\nUID:ferdinand\r\nFN:Ferdinand Urban\r\nN:Urban;Ferdinand;;;\r\nEMAIL;TYPE=INTERNET:urban@team-tradeo.de\r\nPHOTO;ENCODING=b;TYPE=JPEG:/9j/4AAQ\r\n SkZJRg==\r\nCLOUD:ferdinand@cld2.tradeo.de\r\nEND:VCARD\r\n';
const [c] = parseVCards(v3);
assert.equal(c.uid, 'ferdinand');
assert.equal(c.firstName, 'Ferdinand');
assert.equal(c.lastName, 'Urban');
assert.equal(c.email, 'urban@team-tradeo.de');
assert.deepEqual(c.photo, { kind: 'inline', mime: 'image/jpeg', base64: '/9j/4AAQSkZJRg==' });
const v4 = 'BEGIN:VCARD\r\nVERSION:4.0\r\nUID:x\r\nFN:Anna Maria Ciesla\r\nPHOTO:data:image/png;base64,iVBORw0KGgo=\r\nEND:VCARD\r\n';
const [d] = parseVCards(v4);
assert.equal(d.firstName, 'Anna Maria');
assert.equal(d.lastName, 'Ciesla');
assert.equal(d.photo.mime, 'image/png');
assert.equal(d.photo.base64, 'iVBORw0KGgo=');
const v4url = 'BEGIN:VCARD\r\nVERSION:4.0\r\nUID:y\r\nFN:Solo\r\nPHOTO;VALUE=URI:https://cloud.example.de/avatar.png\r\nEND:VCARD\r\n';
const [e] = parseVCards(v4url);
assert.equal(e.lastName, 'Solo');
assert.equal(e.firstName, '');
assert.deepEqual(e.photo, { kind: 'url', url: 'https://cloud.example.de/avatar.png' });
});
test('Abwesenheitstitel deuten', () => {
assert.deepEqual(parseAbsenceSummary('Stefan Frühmorgen (Urlaub)'), { name: 'Stefan Frühmorgen', kind: 'urlaub', raw: 'Urlaub' });
assert.equal(parseAbsenceSummary('Carmine Mastromarino (krank)').kind, 'krankheit');
assert.equal(parseAbsenceSummary('Alex Koop (frei)').kind, 'frei');
assert.equal(parseAbsenceSummary('Hans Holzmann [Home Office]').kind, 'frei');
assert.deepEqual(parseAbsenceSummary('Nur Name'), { name: 'Nur Name', kind: 'sonstiges', raw: '' });
});
test('Namen zuordnen: Vor+Nach, Nach+Vor, Umlaute, eindeutiger Nachname', () => {
const persons = [
{ id: '1', firstName: 'Stefan', lastName: 'Frühmorgen' },
{ id: '2', firstName: 'Michael', lastName: 'Lotter' },
{ id: '3', firstName: 'Michael', lastName: 'Schuster' },
];
assert.equal(matchPerson('Stefan Frühmorgen', persons).id, '1');
assert.equal(matchPerson('Fruehmorgen, Stefan', persons).id, '1');
assert.equal(matchPerson('FRÜHMORGEN STEFAN', persons).id, '1');
assert.equal(matchPerson('Stefan Fru\u0308hmorgen', persons).id, '1', 'NFD-Umlaut (u + Trema)');
assert.equal(matchPerson('Lotter', persons).id, '2');
assert.equal(matchPerson('Michael', persons), null);
assert.equal(matchPerson('Unbekannt Person', persons), null);
assert.equal(matchPerson('', persons), null);
});
test('Abwesenheiten verschmelzen: angrenzend und überlappend, getrennt nach Art', () => {
const merged = mergeAbsences([
{ personId: 'a', type: 'urlaub', from: '2026-10-05', to: '2026-10-06' },
{ personId: 'a', type: 'urlaub', from: '2026-10-07', to: '2026-10-09' },
{ personId: 'a', type: 'urlaub', from: '2026-10-08', to: '2026-10-08' },
{ personId: 'a', type: 'frei', from: '2026-10-05', to: '2026-10-05' },
{ personId: 'a', type: 'urlaub', from: '2026-10-19', to: '2026-10-19' },
{ personId: 'b', type: 'urlaub', from: '2026-10-05', to: '2026-10-05' },
]);
assert.deepEqual(merged.map((m) => `${m.personId}/${m.type}/${m.from}/${m.to}`), [
'a/urlaub/2026-10-05/2026-10-09', 'a/frei/2026-10-05/2026-10-05', 'b/urlaub/2026-10-05/2026-10-05', 'a/urlaub/2026-10-19/2026-10-19',
]);
});
+51
View File
@@ -0,0 +1,51 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { buildEventLines, buildCalendar, escapeText, foldLine, eventUid } from '../server/ics.js';
test('escapeText maskiert Sonderzeichen', () => {
assert.equal(escapeText('a,b;c\\d\ne'), 'a\\,b\\;c\\\\d\\ne');
});
test('foldLine faltet lange Zeilen nach 75 Oktetten', () => {
const long = 'X'.repeat(200);
const folded = foldLine(long);
const lines = folded.split('\r\n');
assert.ok(lines.length > 1);
assert.ok(lines.every((l) => Buffer.byteLength(l) <= 75));
assert.ok(lines.slice(1).every((l) => l.startsWith(' ')));
assert.equal(lines.map((l, i) => (i ? l.slice(1) : l)).join(''), long);
// Umlaute werden nicht zerschnitten
const umlaut = foldLine('Ä'.repeat(100));
assert.equal(umlaut.split('\r\n').map((l, i) => (i ? l.slice(1) : l)).join(''), 'Ä'.repeat(100));
});
test('Termin mit Uhrzeit und Zeitzone', () => {
const lines = buildEventLines({ uid: 'u1', date: '2026-10-05', summary: 'Morning Meeting: A / B', time: '09:00', durationMinutes: 15, sequence: 2, now: new Date('2026-09-15T10:00:00Z') });
assert.ok(lines.includes('DTSTART;TZID=Europe/Berlin:20261005T090000'));
assert.ok(lines.includes('DTEND;TZID=Europe/Berlin:20261005T091500'));
assert.ok(lines.includes('SEQUENCE:2'));
assert.ok(lines.includes('DTSTAMP:20260915T100000Z'));
});
test('ganztägiger Termin ohne Uhrzeit', () => {
const lines = buildEventLines({ uid: 'u1', date: '2026-10-30', summary: 'x', time: '' });
assert.ok(lines.includes('DTSTART;VALUE=DATE:20261030'));
assert.ok(lines.includes('DTEND;VALUE=DATE:20261031'));
});
test('Kalender ist wohlgeformt, ohne METHOD für CalDAV, mit ORGANIZER vor Teilnehmern', () => {
const ev = buildEventLines({ uid: eventUid('2026-10-05'), date: '2026-10-05', summary: 'S', time: '09:00', attendees: [{ name: 'Max Mustermann', email: 'max@example.de' }], organizer: { name: 'Rotatory', email: 'team@example.de' } });
const ics = buildCalendar([ev]);
assert.ok(!ics.includes('METHOD:'), 'CalDAV-Objekte dürfen keine METHOD tragen');
assert.ok(buildCalendar([ev], { method: 'PUBLISH' }).includes('METHOD:PUBLISH'));
assert.ok(ics.includes('ORGANIZER;CN="Rotatory":mailto:team@example.de'));
assert.ok(ics.indexOf('ORGANIZER') < ics.indexOf('ATTENDEE'));
const noOrganizer = buildCalendar([buildEventLines({ uid: 'x', date: '2026-10-05', summary: 'S', attendees: [{ name: 'A', email: 'a@example.de' }] })]);
assert.ok(!noOrganizer.includes('ATTENDEE'), 'ohne ORGANIZER keine Teilnehmer');
assert.ok(ics.startsWith('BEGIN:VCALENDAR\r\n'));
assert.ok(ics.endsWith('END:VCALENDAR\r\n'));
assert.ok(ics.includes('BEGIN:VTIMEZONE'));
assert.ok(ics.includes('UID:rotatory-2026-10-05@rotatory.local'));
assert.ok(ics.includes('ATTENDEE;CN="Max Mustermann"'));
assert.ok(ics.split('\r\n').every((l) => Buffer.byteLength(l) <= 75));
});
+149
View File
@@ -0,0 +1,149 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { createNextcloudClient, nextcloudConfigFromEnv, decodeXml, xmlResponses, xmlProp, xmlHasEmpty } from '../server/nextcloud.js';
const PRIV_RW = '<d:current-user-privilege-set><d:privilege><d:write/></d:privilege><d:privilege><d:read/></d:privilege></d:current-user-privilege-set>';
const PRIV_RO = '<d:current-user-privilege-set><d:privilege><d:write-properties/></d:privilege><d:privilege><d:read/></d:privilege></d:current-user-privilege-set>';
const cal = (href, name, priv, owner, comps = '<cal:comp name="VEVENT"/>') => `<d:response><d:href>${href}</d:href><d:propstat><d:prop><d:displayname>${name}</d:displayname><d:resourcetype><d:collection/><cal:calendar/></d:resourcetype><d:owner><d:href>/remote.php/dav/principals/users/${owner}/</d:href></d:owner>${priv}<cal:supported-calendar-component-set>${comps}</cal:supported-calendar-component-set><oc:owner-displayname>${owner.toUpperCase()}</oc:owner-displayname></d:prop><d:status>HTTP/1.1 200 OK</d:status></d:propstat></d:response>`;
/** Simulierte Nextcloud: Login-Name ist die E-Mail, Benutzer-ID "ferdinand". */
function fakeNextcloud() {
const calls = [];
const store = new Map();
let created = false;
const res = (status, text = '', headers = {}) => ({ status, text: async () => text, headers: { get: (k) => headers[k.toLowerCase()] ?? null }, arrayBuffer: async () => new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0, 0, 0, 0, 0, 0, 0, 0]).buffer });
const fetchImpl = async (url, opts) => {
calls.push({ method: opts.method, url, auth: opts.headers.Authorization, body: opts.body, depth: opts.headers.Depth });
const u = new URL(url);
if (opts.method === 'PROPFIND') {
if (u.pathname === '/remote.php/dav/') return res(207, '<d:multistatus xmlns:d="DAV:"><d:response><d:href>/remote.php/dav/</d:href><d:propstat><d:prop><d:current-user-principal><d:href>/remote.php/dav/principals/users/ferdinand/</d:href></d:current-user-principal></d:prop></d:propstat></d:response></d:multistatus>');
if (u.pathname === '/remote.php/dav/principals/users/ferdinand/') return res(207, '<d:multistatus xmlns:d="DAV:" xmlns:cal="urn:ietf:params:xml:ns:caldav" xmlns:card="urn:ietf:params:xml:ns:carddav"><d:response><d:href>/remote.php/dav/principals/users/ferdinand/</d:href><d:propstat><d:prop><cal:calendar-home-set><d:href>/remote.php/dav/calendars/ferdinand/</d:href></cal:calendar-home-set><card:addressbook-home-set><d:href>/remote.php/dav/addressbooks/users/ferdinand/</d:href></card:addressbook-home-set><d:displayname>Ferdinand Urban</d:displayname></d:prop></d:propstat></d:response></d:multistatus>');
if (u.pathname === '/remote.php/dav/calendars/ferdinand/' && opts.headers.Depth === '1') return res(207, `<d:multistatus xmlns:d="DAV:" xmlns:cal="urn:ietf:params:xml:ns:caldav" xmlns:oc="http://owncloud.org/ns"><d:response><d:href>/remote.php/dav/calendars/ferdinand/</d:href><d:propstat><d:prop><d:resourcetype><d:collection/></d:resourcetype></d:prop></d:propstat></d:response>${cal('/remote.php/dav/calendars/ferdinand/personal/', 'Pers&#xF6;nlich', PRIV_RW, 'ferdinand')}${cal('/remote.php/dav/calendars/ferdinand/morning-meeting_shared_by_hilde/', 'Morning Meeting (Hildegard Kau)', PRIV_RW, 'hilde')}${cal('/remote.php/dav/calendars/ferdinand/geburtstage_shared_by_hilde/', 'Geburtstage &amp; Co.', PRIV_RO, 'hilde')}${cal('/remote.php/dav/calendars/ferdinand/app-generated--deck--board-1/', 'Deck', PRIV_RO, 'ferdinand', '<cal:comp name="VTODO"/>')}</d:multistatus>`);
if (u.pathname === '/remote.php/dav/calendars/ferdinand/morning-meeting_shared_by_hilde/') return res(207, '<d:multistatus xmlns:d="DAV:"><d:response><d:href>x</d:href></d:response></d:multistatus>');
if (u.pathname === '/remote.php/dav/calendars/ferdinand/morning-meeting/') return created ? res(207, '<d:multistatus xmlns:d="DAV:"><d:response><d:href>x</d:href></d:response></d:multistatus>') : res(404);
if (u.pathname === '/remote.php/dav/addressbooks/users/ferdinand/' && opts.headers.Depth === '1') return res(207, '<d:multistatus xmlns:d="DAV:" xmlns:card="urn:ietf:params:xml:ns:carddav"><d:response><d:href>/remote.php/dav/addressbooks/users/ferdinand/</d:href><d:propstat><d:prop><d:resourcetype><d:collection/></d:resourcetype></d:prop></d:propstat></d:response><d:response><d:href>/remote.php/dav/addressbooks/users/ferdinand/kontakte/</d:href><d:propstat><d:prop><d:displayname>Kontakte</d:displayname><d:resourcetype><d:collection/><card:addressbook/></d:resourcetype></d:prop></d:propstat></d:response><d:response><d:href>/remote.php/dav/addressbooks/users/ferdinand/z-server-generated--system/</d:href><d:propstat><d:prop><d:displayname>Konten</d:displayname><d:resourcetype><d:collection/><card:addressbook/></d:resourcetype></d:prop></d:propstat></d:response></d:multistatus>');
return res(404);
}
if (opts.method === 'MKCALENDAR') { created = true; return res(201); }
if (opts.method === 'PUT') { const existed = store.has(url); store.set(url, opts.body); return res(existed ? 204 : 201); }
if (opts.method === 'DELETE') return store.delete(url) ? res(204) : res(404);
if (opts.method === 'REPORT') {
if (u.pathname.endsWith('/z-server-generated--system/')) return res(207, '<d:multistatus xmlns:d="DAV:" xmlns:card="urn:ietf:params:xml:ns:carddav"><d:response><d:href>/x/ferdinand.vcf</d:href><d:propstat><d:prop><card:address-data>BEGIN:VCARD&#13;\nVERSION:3.0&#13;\nUID:ferdinand&#13;\nFN:Ferdinand Urban&#13;\nN:Urban;Ferdinand;;;&#13;\nEND:VCARD&#13;\n</card:address-data></d:prop></d:propstat></d:response></d:multistatus>');
const expand = /<c:expand/.test(opts.body);
return res(207, `<d:multistatus xmlns:d="DAV:" xmlns:cal="urn:ietf:params:xml:ns:caldav"><d:response><d:href>${u.pathname}abc.ics</d:href><d:propstat><d:prop><d:getetag>"1"</d:getetag><cal:calendar-data>BEGIN:VCALENDAR&#13;\nBEGIN:VEVENT&#13;\nUID:${expand ? 'expanded' : 'master'}&#13;\nSUMMARY:Alex Koop (frei)&#13;\nDTSTART;VALUE=DATE:20261005&#13;\nDTEND;VALUE=DATE:20261006&#13;\nEND:VEVENT&#13;\nEND:VCALENDAR&#13;\n</cal:calendar-data></d:prop></d:propstat></d:response></d:multistatus>`);
}
if (opts.method === 'GET' && u.pathname.startsWith('/index.php/avatar/')) {
return u.pathname.includes('/ferdinand/') ? res(200, '', { 'content-type': 'image/png', 'x-nc-iscustomavatar': '1' }) : res(200, '', { 'content-type': 'image/png', 'x-nc-iscustomavatar': '0' });
}
return res(405);
};
return { fetchImpl, calls, store };
}
const mk = (fake) => createNextcloudClient({ baseUrl: 'https://cld2.example.de', user: 'urban@team-tradeo.de', password: 'geheim', calendar: 'morning-meeting', displayName: 'Morning <Meeting>', fetchImpl: fake.fetchImpl });
test('Konfiguration aus Umgebung, Kalender-ID wird normalisiert', () => {
const c = nextcloudConfigFromEnv({ NEXTCLOUD_URL: 'https://cloud.example.de/', NEXTCLOUD_USER: 'team', NEXTCLOUD_APP_PASSWORD: 'x', NEXTCLOUD_CALENDAR: 'Morning Meeting!' });
assert.equal(c.baseUrl, 'https://cloud.example.de');
assert.equal(c.calendar, 'morning-meeting-');
assert.ok(c.configured);
assert.ok(!nextcloudConfigFromEnv({}).configured);
});
test('XML-Hilfen sind präfix-unabhängig und dekodieren Entities', () => {
const xml = '<D:multistatus xmlns:D="DAV:"><D:response><D:href>/a/</D:href><D:prop><D:displayname>A &amp; B &#xF6;</D:displayname><D:resourcetype><D:collection/><C:calendar/></D:resourcetype></D:prop></D:response></D:multistatus>';
const [r] = xmlResponses(xml);
assert.equal(decodeXml(xmlProp(r, 'displayname')), 'A & B ö');
assert.ok(xmlHasEmpty(xmlProp(r, 'resourcetype'), 'calendar'));
assert.ok(!xmlHasEmpty(xmlProp(r, 'resourcetype'), 'addressbook'));
});
test('Discovery: Login per E-Mail, Benutzer-ID aus dem Principal', async () => {
const fake = fakeNextcloud();
const client = mk(fake);
const d = await client.discover();
assert.equal(d.userId, 'ferdinand');
assert.equal(d.displayName, 'Ferdinand Urban');
assert.equal(d.calendarHome, 'https://cld2.example.de/remote.php/dav/calendars/ferdinand/');
assert.equal(d.addressbookHome, 'https://cld2.example.de/remote.php/dav/addressbooks/users/ferdinand/');
assert.equal(fake.calls[0].auth, 'Basic ' + Buffer.from('urban@team-tradeo.de:geheim').toString('base64'));
await client.discover();
assert.equal(fake.calls.length, 2, 'Discovery wird gecacht');
});
test('Kalenderliste: nur Ereigniskalender, Schreibrecht und Freigabe erkannt', async () => {
const client = mk(fakeNextcloud());
const cals = await client.listCalendars();
assert.deepEqual(cals.map((c) => c.id), ['geburtstage_shared_by_hilde', 'morning-meeting_shared_by_hilde', 'personal']);
const mm = cals.find((c) => c.id === 'morning-meeting_shared_by_hilde');
assert.equal(mm.writable, true);
assert.equal(mm.shared, true);
assert.equal(mm.ownerName, 'HILDE');
assert.equal(cals.find((c) => c.id === 'geburtstage_shared_by_hilde').writable, false);
assert.equal(cals.find((c) => c.id === 'personal').shared, false);
assert.equal(cals.find((c) => c.id === 'personal').name, 'Persönlich');
});
test('ensureCalendar: vorhandenes Ziel prüfen oder eigenen Kalender anlegen', async () => {
const fake = fakeNextcloud();
const client = mk(fake);
const target = 'https://cld2.example.de/remote.php/dav/calendars/ferdinand/morning-meeting_shared_by_hilde/';
assert.deepEqual(await client.ensureCalendar(target), { href: target, created: false });
const own = await client.ensureCalendar(null);
assert.equal(own.created, true);
assert.equal(own.href, 'https://cld2.example.de/remote.php/dav/calendars/ferdinand/morning-meeting/');
const mkc = fake.calls.find((c) => c.method === 'MKCALENDAR');
assert.ok(mkc.body.includes('<d:displayname>Morning &lt;Meeting&gt;</d:displayname>'));
assert.deepEqual(await client.ensureCalendar(null), { href: own.href, created: false });
await assert.rejects(client.ensureCalendar('https://cld2.example.de/remote.php/dav/calendars/ferdinand/weg/'), /Zielkalender nicht erreichbar/);
});
test('Termine schreiben, abfragen (expand) und löschen', async () => {
const fake = fakeNextcloud();
const client = mk(fake);
const href = 'https://cld2.example.de/remote.php/dav/calendars/ferdinand/morning-meeting_shared_by_hilde/';
await client.putEvent(href, 'rotatory-2026-10-05@cld2.example.de', 'BEGIN:VCALENDAR\r\nEND:VCALENDAR\r\n');
assert.equal(fake.calls.at(-1).url, href + 'rotatory-2026-10-05%40cld2.example.de.ics');
const events = await client.queryEvents(href, '2026-10-01', '2026-10-31', { expand: true });
assert.equal(events.length, 1);
assert.ok(events[0].ics.includes('UID:expanded'));
assert.ok(events[0].ics.includes('\r\n'), 'Zeilenumbrüche aus &#13; wiederhergestellt');
const report = fake.calls.at(-1);
assert.ok(report.body.includes('<c:expand start="20261001T000000Z" end="20261101T000000Z"/>'));
assert.ok(report.body.includes('<c:time-range start="20261001T000000Z" end="20261101T000000Z"/>'));
assert.equal(await client.deleteEvent(href, 'rotatory-2026-10-05@cld2.example.de'), true);
assert.equal(await client.deleteEvent(href, 'rotatory-2026-10-05@cld2.example.de'), false);
assert.equal(await client.deleteEvent(href, href + 'irgendwas.ics'), false, 'href-Form akzeptiert');
});
test('Adressbücher, Kontakte und Avatare', async () => {
const client = mk(fakeNextcloud());
const books = await client.listAddressBooks();
assert.equal(books[0].system, true);
assert.equal(books[0].name, 'Konten');
assert.equal(books.length, 2);
const cards = await client.queryContacts(books[0].href);
assert.equal(cards.length, 1);
assert.ok(cards[0].includes('FN:Ferdinand Urban'));
const avatar = await client.fetchAvatar('ferdinand');
assert.equal(avatar.mime, 'image/png');
assert.equal(await client.fetchAvatar('niemand'), null, 'Buchstaben-Avatar zählt nicht');
});
test('fetchBinary schickt das App-Passwort nie an fremde Hosts', async () => {
const fake = fakeNextcloud();
const client = mk(fake);
const before = fake.calls.length;
assert.equal(await client.fetchBinary('https://evil.example/avatar.png'), null);
assert.equal(await client.fetchBinary('kein url'), null);
assert.equal(fake.calls.length, before, 'kein Request abgesetzt');
});
test('falsche Zugangsdaten und Ausfall liefern verständliche Meldungen', async () => {
const client = createNextcloudClient({ baseUrl: 'https://c.example', user: 'u', password: 'p', fetchImpl: async () => ({ status: 401, text: async () => '' }) });
await assert.rejects(client.test(), /Zugangsdaten/);
const down = createNextcloudClient({ baseUrl: 'https://c.example', user: 'u', password: 'p', fetchImpl: async () => { throw new Error('fetch failed', { cause: new Error('ECONNREFUSED') }); } });
await assert.rejects(down.test(), /nicht erreichbar: ECONNREFUSED/);
const notNc = createNextcloudClient({ baseUrl: 'https://c.example', user: 'u', password: 'p', fetchImpl: async () => ({ status: 207, text: async () => '<d:multistatus xmlns:d="DAV:"></d:multistatus>' }) });
await assert.rejects(notNc.test(), /keinen Principal/);
});
+159
View File
@@ -0,0 +1,159 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { workingDays, generateSchedule, isAbsent, addDays, isValidDate, countAssignments } from '../server/scheduler.js';
function seeded(seed = 42) {
let s = seed;
return () => { s = (s * 1664525 + 1013904223) % 4294967296; return s / 4294967296; };
}
const persons = (n, extra = {}) => Array.from({ length: n }, (_, i) => ({ id: `p${i}`, canMain: true, canCo: true, active: true, ...extra }));
test('workingDays liefert nur Mo–Fr ohne blockierte Tage', () => {
// 2026-09-14 ist ein Montag
const days = workingDays('2026-09-12', '2026-09-20', new Set(['2026-09-16']));
assert.deepEqual(days, ['2026-09-14', '2026-09-15', '2026-09-17', '2026-09-18']);
});
test('isValidDate lehnt Unsinn ab', () => {
assert.ok(isValidDate('2026-02-28'));
assert.ok(!isValidDate('2026-02-30'));
assert.ok(!isValidDate('2026-2-3'));
assert.ok(!isValidDate('heute'));
});
test('jeder Werktag bekommt zwei verschiedene Personen', () => {
const dates = workingDays('2026-10-01', '2026-10-31');
const result = generateSchedule({ dates, persons: persons(6), rng: seeded(1) });
for (const d of dates) {
assert.ok(result[d].main, `main fehlt am ${d}`);
assert.ok(result[d].co, `co fehlt am ${d}`);
assert.notEqual(result[d].main, result[d].co);
}
});
test('Einsätze sind fair verteilt (Differenz höchstens 1)', () => {
const dates = workingDays('2026-10-01', '2026-12-31');
const result = generateSchedule({ dates, persons: persons(7), rng: seeded(7) });
const counts = countAssignments(result, '2026-10-01', '2026-12-31');
const totals = Object.values(counts).map((c) => c.total);
assert.equal(totals.length, 7);
assert.ok(Math.max(...totals) - Math.min(...totals) <= 1, `Verteilung: ${totals}`);
// Rollen ebenfalls ausgewogen
for (const c of Object.values(counts)) assert.ok(Math.abs(c.main - c.co) <= 2, JSON.stringify(c));
});
test('Abwesende werden nicht eingeplant', () => {
const dates = workingDays('2026-10-05', '2026-10-09');
const absences = [{ personId: 'p0', from: '2026-10-06', to: '2026-10-08' }];
const result = generateSchedule({ dates, persons: persons(3), absences, rng: seeded(3) });
for (const d of ['2026-10-06', '2026-10-07', '2026-10-08']) {
assert.notEqual(result[d].main, 'p0');
assert.notEqual(result[d].co, 'p0');
}
assert.ok(isAbsent('p0', '2026-10-07', absences));
assert.ok(!isAbsent('p0', '2026-10-09', absences));
});
test('Nicht-Moderatoren und Inaktive werden nie ausgelost, Nur-Neben nie Haupt', () => {
const ps = [
...persons(3),
{ id: 'none', canMain: false, canCo: false, active: true },
{ id: 'gone', canMain: true, canCo: true, active: false },
{ id: 'coonly', canMain: false, canCo: true, active: true },
];
const dates = workingDays('2026-10-01', '2026-11-30');
const result = generateSchedule({ dates, persons: ps, rng: seeded(9) });
const used = new Set();
for (const a of Object.values(result)) { used.add(a.main); used.add(a.co); assert.notEqual(a.main, 'coonly'); }
assert.ok(!used.has('none'));
assert.ok(!used.has('gone'));
assert.ok(used.has('coonly'));
});
test('niemand an zwei Werktagen hintereinander, wenn genug Leute da sind', () => {
const dates = workingDays('2026-10-01', '2026-10-31');
const result = generateSchedule({ dates, persons: persons(6), rng: seeded(11) });
for (let i = 1; i < dates.length; i += 1) {
const prev = [result[dates[i - 1]].main, result[dates[i - 1]].co];
const cur = [result[dates[i]].main, result[dates[i]].co];
for (const id of cur) assert.ok(!prev.includes(id), `${id} am ${dates[i - 1]} und ${dates[i]}`);
}
});
test('fixierte und freie Tage bleiben erhalten, keep-Tage ebenfalls', () => {
const dates = workingDays('2026-10-05', '2026-10-09');
const existing = {
'2026-10-05': { main: 'p2', co: 'p1', locked: true },
'2026-10-06': { off: true, main: null, co: null, locked: false },
'2026-10-07': { main: 'p0', co: 'p2', locked: false, keep: true },
};
const result = generateSchedule({ dates, persons: persons(4), existing, rng: seeded(5) });
assert.deepEqual(result['2026-10-05'], { main: 'p2', co: 'p1', locked: true });
assert.equal(result['2026-10-06'].off, true);
assert.deepEqual(result['2026-10-07'], { main: 'p0', co: 'p2', locked: false });
assert.ok(result['2026-10-08'].main);
});
test('Historie zählt für die Fairness – wer viel dran war, kommt seltener', () => {
const history = {};
// p0 und p1 waren in den letzten zwei Wochen jeden Tag dran
for (const d of workingDays('2026-09-14', '2026-09-25')) history[d] = { main: 'p0', co: 'p1' };
const dates = workingDays('2026-09-28', '2026-10-09');
const result = generateSchedule({ dates, persons: persons(6), history, rng: seeded(2) });
const counts = countAssignments(result, '2026-09-28', '2026-10-09');
const heavy = (counts.p0?.total || 0) + (counts.p1?.total || 0);
const light = ['p2', 'p3', 'p4', 'p5'].reduce((n, id) => n + (counts[id]?.total || 0), 0);
assert.equal(heavy, 0, `p0/p1 sollten aussetzen: ${JSON.stringify(counts)}`);
assert.equal(light, dates.length * 2);
});
test('ohne Kandidaten bleibt der Platz leer statt zu scheitern', () => {
const result = generateSchedule({ dates: ['2026-10-05'], persons: [{ id: 'a', canMain: true, canCo: false, active: true }], rng: seeded(1) });
assert.equal(result['2026-10-05'].main, 'a');
assert.equal(result['2026-10-05'].co, null);
const empty = generateSchedule({ dates: ['2026-10-05'], persons: [] });
assert.equal(empty['2026-10-05'].main, null);
});
test('addDays über Monatsgrenzen', () => {
assert.equal(addDays('2026-01-31', 1), '2026-02-01');
assert.equal(addDays('2026-03-01', -1), '2026-02-28');
});
test('feste freie Wochentage (Teilzeit) werden respektiert', async () => {
const { isAvailable, isoWeekday } = await import('../server/scheduler.js');
assert.equal(isoWeekday('2026-10-05'), 1);
assert.equal(isoWeekday('2026-10-11'), 7);
const ps = [...persons(4), { id: 'teilzeit', canMain: true, canCo: true, active: true, freeDays: [1, 5] }];
const dates = workingDays('2026-10-01', '2026-11-30');
const result = generateSchedule({ dates, persons: ps, rng: seeded(4) });
for (const d of dates) {
const wd = isoWeekday(d);
if (wd === 1 || wd === 5) assert.ok(result[d].main !== 'teilzeit' && result[d].co !== 'teilzeit', `Teilzeit am ${d}`);
}
assert.ok(Object.values(result).some((a) => a.main === 'teilzeit' || a.co === 'teilzeit'));
assert.ok(!isAvailable({ id: 'x', freeDays: [3] }, '2026-10-07', []));
assert.ok(isAvailable({ id: 'x', freeDays: [3] }, '2026-10-08', []));
});
test('„nicht zwei Werktage in Folge" gilt auch über einen Feiertag hinweg', () => {
// Mi 2026-10-07 blockiert → Di und Do gelten als aufeinanderfolgend
const blocked = new Set(['2026-10-07']);
const dates = workingDays('2026-10-06', '2026-10-08', blocked);
assert.deepEqual(dates, ['2026-10-06', '2026-10-08']);
for (let seed = 1; seed <= 20; seed += 1) {
const result = generateSchedule({ dates, persons: persons(6), blocked, rng: seeded(seed) });
const tue = [result['2026-10-06'].main, result['2026-10-06'].co];
for (const id of [result['2026-10-08'].main, result['2026-10-08'].co]) assert.ok(!tue.includes(id), `seed ${seed}: ${id} Di und Do`);
}
});
test('fill: halb besetzte Tage werden nur aufgefüllt, der vorhandene Slot bleibt', () => {
const existing = { '2026-10-05': { main: 'p2', co: null, locked: false, fill: true }, '2026-10-06': { main: null, co: 'p1', locked: false, fill: true } };
const result = generateSchedule({ dates: ['2026-10-05', '2026-10-06'], persons: persons(4), existing, rng: seeded(3) });
assert.equal(result['2026-10-05'].main, 'p2');
assert.ok(result['2026-10-05'].co && result['2026-10-05'].co !== 'p2');
assert.equal(result['2026-10-06'].co, 'p1');
assert.ok(result['2026-10-06'].main && result['2026-10-06'].main !== 'p1');
assert.equal('fill' in result['2026-10-05'], false);
});