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

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