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:
commit
95756778d8
26 files changed
+4697
No files matched your search
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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',
|
||||
]);
|
||||
});
|
||||
@@ -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));
|
||||
});
|
||||
@@ -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ö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 & 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 \nVERSION:3.0 \nUID:ferdinand \nFN:Ferdinand Urban \nN:Urban;Ferdinand;;; \nEND:VCARD \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 \nBEGIN:VEVENT \nUID:${expand ? 'expanded' : 'master'} \nSUMMARY:Alex Koop (frei) \nDTSTART;VALUE=DATE:20261005 \nDTEND;VALUE=DATE:20261006 \nEND:VEVENT \nEND:VCALENDAR \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 & B ö</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 <Meeting></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 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/);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
Reference in new issue
Block a user