caldav-adapter 8.3.0 → 8.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1254 @@
1
+ const test = require('ava');
2
+ const { DOMParser } = require('@xmldom/xmldom');
3
+ const xpath = require('xpath');
4
+
5
+ // Use the same namespace configuration as caldav-adapter
6
+ const namespaces = {
7
+ D: 'DAV:',
8
+ CAL: 'urn:ietf:params:xml:ns:caldav',
9
+ CS: 'http://calendarserver.org/ns/',
10
+ ICAL: 'http://apple.com/ns/ical/'
11
+ };
12
+
13
+ const select = xpath.useNamespaces(namespaces);
14
+
15
+ // Helper to convert NodeList to array (xmldom NodeList is not iterable with spread)
16
+ function nodeListToArray(nodeList) {
17
+ const result = [];
18
+ if (nodeList && nodeList.length > 0) {
19
+ for (let i = 0; i < nodeList.length; i++) {
20
+ result.push(nodeList.item(i));
21
+ }
22
+ }
23
+
24
+ return result;
25
+ }
26
+
27
+ // Helper to parse XML and get children like proppatch.js does
28
+ function getProppatchChildren(
29
+ xmlBody,
30
+ path = '/D:propertyupdate/D:set/D:prop'
31
+ ) {
32
+ const parser = new DOMParser();
33
+ const doc = parser.parseFromString(xmlBody, 'text/xml');
34
+ const propNode = select(path, doc);
35
+ return propNode[0] ? nodeListToArray(propNode[0].childNodes) : [];
36
+ }
37
+
38
+ // Helper to get both set and remove children
39
+ function getProppatchAllChildren(xmlBody) {
40
+ const parser = new DOMParser();
41
+ const doc = parser.parseFromString(xmlBody, 'text/xml');
42
+
43
+ const setNode = select('/D:propertyupdate/D:set/D:prop', doc);
44
+ const removeNode = select('/D:propertyupdate/D:remove/D:prop', doc);
45
+
46
+ const setChildren = setNode[0] ? nodeListToArray(setNode[0].childNodes) : [];
47
+ const removeChildren = removeNode[0]
48
+ ? nodeListToArray(removeNode[0].childNodes)
49
+ : [];
50
+
51
+ return { setChildren, removeChildren };
52
+ }
53
+
54
+ // Protected properties per RFC 4791
55
+ const PROTECTED_PROPERTIES = new Set([
56
+ 'supported-calendar-component-set',
57
+ 'supported-calendar-data',
58
+ 'max-resource-size',
59
+ 'min-date-time',
60
+ 'max-date-time',
61
+ 'max-instances',
62
+ 'max-attendees-per-instance',
63
+ 'getctag',
64
+ 'getetag',
65
+ 'getcontenttype',
66
+ 'getcontentlength',
67
+ 'getlastmodified',
68
+ 'creationdate',
69
+ 'resourcetype',
70
+ 'sync-token',
71
+ 'current-user-privilege-set',
72
+ 'owner',
73
+ 'supported-report-set'
74
+ ]);
75
+
76
+ // Modifiable properties
77
+ const MODIFIABLE_PROPERTIES = new Set([
78
+ 'displayname',
79
+ 'calendar-description',
80
+ 'calendar-timezone',
81
+ 'calendar-color',
82
+ 'calendar-order'
83
+ ]);
84
+
85
+ // Helper to simulate the fixed proppatch logic
86
+ function simulateProppatch(setChildren, removeChildren = []) {
87
+ const updates = {};
88
+
89
+ // Process set children
90
+ for (const child of setChildren) {
91
+ if (!child.localName) continue;
92
+ if (PROTECTED_PROPERTIES.has(child.localName)) continue;
93
+
94
+ switch (child.localName) {
95
+ case 'displayname': {
96
+ updates.name = child.textContent;
97
+ break;
98
+ }
99
+
100
+ case 'calendar-description': {
101
+ updates.description = child.textContent;
102
+ break;
103
+ }
104
+
105
+ case 'calendar-timezone': {
106
+ updates.timezone = child.textContent;
107
+ break;
108
+ }
109
+
110
+ case 'calendar-color': {
111
+ updates.color = child.textContent;
112
+ break;
113
+ }
114
+
115
+ case 'calendar-order': {
116
+ updates.order = child.textContent
117
+ ? Number.parseInt(child.textContent, 10)
118
+ : null;
119
+ break;
120
+ }
121
+
122
+ // No default
123
+ }
124
+ }
125
+
126
+ // Process remove children
127
+ for (const child of removeChildren) {
128
+ if (!child.localName) continue;
129
+ if (PROTECTED_PROPERTIES.has(child.localName)) continue;
130
+
131
+ switch (child.localName) {
132
+ case 'displayname': {
133
+ updates.name = '';
134
+ break;
135
+ }
136
+
137
+ case 'calendar-description': {
138
+ updates.description = '';
139
+ break;
140
+ }
141
+
142
+ case 'calendar-timezone': {
143
+ updates.timezone = '';
144
+ break;
145
+ }
146
+
147
+ case 'calendar-color': {
148
+ updates.color = '';
149
+ break;
150
+ }
151
+
152
+ case 'calendar-order': {
153
+ updates.order = null;
154
+ break;
155
+ }
156
+
157
+ // No default
158
+ }
159
+ }
160
+
161
+ return updates;
162
+ }
163
+
164
+ // =============================================================================
165
+ // RFC 4918 Section 9.2 - PROPPATCH Method Tests
166
+ // =============================================================================
167
+
168
+ test('RFC 4918 9.2: PROPPATCH should process set instructions', (t) => {
169
+ const xml = `<?xml version="1.0" encoding="UTF-8"?>
170
+ <D:propertyupdate xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
171
+ <D:set>
172
+ <D:prop>
173
+ <D:displayname>My Calendar</D:displayname>
174
+ </D:prop>
175
+ </D:set>
176
+ </D:propertyupdate>`;
177
+
178
+ const children = getProppatchChildren(xml);
179
+ const updates = simulateProppatch(children);
180
+
181
+ t.is(updates.name, 'My Calendar');
182
+ });
183
+
184
+ test('RFC 4918 9.2: PROPPATCH should process remove instructions', (t) => {
185
+ const xml = `<?xml version="1.0" encoding="UTF-8"?>
186
+ <D:propertyupdate xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
187
+ <D:remove>
188
+ <D:prop>
189
+ <C:calendar-description/>
190
+ </D:prop>
191
+ </D:remove>
192
+ </D:propertyupdate>`;
193
+
194
+ const { removeChildren } = getProppatchAllChildren(xml);
195
+ const updates = simulateProppatch([], removeChildren);
196
+
197
+ t.true('description' in updates);
198
+ t.is(updates.description, '');
199
+ });
200
+
201
+ test('RFC 4918 9.2: PROPPATCH should process both set and remove in same request', (t) => {
202
+ const xml = `<?xml version="1.0" encoding="UTF-8"?>
203
+ <D:propertyupdate xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
204
+ <D:set>
205
+ <D:prop>
206
+ <D:displayname>Work Calendar</D:displayname>
207
+ </D:prop>
208
+ </D:set>
209
+ <D:remove>
210
+ <D:prop>
211
+ <C:calendar-description/>
212
+ </D:prop>
213
+ </D:remove>
214
+ </D:propertyupdate>`;
215
+
216
+ const { setChildren, removeChildren } = getProppatchAllChildren(xml);
217
+ const updates = simulateProppatch(setChildren, removeChildren);
218
+
219
+ t.is(updates.name, 'Work Calendar');
220
+ t.is(updates.description, '');
221
+ });
222
+
223
+ // =============================================================================
224
+ // RFC 4918 Section 14.26 - set XML Element Tests
225
+ // =============================================================================
226
+
227
+ test('RFC 4918 14.26: Empty property value should be valid (self-closing)', (t) => {
228
+ const xml = `<?xml version="1.0" encoding="UTF-8"?>
229
+ <A:propertyupdate xmlns:A="DAV:"><A:set><A:prop><B:calendar-description xmlns:B="urn:ietf:params:xml:ns:caldav"/></A:prop></A:set></A:propertyupdate>`;
230
+
231
+ const children = getProppatchChildren(xml);
232
+ t.is(children.length, 1);
233
+ t.is(children[0].localName, 'calendar-description');
234
+ t.is(children[0].textContent, '');
235
+
236
+ const updates = simulateProppatch(children);
237
+ t.true('description' in updates);
238
+ t.is(updates.description, '');
239
+ });
240
+
241
+ test('RFC 4918 14.26: Empty property value should be valid (explicit empty)', (t) => {
242
+ const xml = `<?xml version="1.0" encoding="UTF-8"?>
243
+ <D:propertyupdate xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
244
+ <D:set>
245
+ <D:prop>
246
+ <C:calendar-description></C:calendar-description>
247
+ </D:prop>
248
+ </D:set>
249
+ </D:propertyupdate>`;
250
+
251
+ const children = getProppatchChildren(xml);
252
+ const descChild = children.find(
253
+ (c) => c.localName === 'calendar-description'
254
+ );
255
+ t.truthy(descChild);
256
+ t.is(descChild.textContent, '');
257
+
258
+ const updates = simulateProppatch(children);
259
+ t.true('description' in updates);
260
+ t.is(updates.description, '');
261
+ });
262
+
263
+ test('RFC 4918 14.26: Setting property should replace existing value', (t) => {
264
+ const xml = `<?xml version="1.0" encoding="UTF-8"?>
265
+ <D:propertyupdate xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
266
+ <D:set>
267
+ <D:prop>
268
+ <C:calendar-description>New Description</C:calendar-description>
269
+ </D:prop>
270
+ </D:set>
271
+ </D:propertyupdate>`;
272
+
273
+ const children = getProppatchChildren(xml);
274
+ const updates = simulateProppatch(children);
275
+
276
+ t.is(updates.description, 'New Description');
277
+ });
278
+
279
+ // =============================================================================
280
+ // RFC 4918 Section 14.23 - remove XML Element Tests
281
+ // =============================================================================
282
+
283
+ test('RFC 4918 14.23: Remove instruction should clear property', (t) => {
284
+ const xml = `<?xml version="1.0" encoding="UTF-8"?>
285
+ <D:propertyupdate xmlns:D="DAV:">
286
+ <D:remove>
287
+ <D:prop>
288
+ <D:displayname/>
289
+ </D:prop>
290
+ </D:remove>
291
+ </D:propertyupdate>`;
292
+
293
+ const { removeChildren } = getProppatchAllChildren(xml);
294
+ const updates = simulateProppatch([], removeChildren);
295
+
296
+ t.true('name' in updates);
297
+ t.is(updates.name, '');
298
+ });
299
+
300
+ test('RFC 4918 14.23: Remove elements should be empty (only names needed)', (t) => {
301
+ const xml = `<?xml version="1.0" encoding="UTF-8"?>
302
+ <D:propertyupdate xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
303
+ <D:remove>
304
+ <D:prop>
305
+ <C:calendar-description/>
306
+ <C:calendar-timezone/>
307
+ </D:prop>
308
+ </D:remove>
309
+ </D:propertyupdate>`;
310
+
311
+ const { removeChildren } = getProppatchAllChildren(xml);
312
+ const elementChildren = removeChildren.filter((c) => c.localName);
313
+
314
+ // All remove elements should be empty
315
+ for (const child of elementChildren) {
316
+ t.is(child.textContent, '');
317
+ }
318
+
319
+ const updates = simulateProppatch([], removeChildren);
320
+ t.is(updates.description, '');
321
+ t.is(updates.timezone, '');
322
+ });
323
+
324
+ test('RFC 4918 14.23: Removing non-existent property should not error', (t) => {
325
+ const xml = `<?xml version="1.0" encoding="UTF-8"?>
326
+ <D:propertyupdate xmlns:D="DAV:" xmlns:X="http://example.com/ns/">
327
+ <D:remove>
328
+ <D:prop>
329
+ <X:nonexistent-property/>
330
+ </D:prop>
331
+ </D:remove>
332
+ </D:propertyupdate>`;
333
+
334
+ const { removeChildren } = getProppatchAllChildren(xml);
335
+
336
+ // Should not throw
337
+ t.notThrows(() => {
338
+ simulateProppatch([], removeChildren);
339
+ });
340
+ });
341
+
342
+ // =============================================================================
343
+ // RFC 4791 Section 5.2.1 - calendar-description Property Tests
344
+ // =============================================================================
345
+
346
+ test('RFC 4791 5.2.1: calendar-description allows #PCDATA (empty content)', (t) => {
347
+ const xml = `<?xml version="1.0" encoding="UTF-8"?>
348
+ <D:propertyupdate xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
349
+ <D:set>
350
+ <D:prop>
351
+ <C:calendar-description/>
352
+ </D:prop>
353
+ </D:set>
354
+ </D:propertyupdate>`;
355
+
356
+ const children = getProppatchChildren(xml);
357
+ const updates = simulateProppatch(children);
358
+
359
+ t.is(updates.description, '');
360
+ });
361
+
362
+ test('RFC 4791 5.2.1: calendar-description with content', (t) => {
363
+ const xml = `<?xml version="1.0" encoding="UTF-8"?>
364
+ <D:propertyupdate xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
365
+ <D:set>
366
+ <D:prop>
367
+ <C:calendar-description>My Calendar Description</C:calendar-description>
368
+ </D:prop>
369
+ </D:set>
370
+ </D:propertyupdate>`;
371
+
372
+ const children = getProppatchChildren(xml);
373
+ const updates = simulateProppatch(children);
374
+
375
+ t.is(updates.description, 'My Calendar Description');
376
+ });
377
+
378
+ // =============================================================================
379
+ // RFC 4791 Section 5.2.2 - calendar-timezone Property Tests
380
+ // =============================================================================
381
+
382
+ test('RFC 4791 5.2.2: calendar-timezone with VTIMEZONE content', (t) => {
383
+ const vtimezone = `BEGIN:VCALENDAR
384
+ VERSION:2.0
385
+ BEGIN:VTIMEZONE
386
+ TZID:America/New_York
387
+ END:VTIMEZONE
388
+ END:VCALENDAR`;
389
+
390
+ const xml = `<?xml version="1.0" encoding="UTF-8"?>
391
+ <D:propertyupdate xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
392
+ <D:set>
393
+ <D:prop>
394
+ <C:calendar-timezone>${vtimezone}</C:calendar-timezone>
395
+ </D:prop>
396
+ </D:set>
397
+ </D:propertyupdate>`;
398
+
399
+ const children = getProppatchChildren(xml);
400
+ const updates = simulateProppatch(children);
401
+
402
+ t.true(updates.timezone.includes('VTIMEZONE'));
403
+ t.true(updates.timezone.includes('America/New_York'));
404
+ });
405
+
406
+ // =============================================================================
407
+ // RFC 4791 Section 5.2.3 - Protected Properties Tests
408
+ // =============================================================================
409
+
410
+ test('RFC 4791 5.2.3: supported-calendar-component-set is protected', (t) => {
411
+ t.true(PROTECTED_PROPERTIES.has('supported-calendar-component-set'));
412
+ });
413
+
414
+ test('RFC 4791 5.2.3: Protected properties should be rejected', (t) => {
415
+ const xml = `<?xml version="1.0" encoding="UTF-8"?>
416
+ <D:propertyupdate xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
417
+ <D:set>
418
+ <D:prop>
419
+ <C:supported-calendar-component-set>
420
+ <C:comp name="VEVENT"/>
421
+ </C:supported-calendar-component-set>
422
+ </D:prop>
423
+ </D:set>
424
+ </D:propertyupdate>`;
425
+
426
+ const children = getProppatchChildren(xml);
427
+ const updates = simulateProppatch(children);
428
+
429
+ // Protected property should not be in updates
430
+ t.false('supported-calendar-component-set' in updates);
431
+ });
432
+
433
+ // =============================================================================
434
+ // Apple/CalendarServer Extensions Tests
435
+ // =============================================================================
436
+
437
+ test('Apple Extension: calendar-color with value', (t) => {
438
+ const xml = `<?xml version="1.0" encoding="UTF-8"?>
439
+ <D:propertyupdate xmlns:D="DAV:" xmlns:A="http://apple.com/ns/ical/">
440
+ <D:set>
441
+ <D:prop>
442
+ <A:calendar-color>#FF5733FF</A:calendar-color>
443
+ </D:prop>
444
+ </D:set>
445
+ </D:propertyupdate>`;
446
+
447
+ const children = getProppatchChildren(xml);
448
+ const updates = simulateProppatch(children);
449
+
450
+ t.is(updates.color, '#FF5733FF');
451
+ });
452
+
453
+ test('Apple Extension: calendar-color empty (clear color)', (t) => {
454
+ const xml = `<?xml version="1.0" encoding="UTF-8"?>
455
+ <D:propertyupdate xmlns:D="DAV:" xmlns:A="http://apple.com/ns/ical/">
456
+ <D:set>
457
+ <D:prop>
458
+ <A:calendar-color/>
459
+ </D:prop>
460
+ </D:set>
461
+ </D:propertyupdate>`;
462
+
463
+ const children = getProppatchChildren(xml);
464
+ const updates = simulateProppatch(children);
465
+
466
+ t.true('color' in updates);
467
+ t.is(updates.color, '');
468
+ });
469
+
470
+ test('Apple Extension: calendar-order with integer value', (t) => {
471
+ const xml = `<?xml version="1.0" encoding="UTF-8"?>
472
+ <D:propertyupdate xmlns:D="DAV:" xmlns:A="http://apple.com/ns/ical/">
473
+ <D:set>
474
+ <D:prop>
475
+ <A:calendar-order>5</A:calendar-order>
476
+ </D:prop>
477
+ </D:set>
478
+ </D:propertyupdate>`;
479
+
480
+ const children = getProppatchChildren(xml);
481
+ const updates = simulateProppatch(children);
482
+
483
+ t.is(updates.order, 5);
484
+ t.is(typeof updates.order, 'number');
485
+ });
486
+
487
+ test('Apple Extension: calendar-order empty (set to null)', (t) => {
488
+ const xml = `<?xml version="1.0" encoding="UTF-8"?>
489
+ <D:propertyupdate xmlns:D="DAV:" xmlns:A="http://apple.com/ns/ical/">
490
+ <D:set>
491
+ <D:prop>
492
+ <A:calendar-order/>
493
+ </D:prop>
494
+ </D:set>
495
+ </D:propertyupdate>`;
496
+
497
+ const children = getProppatchChildren(xml);
498
+ const updates = simulateProppatch(children);
499
+
500
+ t.true('order' in updates);
501
+ t.is(updates.order, null);
502
+ });
503
+
504
+ // =============================================================================
505
+ // DAV:displayname Property Tests
506
+ // =============================================================================
507
+
508
+ test('DAV:displayname with value', (t) => {
509
+ const xml = `<?xml version="1.0" encoding="UTF-8"?>
510
+ <D:propertyupdate xmlns:D="DAV:">
511
+ <D:set>
512
+ <D:prop>
513
+ <D:displayname>Work Calendar</D:displayname>
514
+ </D:prop>
515
+ </D:set>
516
+ </D:propertyupdate>`;
517
+
518
+ const children = getProppatchChildren(xml);
519
+ const updates = simulateProppatch(children);
520
+
521
+ t.is(updates.name, 'Work Calendar');
522
+ });
523
+
524
+ test('DAV:displayname empty', (t) => {
525
+ const xml = `<?xml version="1.0" encoding="UTF-8"?>
526
+ <D:propertyupdate xmlns:D="DAV:">
527
+ <D:set>
528
+ <D:prop>
529
+ <D:displayname/>
530
+ </D:prop>
531
+ </D:set>
532
+ </D:propertyupdate>`;
533
+
534
+ const children = getProppatchChildren(xml);
535
+ const updates = simulateProppatch(children);
536
+
537
+ t.true('name' in updates);
538
+ t.is(updates.name, '');
539
+ });
540
+
541
+ // =============================================================================
542
+ // Multiple Properties Tests
543
+ // =============================================================================
544
+
545
+ test('Multiple properties in single PROPPATCH', (t) => {
546
+ const xml = `<?xml version="1.0" encoding="UTF-8"?>
547
+ <D:propertyupdate xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav" xmlns:A="http://apple.com/ns/ical/">
548
+ <D:set>
549
+ <D:prop>
550
+ <D:displayname>Work Calendar</D:displayname>
551
+ <C:calendar-description>My work events</C:calendar-description>
552
+ <A:calendar-color>#0000FFFF</A:calendar-color>
553
+ <A:calendar-order>1</A:calendar-order>
554
+ </D:prop>
555
+ </D:set>
556
+ </D:propertyupdate>`;
557
+
558
+ const children = getProppatchChildren(xml);
559
+ const updates = simulateProppatch(children);
560
+
561
+ t.is(updates.name, 'Work Calendar');
562
+ t.is(updates.description, 'My work events');
563
+ t.is(updates.color, '#0000FFFF');
564
+ t.is(updates.order, 1);
565
+ });
566
+
567
+ test('Mixed empty and non-empty properties', (t) => {
568
+ const xml = `<?xml version="1.0" encoding="UTF-8"?>
569
+ <D:propertyupdate xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav" xmlns:A="http://apple.com/ns/ical/">
570
+ <D:set>
571
+ <D:prop>
572
+ <D:displayname>My Calendar</D:displayname>
573
+ <C:calendar-description/>
574
+ <A:calendar-color>#FF0000FF</A:calendar-color>
575
+ </D:prop>
576
+ </D:set>
577
+ </D:propertyupdate>`;
578
+
579
+ const children = getProppatchChildren(xml);
580
+ const updates = simulateProppatch(children);
581
+
582
+ t.is(updates.name, 'My Calendar');
583
+ t.is(updates.description, '');
584
+ t.is(updates.color, '#FF0000FF');
585
+ });
586
+
587
+ // =============================================================================
588
+ // Whitespace Handling Tests
589
+ // =============================================================================
590
+
591
+ test('Whitespace preservation in property values', (t) => {
592
+ const xml = `<?xml version="1.0" encoding="UTF-8"?>
593
+ <D:propertyupdate xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
594
+ <D:set>
595
+ <D:prop>
596
+ <C:calendar-description> spaced </C:calendar-description>
597
+ </D:prop>
598
+ </D:set>
599
+ </D:propertyupdate>`;
600
+
601
+ const children = getProppatchChildren(xml);
602
+ const updates = simulateProppatch(children);
603
+
604
+ t.is(updates.description, ' spaced ');
605
+ });
606
+
607
+ // =============================================================================
608
+ // Namespace Handling Tests
609
+ // =============================================================================
610
+
611
+ test('Different namespace prefixes for same namespace', (t) => {
612
+ // Using 'B' instead of 'C' for caldav namespace
613
+ const xml = `<?xml version="1.0" encoding="UTF-8"?>
614
+ <A:propertyupdate xmlns:A="DAV:">
615
+ <A:set>
616
+ <A:prop>
617
+ <B:calendar-description xmlns:B="urn:ietf:params:xml:ns:caldav">Test Description</B:calendar-description>
618
+ </A:prop>
619
+ </A:set>
620
+ </A:propertyupdate>`;
621
+
622
+ const children = getProppatchChildren(xml);
623
+ const updates = simulateProppatch(children);
624
+
625
+ t.is(updates.description, 'Test Description');
626
+ });
627
+
628
+ test('Unknown namespace properties should be ignored gracefully', (t) => {
629
+ const xml = `<?xml version="1.0" encoding="UTF-8"?>
630
+ <D:propertyupdate xmlns:D="DAV:" xmlns:X="http://example.com/ns/">
631
+ <D:set>
632
+ <D:prop>
633
+ <D:displayname>My Calendar</D:displayname>
634
+ <X:unknown-property>some value</X:unknown-property>
635
+ </D:prop>
636
+ </D:set>
637
+ </D:propertyupdate>`;
638
+
639
+ const children = getProppatchChildren(xml);
640
+ const updates = simulateProppatch(children);
641
+
642
+ // Should still process known properties
643
+ t.is(updates.name, 'My Calendar');
644
+ // Unknown property should not be in updates
645
+ t.false('unknown-property' in updates);
646
+ });
647
+
648
+ // =============================================================================
649
+ // Document Order Processing Tests (RFC 4918 Section 9.2)
650
+ // =============================================================================
651
+
652
+ test('RFC 4918 9.2: Instructions processed in document order', (t) => {
653
+ // Set then remove should result in empty
654
+ const xml1 = `<?xml version="1.0" encoding="UTF-8"?>
655
+ <D:propertyupdate xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
656
+ <D:set>
657
+ <D:prop>
658
+ <C:calendar-description>First Value</C:calendar-description>
659
+ </D:prop>
660
+ </D:set>
661
+ <D:remove>
662
+ <D:prop>
663
+ <C:calendar-description/>
664
+ </D:prop>
665
+ </D:remove>
666
+ </D:propertyupdate>`;
667
+
668
+ const { setChildren: set1, removeChildren: remove1 } =
669
+ getProppatchAllChildren(xml1);
670
+ const updates1 = simulateProppatch(set1, remove1);
671
+
672
+ // Remove comes after set, so should be empty
673
+ t.is(updates1.description, '');
674
+ });
675
+
676
+ // =============================================================================
677
+ // Protected Properties Complete List Tests
678
+ // =============================================================================
679
+
680
+ test('All RFC 4791 protected properties are in PROTECTED_PROPERTIES set', (t) => {
681
+ const rfc4791Protected = [
682
+ 'supported-calendar-component-set',
683
+ 'supported-calendar-data',
684
+ 'max-resource-size',
685
+ 'min-date-time',
686
+ 'max-date-time',
687
+ 'max-instances',
688
+ 'max-attendees-per-instance'
689
+ ];
690
+
691
+ for (const prop of rfc4791Protected) {
692
+ t.true(
693
+ PROTECTED_PROPERTIES.has(prop),
694
+ `${prop} should be in PROTECTED_PROPERTIES`
695
+ );
696
+ }
697
+ });
698
+
699
+ test('All DAV protected properties are in PROTECTED_PROPERTIES set', (t) => {
700
+ const davProtected = [
701
+ 'getetag',
702
+ 'getcontenttype',
703
+ 'getcontentlength',
704
+ 'getlastmodified',
705
+ 'creationdate',
706
+ 'resourcetype',
707
+ 'sync-token',
708
+ 'current-user-privilege-set',
709
+ 'owner',
710
+ 'supported-report-set'
711
+ ];
712
+
713
+ for (const prop of davProtected) {
714
+ t.true(
715
+ PROTECTED_PROPERTIES.has(prop),
716
+ `${prop} should be in PROTECTED_PROPERTIES`
717
+ );
718
+ }
719
+ });
720
+
721
+ // =============================================================================
722
+ // Modifiable Properties Complete List Tests
723
+ // =============================================================================
724
+
725
+ test('All modifiable properties are in MODIFIABLE_PROPERTIES set', (t) => {
726
+ const modifiable = [
727
+ 'displayname',
728
+ 'calendar-description',
729
+ 'calendar-timezone',
730
+ 'calendar-color',
731
+ 'calendar-order'
732
+ ];
733
+
734
+ for (const prop of modifiable) {
735
+ t.true(
736
+ MODIFIABLE_PROPERTIES.has(prop),
737
+ `${prop} should be in MODIFIABLE_PROPERTIES`
738
+ );
739
+ }
740
+ });
741
+
742
+ // =============================================================================
743
+ // Edge Cases Tests
744
+ // =============================================================================
745
+
746
+ test('Empty propertyupdate should not throw', (t) => {
747
+ const xml = `<?xml version="1.0" encoding="UTF-8"?>
748
+ <D:propertyupdate xmlns:D="DAV:">
749
+ <D:set>
750
+ <D:prop>
751
+ </D:prop>
752
+ </D:set>
753
+ </D:propertyupdate>`;
754
+
755
+ const children = getProppatchChildren(xml);
756
+
757
+ t.notThrows(() => {
758
+ simulateProppatch(children);
759
+ });
760
+ });
761
+
762
+ test('Whitespace-only text nodes should be ignored', (t) => {
763
+ const xml = `<?xml version="1.0" encoding="UTF-8"?>
764
+ <D:propertyupdate xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
765
+ <D:set>
766
+ <D:prop>
767
+ <C:calendar-description>Test</C:calendar-description>
768
+ </D:prop>
769
+ </D:set>
770
+ </D:propertyupdate>`;
771
+
772
+ const children = getProppatchChildren(xml);
773
+ // Filter to only element nodes (nodeType 1)
774
+ const elementChildren = children.filter((c) => c.nodeType === 1);
775
+
776
+ t.is(elementChildren.length, 1);
777
+ t.is(elementChildren[0].localName, 'calendar-description');
778
+ });
779
+
780
+ test('Special characters in property values should be preserved', (t) => {
781
+ const xml = `<?xml version="1.0" encoding="UTF-8"?>
782
+ <D:propertyupdate xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
783
+ <D:set>
784
+ <D:prop>
785
+ <C:calendar-description>Test &amp; Description &lt;with&gt; "special" chars</C:calendar-description>
786
+ </D:prop>
787
+ </D:set>
788
+ </D:propertyupdate>`;
789
+
790
+ const children = getProppatchChildren(xml);
791
+ const updates = simulateProppatch(children);
792
+
793
+ t.is(updates.description, 'Test & Description <with> "special" chars');
794
+ });
795
+
796
+ test('Unicode characters in property values', (t) => {
797
+ const xml = `<?xml version="1.0" encoding="UTF-8"?>
798
+ <D:propertyupdate xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
799
+ <D:set>
800
+ <D:prop>
801
+ <C:calendar-description>日本語カレンダー 📅</C:calendar-description>
802
+ </D:prop>
803
+ </D:set>
804
+ </D:propertyupdate>`;
805
+
806
+ const children = getProppatchChildren(xml);
807
+ const updates = simulateProppatch(children);
808
+
809
+ t.is(updates.description, '日本語カレンダー 📅');
810
+ });
811
+
812
+ test('Newlines in property values should be preserved', (t) => {
813
+ const xml = `<?xml version="1.0" encoding="UTF-8"?>
814
+ <D:propertyupdate xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
815
+ <D:set>
816
+ <D:prop>
817
+ <C:calendar-description>Line 1
818
+ Line 2
819
+ Line 3</C:calendar-description>
820
+ </D:prop>
821
+ </D:set>
822
+ </D:propertyupdate>`;
823
+
824
+ const children = getProppatchChildren(xml);
825
+ const updates = simulateProppatch(children);
826
+
827
+ t.true(updates.description.includes('\n'));
828
+ t.true(updates.description.includes('Line 1'));
829
+ t.true(updates.description.includes('Line 2'));
830
+ t.true(updates.description.includes('Line 3'));
831
+ });
832
+
833
+ // =============================================================================
834
+ // Remove Multiple Properties Tests
835
+ // =============================================================================
836
+
837
+ test('Remove multiple properties at once', (t) => {
838
+ const xml = `<?xml version="1.0" encoding="UTF-8"?>
839
+ <D:propertyupdate xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav" xmlns:A="http://apple.com/ns/ical/">
840
+ <D:remove>
841
+ <D:prop>
842
+ <D:displayname/>
843
+ <C:calendar-description/>
844
+ <A:calendar-color/>
845
+ <A:calendar-order/>
846
+ </D:prop>
847
+ </D:remove>
848
+ </D:propertyupdate>`;
849
+
850
+ const { removeChildren } = getProppatchAllChildren(xml);
851
+ const updates = simulateProppatch([], removeChildren);
852
+
853
+ t.is(updates.name, '');
854
+ t.is(updates.description, '');
855
+ t.is(updates.color, '');
856
+ t.is(updates.order, null);
857
+ });
858
+
859
+ // =============================================================================
860
+ // Complex Mixed Operations Tests
861
+ // =============================================================================
862
+
863
+ test('Complex: Set some properties, remove others', (t) => {
864
+ const xml = `<?xml version="1.0" encoding="UTF-8"?>
865
+ <D:propertyupdate xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav" xmlns:A="http://apple.com/ns/ical/">
866
+ <D:set>
867
+ <D:prop>
868
+ <D:displayname>New Name</D:displayname>
869
+ <A:calendar-color>#123456FF</A:calendar-color>
870
+ </D:prop>
871
+ </D:set>
872
+ <D:remove>
873
+ <D:prop>
874
+ <C:calendar-description/>
875
+ <A:calendar-order/>
876
+ </D:prop>
877
+ </D:remove>
878
+ </D:propertyupdate>`;
879
+
880
+ const { setChildren, removeChildren } = getProppatchAllChildren(xml);
881
+ const updates = simulateProppatch(setChildren, removeChildren);
882
+
883
+ t.is(updates.name, 'New Name');
884
+ t.is(updates.color, '#123456FF');
885
+ t.is(updates.description, '');
886
+ t.is(updates.order, null);
887
+ });
888
+
889
+ // =============================================================================
890
+ // RFC 4791 Section 5.2.2 - calendar-timezone Validation Tests
891
+ // =============================================================================
892
+
893
+ /**
894
+ * Validate VTIMEZONE content per RFC 4791 Section 5.2.2
895
+ */
896
+ function validateCalendarTimezone(value) {
897
+ if (!value || value.trim() === '') {
898
+ return { valid: true, error: null };
899
+ }
900
+
901
+ if (!value.includes('BEGIN:VCALENDAR') || !value.includes('END:VCALENDAR')) {
902
+ return {
903
+ valid: false,
904
+ error:
905
+ 'calendar-timezone must be a valid iCalendar object (missing VCALENDAR)'
906
+ };
907
+ }
908
+
909
+ const vtimezoneCount = (value.match(/BEGIN:VTIMEZONE/g) || []).length;
910
+ if (vtimezoneCount === 0) {
911
+ return {
912
+ valid: false,
913
+ error: 'calendar-timezone must contain a VTIMEZONE component'
914
+ };
915
+ }
916
+
917
+ if (vtimezoneCount > 1) {
918
+ return {
919
+ valid: false,
920
+ error: 'calendar-timezone must contain exactly one VTIMEZONE component'
921
+ };
922
+ }
923
+
924
+ const endVtimezoneCount = (value.match(/END:VTIMEZONE/g) || []).length;
925
+ if (vtimezoneCount !== endVtimezoneCount) {
926
+ return {
927
+ valid: false,
928
+ error: 'calendar-timezone has malformed VTIMEZONE component'
929
+ };
930
+ }
931
+
932
+ if (!value.includes('TZID:') && !value.includes('TZID;')) {
933
+ return {
934
+ valid: false,
935
+ error: 'VTIMEZONE component must have a TZID property'
936
+ };
937
+ }
938
+
939
+ return { valid: true, error: null };
940
+ }
941
+
942
+ test('RFC 4791 5.2.2: Empty timezone is valid (clearing)', (t) => {
943
+ const result = validateCalendarTimezone('');
944
+ t.true(result.valid);
945
+ t.is(result.error, null);
946
+ });
947
+
948
+ test('RFC 4791 5.2.2: Valid VTIMEZONE passes validation', (t) => {
949
+ const validTimezone = `BEGIN:VCALENDAR
950
+ VERSION:2.0
951
+ PRODID:-//Test//Test//EN
952
+ BEGIN:VTIMEZONE
953
+ TZID:America/New_York
954
+ BEGIN:STANDARD
955
+ DTSTART:19701101T020000
956
+ RRULE:FREQ=YEARLY;BYMONTH=11;BYDAY=1SU
957
+ TZOFFSETFROM:-0400
958
+ TZOFFSETTO:-0500
959
+ TZNAME:EST
960
+ END:STANDARD
961
+ BEGIN:DAYLIGHT
962
+ DTSTART:19700308T020000
963
+ RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=2SU
964
+ TZOFFSETFROM:-0500
965
+ TZOFFSETTO:-0400
966
+ TZNAME:EDT
967
+ END:DAYLIGHT
968
+ END:VTIMEZONE
969
+ END:VCALENDAR`;
970
+
971
+ const result = validateCalendarTimezone(validTimezone);
972
+ t.true(result.valid);
973
+ t.is(result.error, null);
974
+ });
975
+
976
+ test('RFC 4791 5.2.2: Missing VCALENDAR wrapper fails validation', (t) => {
977
+ const invalidTimezone = `BEGIN:VTIMEZONE
978
+ TZID:America/New_York
979
+ END:VTIMEZONE`;
980
+
981
+ const result = validateCalendarTimezone(invalidTimezone);
982
+ t.false(result.valid);
983
+ t.true(result.error.includes('VCALENDAR'));
984
+ });
985
+
986
+ test('RFC 4791 5.2.2: Missing VTIMEZONE component fails validation', (t) => {
987
+ const invalidTimezone = `BEGIN:VCALENDAR
988
+ VERSION:2.0
989
+ END:VCALENDAR`;
990
+
991
+ const result = validateCalendarTimezone(invalidTimezone);
992
+ t.false(result.valid);
993
+ t.true(result.error.includes('VTIMEZONE'));
994
+ });
995
+
996
+ test('RFC 4791 5.2.2: Multiple VTIMEZONE components fails validation', (t) => {
997
+ const invalidTimezone = `BEGIN:VCALENDAR
998
+ VERSION:2.0
999
+ BEGIN:VTIMEZONE
1000
+ TZID:America/New_York
1001
+ END:VTIMEZONE
1002
+ BEGIN:VTIMEZONE
1003
+ TZID:Europe/London
1004
+ END:VTIMEZONE
1005
+ END:VCALENDAR`;
1006
+
1007
+ const result = validateCalendarTimezone(invalidTimezone);
1008
+ t.false(result.valid);
1009
+ t.true(result.error.includes('exactly one'));
1010
+ });
1011
+
1012
+ test('RFC 4791 5.2.2: Missing TZID property fails validation', (t) => {
1013
+ const invalidTimezone = `BEGIN:VCALENDAR
1014
+ VERSION:2.0
1015
+ BEGIN:VTIMEZONE
1016
+ BEGIN:STANDARD
1017
+ DTSTART:19701101T020000
1018
+ TZOFFSETFROM:-0400
1019
+ TZOFFSETTO:-0500
1020
+ END:STANDARD
1021
+ END:VTIMEZONE
1022
+ END:VCALENDAR`;
1023
+
1024
+ const result = validateCalendarTimezone(invalidTimezone);
1025
+ t.false(result.valid);
1026
+ t.true(result.error.includes('TZID'));
1027
+ });
1028
+
1029
+ // =============================================================================
1030
+ // RFC 4791 Section 5.2.1 - xml:lang Attribute Tests
1031
+ // =============================================================================
1032
+
1033
+ /**
1034
+ * Extract xml:lang attribute from an element
1035
+ */
1036
+ function extractXmlLang(child) {
1037
+ if (child.getAttributeNS) {
1038
+ const lang = child.getAttributeNS(
1039
+ 'http://www.w3.org/XML/1998/namespace',
1040
+ 'lang'
1041
+ );
1042
+ if (lang) return lang;
1043
+ }
1044
+
1045
+ if (child.getAttribute) {
1046
+ const lang = child.getAttribute('xml:lang');
1047
+ if (lang) return lang;
1048
+ }
1049
+
1050
+ let parent = child.parentNode;
1051
+ while (parent && parent.getAttribute) {
1052
+ const lang = parent.getAttribute('xml:lang');
1053
+ if (lang) return lang;
1054
+ parent = parent.parentNode;
1055
+ }
1056
+
1057
+ return null;
1058
+ }
1059
+
1060
+ test('RFC 4791 5.2.1: xml:lang attribute on element is extracted', (t) => {
1061
+ const xml = `<?xml version="1.0" encoding="UTF-8"?>
1062
+ <D:propertyupdate xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
1063
+ <D:set>
1064
+ <D:prop>
1065
+ <C:calendar-description xml:lang="en-US">English Description</C:calendar-description>
1066
+ </D:prop>
1067
+ </D:set>
1068
+ </D:propertyupdate>`;
1069
+
1070
+ const children = getProppatchChildren(xml);
1071
+ const descChild = children.find(
1072
+ (c) => c.localName === 'calendar-description'
1073
+ );
1074
+ t.truthy(descChild);
1075
+
1076
+ const lang = extractXmlLang(descChild);
1077
+ t.is(lang, 'en-US');
1078
+ });
1079
+
1080
+ test('RFC 4791 5.2.1: xml:lang attribute inherited from parent', (t) => {
1081
+ const xml = `<?xml version="1.0" encoding="UTF-8"?>
1082
+ <D:propertyupdate xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav" xml:lang="de-DE">
1083
+ <D:set>
1084
+ <D:prop>
1085
+ <C:calendar-description>German Description</C:calendar-description>
1086
+ </D:prop>
1087
+ </D:set>
1088
+ </D:propertyupdate>`;
1089
+
1090
+ const parser = new DOMParser();
1091
+ const doc = parser.parseFromString(xml, 'text/xml');
1092
+ const propNode = select('/D:propertyupdate/D:set/D:prop', doc);
1093
+ const children = propNode[0] ? nodeListToArray(propNode[0].childNodes) : [];
1094
+ const descChild = children.find(
1095
+ (c) => c.localName === 'calendar-description'
1096
+ );
1097
+ t.truthy(descChild);
1098
+
1099
+ const lang = extractXmlLang(descChild);
1100
+ t.is(lang, 'de-DE');
1101
+ });
1102
+
1103
+ test('RFC 4791 5.2.1: No xml:lang returns null', (t) => {
1104
+ const xml = `<?xml version="1.0" encoding="UTF-8"?>
1105
+ <D:propertyupdate xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
1106
+ <D:set>
1107
+ <D:prop>
1108
+ <C:calendar-description>No Language</C:calendar-description>
1109
+ </D:prop>
1110
+ </D:set>
1111
+ </D:propertyupdate>`;
1112
+
1113
+ const children = getProppatchChildren(xml);
1114
+ const descChild = children.find(
1115
+ (c) => c.localName === 'calendar-description'
1116
+ );
1117
+ t.truthy(descChild);
1118
+
1119
+ const lang = extractXmlLang(descChild);
1120
+ t.is(lang, null);
1121
+ });
1122
+
1123
+ // =============================================================================
1124
+ // RFC 4918 Section 9.2 - Atomicity Tests
1125
+ // =============================================================================
1126
+
1127
+ test('RFC 4918 9.2: Atomicity - all properties should fail if one is protected', (t) => {
1128
+ const xml = `<?xml version="1.0" encoding="UTF-8"?>
1129
+ <D:propertyupdate xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
1130
+ <D:set>
1131
+ <D:prop>
1132
+ <D:displayname>New Name</D:displayname>
1133
+ <C:supported-calendar-component-set>
1134
+ <C:comp name="VEVENT"/>
1135
+ </C:supported-calendar-component-set>
1136
+ </D:prop>
1137
+ </D:set>
1138
+ </D:propertyupdate>`;
1139
+
1140
+ const children = getProppatchChildren(xml);
1141
+
1142
+ // Check that we have both a modifiable and protected property
1143
+ const hasDisplayname = children.some((c) => c.localName === 'displayname');
1144
+ const hasProtected = children.some(
1145
+ (c) => c.localName === 'supported-calendar-component-set'
1146
+ );
1147
+
1148
+ t.true(hasDisplayname);
1149
+ t.true(hasProtected);
1150
+
1151
+ // In a real implementation, the presence of the protected property
1152
+ // would cause the entire request to fail (atomicity)
1153
+ });
1154
+
1155
+ test('RFC 4918 9.2: Atomicity - validation error should fail all properties', (t) => {
1156
+ const invalidTimezone = 'NOT A VALID TIMEZONE';
1157
+ const validation = validateCalendarTimezone(invalidTimezone);
1158
+
1159
+ t.false(validation.valid);
1160
+ // In a real implementation, this would cause all properties in the
1161
+ // PROPPATCH request to fail due to atomicity requirements
1162
+ });
1163
+
1164
+ // =============================================================================
1165
+ // Integration-style Tests
1166
+ // =============================================================================
1167
+
1168
+ test('Integration: Full PROPPATCH with xml:lang and valid timezone', (t) => {
1169
+ const validTimezone = `BEGIN:VCALENDAR
1170
+ VERSION:2.0
1171
+ BEGIN:VTIMEZONE
1172
+ TZID:Europe/Paris
1173
+ END:VTIMEZONE
1174
+ END:VCALENDAR`;
1175
+
1176
+ const xml = `<?xml version="1.0" encoding="UTF-8"?>
1177
+ <D:propertyupdate xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav" xmlns:A="http://apple.com/ns/ical/">
1178
+ <D:set>
1179
+ <D:prop>
1180
+ <D:displayname xml:lang="fr-FR">Mon Calendrier</D:displayname>
1181
+ <C:calendar-description xml:lang="fr-FR">Description en français</C:calendar-description>
1182
+ <C:calendar-timezone>${validTimezone}</C:calendar-timezone>
1183
+ <A:calendar-color>#0000FFFF</A:calendar-color>
1184
+ </D:prop>
1185
+ </D:set>
1186
+ </D:propertyupdate>`;
1187
+
1188
+ const children = getProppatchChildren(xml);
1189
+
1190
+ // Verify all properties are present
1191
+ const displayname = children.find((c) => c.localName === 'displayname');
1192
+ const description = children.find(
1193
+ (c) => c.localName === 'calendar-description'
1194
+ );
1195
+ const timezone = children.find((c) => c.localName === 'calendar-timezone');
1196
+ const color = children.find((c) => c.localName === 'calendar-color');
1197
+
1198
+ t.truthy(displayname);
1199
+ t.truthy(description);
1200
+ t.truthy(timezone);
1201
+ t.truthy(color);
1202
+
1203
+ // Verify xml:lang extraction
1204
+ t.is(extractXmlLang(displayname), 'fr-FR');
1205
+ t.is(extractXmlLang(description), 'fr-FR');
1206
+
1207
+ // Verify timezone validation
1208
+ const timezoneValidation = validateCalendarTimezone(timezone.textContent);
1209
+ t.true(timezoneValidation.valid);
1210
+
1211
+ // Verify values
1212
+ t.is(displayname.textContent, 'Mon Calendrier');
1213
+ t.is(description.textContent, 'Description en français');
1214
+ t.is(color.textContent, '#0000FFFF');
1215
+ });
1216
+
1217
+ // =============================================================================
1218
+ // Additional Tests for Stack Trace Issues
1219
+ // =============================================================================
1220
+
1221
+ test('xml.get returns empty array for null document', (t) => {
1222
+ const xml = require('../common/xml');
1223
+ const result = xml.get('/D:propfind/D:prop', null);
1224
+ t.deepEqual(result, []);
1225
+ });
1226
+
1227
+ test('xml.get returns empty array for undefined document', (t) => {
1228
+ const xml = require('../common/xml');
1229
+ const result = xml.get('/D:propfind/D:prop', undefined);
1230
+ t.deepEqual(result, []);
1231
+ });
1232
+
1233
+ test('xml.get returns empty array for non-object document', (t) => {
1234
+ const xml = require('../common/xml');
1235
+ const result = xml.get('/D:propfind/D:prop', 'not an object');
1236
+ t.deepEqual(result, []);
1237
+ });
1238
+
1239
+ test('xml.getWithChildren handles null document gracefully', (t) => {
1240
+ const xml = require('../common/xml');
1241
+ const result = xml.getWithChildren('/D:propfind/D:prop', null);
1242
+ t.deepEqual(result.propNode, []);
1243
+ t.deepEqual(result.children, []);
1244
+ });
1245
+
1246
+ test('preconditionFail generates valid XML error response', (t) => {
1247
+ const { preconditionFail } = require('../common/x-build');
1248
+ const result = preconditionFail(
1249
+ '/dav/user/calendar/event.ics',
1250
+ 'no-uid-conflict'
1251
+ );
1252
+ t.true(result.includes('D:error'));
1253
+ t.true(result.includes('no-uid-conflict'));
1254
+ });