Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e8f971b54e |
@@ -1,65 +0,0 @@
|
||||
# Calendar iCal export and subscriptions
|
||||
|
||||
How `GET /calendar/events/{calendar}/ical` builds its file, what changed on 2026-09-28, and the
|
||||
endpoint the calendar app uses to hand editors ready-made subscription URLs.
|
||||
|
||||
## The export
|
||||
|
||||
`icalgenerator.service.ts` turns a calendar's `PUBLIC` events into one `VCALENDAR`:
|
||||
|
||||
- Timed events: `DTSTART;TZID=Europe/Berlin:` / `DTEND;TZID=Europe/Berlin:` formatted with the
|
||||
**API process's local** `getHours()` etc. The times are therefore only right if the process runs
|
||||
in Berlin time (`TZ=Europe/Berlin`, or a server in that zone). Nothing enforces this. Inferred
|
||||
from the code; not checked against the production server.
|
||||
- Whole-day events: `VALUE=DATE`, and the stored end date is treated as the *inclusive* last day -
|
||||
the generator adds one day for `DTEND`. The calendar app stores whole-day events as 00:00 to
|
||||
23:59 Berlin time for this reason.
|
||||
- `repeatFrequency` is written verbatim as `RRULE:FREQ=<value>` (no interval, count or end).
|
||||
- `public` needs no credential; the other calendars take `?password=` because a calendar client
|
||||
cannot send the session cookie (see `calendar-auth-migration.md`).
|
||||
|
||||
## RFC 5545 conformance (2026-09-28)
|
||||
|
||||
Before this date the generator wrote text values raw. A line break in a description ended the
|
||||
`DESCRIPTION` property early and the rest was read as a garbage property; unescaped `,` and `;`
|
||||
are invalid in text values. The new calendar frontend has a multi-line description field, so this
|
||||
went from theoretical to certain. Now:
|
||||
|
||||
- `SUMMARY`, `DESCRIPTION` and `LOCATION` go through `escapeText` (`\` `;` `,` and line breaks,
|
||||
backslash first).
|
||||
- `URL` is a URI value and is not escaped; line breaks are stripped from it.
|
||||
- Every content line is folded at 75 octets (UTF-8 aware - a multi-byte character is never split)
|
||||
and the file uses CRLF line endings throughout (`toContentLines`).
|
||||
- `formatDate` no longer shifts the caller's `Date` when it adds the whole-day end's extra day.
|
||||
- The response is `Content-Type: text/calendar; charset=utf-8`. Before, Express inferred
|
||||
`text/html` from the string body; calendar apps coped, but it was wrong.
|
||||
|
||||
Pinned by `test/calendar/icalgenerator.service.test.ts`.
|
||||
|
||||
Not changed, worth knowing: `ORGANIZER` is written as a bare display name (`ORGANIZER:Anna`),
|
||||
while RFC 5545 expects a cal-address (`ORGANIZER;CN=Anna:mailto:…`). Calendar apps have tolerated
|
||||
it so far.
|
||||
|
||||
## `GET /calendar/events/subscriptions`
|
||||
|
||||
For the calendar app's "Abonnieren" dialog. Behind `requireAppAccess('calendar')`; the shared
|
||||
password is **not** accepted here (one calendar's password must not reveal the others).
|
||||
|
||||
Returns `[{calendar, icalUrl}]` in `calendarNames` order, built from `API_BASE_URL`:
|
||||
|
||||
- `public` without a password;
|
||||
- every other calendar with `?password=<its shared password>`, URL-encoded;
|
||||
- a calendar whose credential env var is unset is left out rather than listed with a URL that
|
||||
cannot work.
|
||||
|
||||
`choir` and `birthdays` share `CHOIR_CREDENTIAL`, so rotating it changes both URLs - and breaks
|
||||
every existing subscription to either. The response is sent with `Cache-Control: no-store`
|
||||
because it carries the passwords.
|
||||
|
||||
Signed-in editors can read every event of every calendar anyway, so handing them the passwords
|
||||
exposes nothing they could not already see.
|
||||
|
||||
## Found while doing this
|
||||
|
||||
- Only the admin pool reads `DB_PORT`; `Calendar.db.ts` (and the feedback/tickets pools) always
|
||||
connect to 3306. A local test database for the calendar has to listen there.
|
||||
@@ -21,13 +21,7 @@ dotenv.config();
|
||||
* test/calendar/credentials.service.test.ts.
|
||||
*/
|
||||
|
||||
/**
|
||||
* The shared password for a calendar, or undefined for `public` (which needs
|
||||
* none), an unknown calendar, or one whose credential is not configured.
|
||||
* Exported for the subscriptions endpoint, which hands signed-in editors the
|
||||
* ready-made iCal URLs - see GET /calendar/events/subscriptions.
|
||||
*/
|
||||
export const credentialFor = (calendarName: string): string | undefined => {
|
||||
const credentialFor = (calendarName: string): string | undefined => {
|
||||
switch (calendarName) {
|
||||
case 'members':
|
||||
return process.env.MEMBER_CREDENTIAL;
|
||||
|
||||
@@ -8,7 +8,6 @@ import * as EventService from './events.service.js';
|
||||
import * as iCalService from './icalgenerator.service.js';
|
||||
import * as CredentialService from './credentials.service.js';
|
||||
import {requireAppAccess, resolveAccess, AdminAccess} from '../../admin/admin.middleware.js';
|
||||
import {API_BASE_URL} from '../../admin/admin.config.js';
|
||||
import {Guid} from 'guid-typescript';
|
||||
import logger from '../../../middleware/logger.js';
|
||||
|
||||
@@ -424,12 +423,7 @@ eventsRouter.get('/:calendar/ical', async (req: Request, res: Response) => {
|
||||
let file = await iCalService.convertToIcal(events);
|
||||
|
||||
// Send the ical file back
|
||||
// text/calendar, not the text/html that send() infers from a string - calendar apps
|
||||
// coped with the wrong type, but it was wrong (RFC 5545 §8.1).
|
||||
res.set({
|
||||
'Content-Type': 'text/calendar; charset=utf-8',
|
||||
'Content-Disposition': 'attachment; filename=' + fileName + '.ics'
|
||||
});
|
||||
res.set({'Content-Disposition': 'attachment; filename=' + fileName + '.ics'});
|
||||
res.status(200).send(file);
|
||||
} catch (e: any) {
|
||||
let errorGuid = Guid.create().toString();
|
||||
@@ -442,59 +436,6 @@ eventsRouter.get('/:calendar/ical', async (req: Request, res: Response) => {
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /calendar/events/subscriptions:
|
||||
* get:
|
||||
* summary: The iCal subscription URL of every calendar
|
||||
* description: >
|
||||
* Ready-to-use iCal URLs for each calendar, with the calendar's shared password
|
||||
* already in the query string where it needs one - for the calendar app's
|
||||
* "Abonnieren" dialog. A calendar whose password is not configured is left out
|
||||
* rather than listed with a URL that cannot work. Requires a signed-in account
|
||||
* with the calendar permission: those editors can read every event anyway, so the
|
||||
* passwords tell them nothing new. Never cached.
|
||||
* tags:
|
||||
* - calendar
|
||||
* security:
|
||||
* - AdminSessionCookie: []
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Success
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: array
|
||||
* items:
|
||||
* type: object
|
||||
* properties:
|
||||
* calendar:
|
||||
* type: string
|
||||
* example: members
|
||||
* icalUrl:
|
||||
* type: string
|
||||
* example: https://api.nachklang.art/calendar/events/members/ical?password=…
|
||||
* 401:
|
||||
* description: Unauthorized - not signed in
|
||||
* 403:
|
||||
* description: Forbidden - the account lacks the calendar permission
|
||||
*/
|
||||
eventsRouter.get('/subscriptions', requireCalendarAccess, (req: Request, res: Response) => {
|
||||
const base = API_BASE_URL.replace(/\/+$/, '');
|
||||
const subscriptions = Array.from(calendarNames.keys()).flatMap((calendar) => {
|
||||
const url = `${base}/calendar/events/${calendar}/ical`;
|
||||
if (calendar === 'public') {
|
||||
return [{calendar, icalUrl: url}];
|
||||
}
|
||||
const password = CredentialService.credentialFor(calendar);
|
||||
return password ? [{calendar, icalUrl: `${url}?password=${encodeURIComponent(password)}`}] : [];
|
||||
});
|
||||
|
||||
// These URLs carry the shared passwords; keep them out of every cache on the way.
|
||||
res.set('Cache-Control', 'no-store');
|
||||
res.status(200).send(subscriptions);
|
||||
});
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /calendar/events:
|
||||
|
||||
@@ -13,7 +13,7 @@ export const convertToIcal = async (events: Event[]): Promise<string> => {
|
||||
addEventToFile(ical, event);
|
||||
}
|
||||
|
||||
return toContentLines(serializeIcalFile(ical));
|
||||
return serializeIcalFile(ical);
|
||||
} catch (err) {
|
||||
throw err;
|
||||
}
|
||||
@@ -116,11 +116,9 @@ const addEventToFile = (ical: iCalFile, event: Event) => {
|
||||
* @param event
|
||||
*/
|
||||
const createIcalEvent = (event: Event): iCalEvent => {
|
||||
// Free text is escaped (see escapeText); the URL is a URI value, which has
|
||||
// no escapes - it only must not break the line.
|
||||
let description = event.description ? escapeText(event.description) + '\n' : '';
|
||||
let location = event.location ? escapeText(event.location) + '\n' : '';
|
||||
let url = event.url ? event.url.replace(/[\r\n]+/g, '') + '\n' : '';
|
||||
let description = event.description ? event.description + '\n' : '';
|
||||
let location = event.location ? event.location + '\n' : '';
|
||||
let url = event.url ? event.url + '\n' : '';
|
||||
|
||||
return {
|
||||
header: 'BEGIN:VEVENT\n',
|
||||
@@ -130,7 +128,7 @@ const createIcalEvent = (event: Event): iCalEvent => {
|
||||
start: formatDate(event.startDateTime, event.wholeDay) + '\n',
|
||||
end: formatDate(event.endDateTime, event.wholeDay, true) + '\n',
|
||||
repeatFrequency: event.repeatFrequency ? event.repeatFrequency + '\n' : '',
|
||||
summary: escapeText(event.name) + '\n',
|
||||
summary: event.name + '\n',
|
||||
description: description,
|
||||
location: location,
|
||||
url: url,
|
||||
@@ -145,11 +143,8 @@ const createIcalEvent = (event: Event): iCalEvent => {
|
||||
* @param wholeDayFormat
|
||||
* @param isEndDate
|
||||
*/
|
||||
const formatDate = (input: Date, wholeDayFormat: boolean = false, isEndDate: boolean = false): string => {
|
||||
const formatDate = (date: Date, wholeDayFormat: boolean = false, isEndDate: boolean = false): string => {
|
||||
let returnString = '';
|
||||
// A copy: this used to shift the caller's Date in place, so the event object
|
||||
// came out of the export with its end a day later than it went in.
|
||||
const date = new Date(input);
|
||||
|
||||
// We need to do this for whole day events as otherwise the event ends one day too early
|
||||
if(wholeDayFormat && isEndDate) date.setDate(date.getDate() + 1)
|
||||
@@ -189,58 +184,6 @@ export interface iCalEvent {
|
||||
footer: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* RFC 5545 §3.3.11 TEXT escaping. Without it a description with a line break
|
||||
* ended the DESCRIPTION property early and the rest of the text was read as a
|
||||
* new (garbage) property, and an unescaped `,` or `;` is invalid in the value.
|
||||
* The backslash goes first, or the escapes added after it would be doubled.
|
||||
*/
|
||||
export const escapeText = (value: string): string =>
|
||||
value
|
||||
.replace(/\\/g, '\\\\')
|
||||
.replace(/;/g, '\\;')
|
||||
.replace(/,/g, '\\,')
|
||||
.replace(/\r\n|\r|\n/g, '\\n');
|
||||
|
||||
const MAX_LINE_OCTETS = 75;
|
||||
|
||||
/**
|
||||
* RFC 5545 §3.1 line folding: no content line longer than 75 octets, a longer
|
||||
* one continues on the next line after a single leading space. Counts UTF-8
|
||||
* octets, not characters, and never splits inside a character - "ü" is two
|
||||
* octets and a fold between them corrupts it.
|
||||
*/
|
||||
export const foldLine = (line: string): string => {
|
||||
const parts: string[] = [];
|
||||
let current = '';
|
||||
let octets = 0;
|
||||
for (const char of line) {
|
||||
const size = Buffer.byteLength(char, 'utf8');
|
||||
// A continuation line's leading space counts towards its 75.
|
||||
const limit = parts.length === 0 ? MAX_LINE_OCTETS : MAX_LINE_OCTETS - 1;
|
||||
if (octets + size > limit) {
|
||||
parts.push(current);
|
||||
current = '';
|
||||
octets = 0;
|
||||
}
|
||||
current += char;
|
||||
octets += size;
|
||||
}
|
||||
parts.push(current);
|
||||
return parts.join('\r\n ');
|
||||
};
|
||||
|
||||
/**
|
||||
* The generator builds the file with bare `\n` line ends; the format wants
|
||||
* every content line folded and terminated by CRLF (RFC 5545 §3.1).
|
||||
*/
|
||||
const toContentLines = (ical: string): string =>
|
||||
ical
|
||||
.split('\n')
|
||||
.filter((line) => line !== '')
|
||||
.map(foldLine)
|
||||
.join('\r\n') + '\r\n';
|
||||
|
||||
/**
|
||||
* Checks if a given string is null, undefined or blank
|
||||
* @param str The string to check
|
||||
|
||||
@@ -129,12 +129,13 @@ vouchersAdminRouter.post('/wildcard', async (req: Request, res: Response) => {
|
||||
* type: array
|
||||
* items:
|
||||
* type: object
|
||||
* required: [name, email, eventIds]
|
||||
* required: [name, eventIds]
|
||||
* properties:
|
||||
* name:
|
||||
* type: string
|
||||
* email:
|
||||
* type: string
|
||||
* description: Optional - a guest can supply their own email when redeeming.
|
||||
* eventIds:
|
||||
* type: array
|
||||
* items:
|
||||
|
||||
@@ -123,10 +123,10 @@ export const generatePersonalizedBatch = async (rows: PersonalizedRowInput[], cr
|
||||
throw new Error('at least one row is required');
|
||||
}
|
||||
for (const row of rows) {
|
||||
if (!row.name || !row.email || row.eventIds.length === 0) {
|
||||
throw new Error('each row requires a name, email, and at least one eligible event');
|
||||
if (!row.name || row.eventIds.length === 0) {
|
||||
throw new Error('each row requires a name and at least one eligible event');
|
||||
}
|
||||
if (!isValidEmail(row.email)) {
|
||||
if (row.email && !isValidEmail(row.email)) {
|
||||
throw new Error(`"${row.email}" does not look like a valid email address`);
|
||||
}
|
||||
}
|
||||
@@ -147,7 +147,7 @@ export const generatePersonalizedBatch = async (rows: PersonalizedRowInput[], cr
|
||||
codes.push(code);
|
||||
await conn.query(
|
||||
'INSERT INTO voucher_codes (code, status, max_guests, prefill_name, prefill_email, batch_id, created_by_email) VALUES (?,?,?,?,?,?,?)',
|
||||
[code, 'UNUSED', row.maxGuests, row.name, row.email, batchId, createdByEmail]
|
||||
[code, 'UNUSED', row.maxGuests, row.name, row.email || null, batchId, createdByEmail]
|
||||
);
|
||||
for (const eventId of row.eventIds) {
|
||||
await conn.query('INSERT INTO voucher_code_events (code, event_id) VALUES (?,?)', [code, eventId]);
|
||||
|
||||
@@ -68,8 +68,6 @@ const validEvent = {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
process.env.MEMBER_CREDENTIAL = 'member-secret';
|
||||
process.env.CHOIR_CREDENTIAL = 'choir & more';
|
||||
process.env.MANAGEMENT_CREDENTIAL = '';
|
||||
(EventService.getAllEvents as any).mockResolvedValue([]);
|
||||
(EventService.getAllEventsAdmin as any).mockResolvedValue([]);
|
||||
(EventService.getNextUpcomingEvent as any).mockResolvedValue({eventId: 1, name: 'Konzert'});
|
||||
@@ -166,13 +164,6 @@ describe('reading', () => {
|
||||
expect(auth.api.getSession).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('serves the iCal export as text/calendar', async () => {
|
||||
const res = await request(app).get('/calendar/events/public/ical').expect(200);
|
||||
|
||||
expect(res.headers['content-type']).toBe('text/calendar; charset=utf-8');
|
||||
expect(res.headers['content-disposition']).toBe('attachment; filename=Nachklang_calendar.ics');
|
||||
});
|
||||
|
||||
it('keeps the shared password working on the iCal export', async () => {
|
||||
(EventService.getAllEvents as any).mockResolvedValue([]);
|
||||
|
||||
@@ -231,43 +222,3 @@ describe('writing', () => {
|
||||
.expect(401);
|
||||
});
|
||||
});
|
||||
|
||||
describe('subscriptions', () => {
|
||||
it('is not for anyone signed out, or signed in without the calendar permission', async () => {
|
||||
await request(app).get('/calendar/events/subscriptions').expect(401);
|
||||
|
||||
signedInAs(['tickets']);
|
||||
await request(app).get('/calendar/events/subscriptions').expect(403);
|
||||
});
|
||||
|
||||
it('refuses the shared password as a way in', async () => {
|
||||
// Otherwise one calendar's password would unlock all the others.
|
||||
await request(app).get('/calendar/events/subscriptions').query({password: 'member-secret'}).expect(401);
|
||||
});
|
||||
|
||||
it('lists a ready-made iCal URL per configured calendar, never cached', async () => {
|
||||
signedInAs(['calendar']);
|
||||
|
||||
const res = await request(app).get('/calendar/events/subscriptions').expect(200);
|
||||
|
||||
expect(res.headers['cache-control']).toBe('no-store');
|
||||
expect(res.body).toEqual([
|
||||
{calendar: 'public', icalUrl: 'http://localhost:3000/calendar/events/public/ical'},
|
||||
{calendar: 'members', icalUrl: 'http://localhost:3000/calendar/events/members/ical?password=member-secret'},
|
||||
// URL-encoded, so a password with & or spaces survives.
|
||||
{calendar: 'choir', icalUrl: 'http://localhost:3000/calendar/events/choir/ical?password=choir%20%26%20more'},
|
||||
// management is missing: its credential is unset, and a URL without one could never work.
|
||||
{calendar: 'birthdays', icalUrl: 'http://localhost:3000/calendar/events/birthdays/ical?password=choir%20%26%20more'}
|
||||
]);
|
||||
});
|
||||
|
||||
it('hands out URLs that actually open the calendar', async () => {
|
||||
signedInAs(['calendar']);
|
||||
const res = await request(app).get('/calendar/events/subscriptions').expect(200);
|
||||
const members = res.body.find((s: {calendar: string}) => s.calendar === 'members');
|
||||
|
||||
signedOut();
|
||||
const path = new URL(members.icalUrl);
|
||||
await request(app).get(path.pathname + path.search).expect(200);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,111 +0,0 @@
|
||||
import {describe, expect, it} from 'vitest';
|
||||
|
||||
import {convertToIcal, escapeText, foldLine} from '../../src/models/calendar/events/icalgenerator.service.js';
|
||||
import {Event} from '../../src/models/calendar/events/event.interface.js';
|
||||
|
||||
/**
|
||||
* The iCal export is read by every subscribed calendar app, so a malformed
|
||||
* file is an outage nobody on our side sees. These pin RFC 5545's text
|
||||
* escaping and line folding, which the generator did not do before: a line
|
||||
* break in a description ended the DESCRIPTION property early, and the new
|
||||
* calendar frontend's multi-line description field would have produced
|
||||
* exactly that.
|
||||
*/
|
||||
|
||||
const event = (overrides: Partial<Event> = {}): Event => ({
|
||||
eventId: 1,
|
||||
calendarId: 1,
|
||||
uuid: 'uuid-1',
|
||||
name: 'Konzert',
|
||||
description: '',
|
||||
startDateTime: new Date('2026-10-03T17:00:00Z'),
|
||||
endDateTime: new Date('2026-10-03T19:00:00Z'),
|
||||
createdDate: new Date('2026-01-01T12:00:00Z'),
|
||||
location: '',
|
||||
createdBy: 'Anna',
|
||||
url: '',
|
||||
wholeDay: false,
|
||||
repeatFrequency: '',
|
||||
...overrides
|
||||
});
|
||||
|
||||
/** RFC 5545 unfolding: a CRLF followed by one space or tab joins the lines. */
|
||||
const unfold = (ical: string): string[] => ical.replace(/\r\n[ \t]/g, '').split('\r\n').filter(Boolean);
|
||||
|
||||
describe('escapeText', () => {
|
||||
it('escapes backslash, semicolon, comma and line breaks', () => {
|
||||
expect(escapeText('a\\b;c,d')).toBe('a\\\\b\\;c\\,d');
|
||||
expect(escapeText('Zeile 1\nZeile 2\r\nZeile 3\rZeile 4')).toBe('Zeile 1\\nZeile 2\\nZeile 3\\nZeile 4');
|
||||
});
|
||||
|
||||
it('escapes the backslash first, so the others are not doubled', () => {
|
||||
expect(escapeText(';')).toBe('\\;');
|
||||
});
|
||||
});
|
||||
|
||||
describe('foldLine', () => {
|
||||
it('leaves a short line alone', () => {
|
||||
expect(foldLine('SUMMARY:Konzert')).toBe('SUMMARY:Konzert');
|
||||
});
|
||||
|
||||
it('keeps every physical line within 75 octets and unfolds back to the original', () => {
|
||||
const line = 'DESCRIPTION:' + 'Probe im Gemeindehaus Süd – bitte Noten mitbringen. '.repeat(6);
|
||||
const folded = foldLine(line);
|
||||
for (const physical of folded.split('\r\n')) {
|
||||
expect(Buffer.byteLength(physical, 'utf8')).toBeLessThanOrEqual(75);
|
||||
}
|
||||
expect(folded.replace(/\r\n /g, '')).toBe(line);
|
||||
});
|
||||
|
||||
it('never splits a multi-byte character', () => {
|
||||
const line = 'SUMMARY:' + 'ü'.repeat(80);
|
||||
for (const physical of foldLine(line).split('\r\n')) {
|
||||
expect(physical.replace(/^ /, '')).toMatch(/^(SUMMARY:)?ü*$/);
|
||||
expect(Buffer.byteLength(physical, 'utf8')).toBeLessThanOrEqual(75);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('convertToIcal', () => {
|
||||
it('terminates every line with CRLF', async () => {
|
||||
const ical = await convertToIcal([event()]);
|
||||
expect(ical.endsWith('END:VCALENDAR\r\n')).toBe(true);
|
||||
expect(ical.replace(/\r\n/g, '')).not.toContain('\n');
|
||||
});
|
||||
|
||||
it('keeps a multi-line description inside one DESCRIPTION property', async () => {
|
||||
const ical = await convertToIcal([event({description: 'Einlass 18:30\nBeginn 19:00; Eintritt frei, Spenden willkommen'})]);
|
||||
const lines = unfold(ical);
|
||||
expect(lines).toContain('DESCRIPTION:Einlass 18:30\\nBeginn 19:00\\; Eintritt frei\\, Spenden willkommen');
|
||||
// Nothing leaked out as a line of its own.
|
||||
expect(lines.some((l) => l.startsWith('Beginn'))).toBe(false);
|
||||
});
|
||||
|
||||
it('escapes the title and the location too', async () => {
|
||||
const lines = unfold(await convertToIcal([event({name: 'Konzert, Teil 2', location: 'Kirche; Karlsruhe'})]));
|
||||
expect(lines).toContain('SUMMARY:Konzert\\, Teil 2');
|
||||
expect(lines).toContain('LOCATION:Kirche\\; Karlsruhe');
|
||||
});
|
||||
|
||||
it('does not escape the URL, but keeps it on one line', async () => {
|
||||
const lines = unfold(await convertToIcal([event({url: 'https://nachklang.art/konzert?a=1,2'})]));
|
||||
expect(lines).toContain('URL:https://nachklang.art/konzert?a=1,2');
|
||||
});
|
||||
|
||||
it('still writes the yearly rule for birthdays', async () => {
|
||||
const lines = unfold(await convertToIcal([event({repeatFrequency: 'YEARLY', wholeDay: true})]));
|
||||
expect(lines).toContain('RRULE:FREQ=YEARLY');
|
||||
});
|
||||
|
||||
it('leaves the event\'s own dates untouched', async () => {
|
||||
// formatDate used to add the whole-day end's extra day to the caller's Date in place.
|
||||
const whole = event({
|
||||
wholeDay: true,
|
||||
startDateTime: new Date('2026-10-03T00:00:00'),
|
||||
endDateTime: new Date('2026-10-03T23:59:00')
|
||||
});
|
||||
const endBefore = whole.endDateTime.getTime();
|
||||
await convertToIcal([whole]);
|
||||
expect(whole.endDateTime.getTime()).toBe(endBefore);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user