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 = '';
const PRIV_RO = '';
const cal = (href, name, priv, owner, comps = '') => `${href}${name}/remote.php/dav/principals/users/${owner}/${priv}${comps}${owner.toUpperCase()}HTTP/1.1 200 OK`;
/** 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, '/remote.php/dav//remote.php/dav/principals/users/ferdinand/');
if (u.pathname === '/remote.php/dav/principals/users/ferdinand/') return res(207, '/remote.php/dav/principals/users/ferdinand//remote.php/dav/calendars/ferdinand//remote.php/dav/addressbooks/users/ferdinand/Ferdinand Urban');
if (u.pathname === '/remote.php/dav/calendars/ferdinand/' && opts.headers.Depth === '1') return res(207, `/remote.php/dav/calendars/ferdinand/${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', '')}`);
if (u.pathname === '/remote.php/dav/calendars/ferdinand/morning-meeting_shared_by_hilde/') return res(207, 'x');
if (u.pathname === '/remote.php/dav/calendars/ferdinand/morning-meeting/') return created ? res(207, 'x') : res(404);
if (u.pathname === '/remote.php/dav/addressbooks/users/ferdinand/' && opts.headers.Depth === '1') return res(207, '/remote.php/dav/addressbooks/users/ferdinand//remote.php/dav/addressbooks/users/ferdinand/kontakte/Kontakte/remote.php/dav/addressbooks/users/ferdinand/z-server-generated--system/Konten');
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, '/x/ferdinand.vcfBEGIN:VCARD
\nVERSION:3.0
\nUID:ferdinand
\nFN:Ferdinand Urban
\nN:Urban;Ferdinand;;;
\nEND:VCARD
\n');
const expand = /${u.pathname}abc.ics"1"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`);
}
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 ', 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 = '/a/A & B ö';
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('Morning <Meeting>'));
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(''));
assert.ok(report.body.includes(''));
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 () => '' }) });
await assert.rejects(notNc.test(), /keinen Principal/);
});