@pipobscure/vcard 0.0.1
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.
- package/LICENSE +190 -0
- package/README.md +1079 -0
- package/dist/escape.d.ts +59 -0
- package/dist/escape.d.ts.map +1 -0
- package/dist/escape.js +136 -0
- package/dist/escape.js.map +1 -0
- package/dist/generator.d.ts +49 -0
- package/dist/generator.d.ts.map +1 -0
- package/dist/generator.js +194 -0
- package/dist/generator.js.map +1 -0
- package/dist/index.d.ts +47 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +85 -0
- package/dist/index.js.map +1 -0
- package/dist/parser.d.ts +54 -0
- package/dist/parser.d.ts.map +1 -0
- package/dist/parser.js +381 -0
- package/dist/parser.js.map +1 -0
- package/dist/property.d.ts +347 -0
- package/dist/property.d.ts.map +1 -0
- package/dist/property.js +854 -0
- package/dist/property.js.map +1 -0
- package/dist/tests/edgecases.test.d.ts +10 -0
- package/dist/tests/edgecases.test.d.ts.map +1 -0
- package/dist/tests/edgecases.test.js +950 -0
- package/dist/tests/edgecases.test.js.map +1 -0
- package/dist/tests/vcard.test.d.ts +6 -0
- package/dist/tests/vcard.test.d.ts.map +1 -0
- package/dist/tests/vcard.test.js +496 -0
- package/dist/tests/vcard.test.js.map +1 -0
- package/dist/types.d.ts +94 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +5 -0
- package/dist/types.js.map +1 -0
- package/dist/vcard.d.ts +177 -0
- package/dist/vcard.d.ts.map +1 -0
- package/dist/vcard.js +424 -0
- package/dist/vcard.js.map +1 -0
- package/package.json +39 -0
|
@@ -0,0 +1,950 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Edge case tests for @pipobscure/vcard
|
|
3
|
+
* Based on real-world vCard examples from:
|
|
4
|
+
* - RFC 6350 / RFC 7095 appendix examples
|
|
5
|
+
* - Apple Contacts exports
|
|
6
|
+
* - Outlook, Google, iOS known quirks
|
|
7
|
+
* - Documented parser bugs and interop failures
|
|
8
|
+
*/
|
|
9
|
+
import { test, describe } from 'node:test';
|
|
10
|
+
import assert from 'node:assert/strict';
|
|
11
|
+
import { VCard, parse, NoteProperty, PhotoProperty, } from '../index.js';
|
|
12
|
+
// ── RFC 7095 appendix example (full-featured v4) ──────────────────────────
|
|
13
|
+
describe('RFC 7095 Simon Perreault example', () => {
|
|
14
|
+
// This is the canonical example from RFC 7095 (jCard spec),
|
|
15
|
+
// which contains most v4 features in one card.
|
|
16
|
+
const SIMON = [
|
|
17
|
+
'BEGIN:VCARD',
|
|
18
|
+
'VERSION:4.0',
|
|
19
|
+
'FN:Simon Perreault',
|
|
20
|
+
'N:Perreault;Simon;;;ing. jr,M.Sc.',
|
|
21
|
+
'BDAY:--0203',
|
|
22
|
+
'ANNIVERSARY:20090808T1430-0500',
|
|
23
|
+
'GENDER:M',
|
|
24
|
+
'LANG;PREF=1:fr',
|
|
25
|
+
'LANG;PREF=2:en',
|
|
26
|
+
'ORG;TYPE=work:Viagenie',
|
|
27
|
+
'ADR;TYPE=work:;Suite D2-630;2875 Laurier;Quebec;QC;G1V 2M2;Canada',
|
|
28
|
+
'TEL;VALUE=uri;TYPE="work,voice";PREF=1:tel:+1-418-656-9254;ext=102',
|
|
29
|
+
'TEL;VALUE=uri;TYPE="work,voice";PREF=2:tel:+1-418-262-6501',
|
|
30
|
+
'EMAIL;TYPE=work:simon.perreault@viagenie.ca',
|
|
31
|
+
'GEO;TYPE=work:geo:46.772673,-71.282945',
|
|
32
|
+
'KEY;TYPE=work;VALUE=uri:http://www.viagenie.ca/simon.perreault/simon.asc',
|
|
33
|
+
'TZ:-0500',
|
|
34
|
+
'URL;TYPE=home:http://nomis80.org',
|
|
35
|
+
'END:VCARD',
|
|
36
|
+
].join('\r\n');
|
|
37
|
+
test('parses without errors', () => {
|
|
38
|
+
const [vc] = parse(SIMON);
|
|
39
|
+
assert.ok(vc);
|
|
40
|
+
assert.equal(vc.parseWarnings.filter(w => w.message.includes('error')).length, 0);
|
|
41
|
+
});
|
|
42
|
+
test('FN is correct', () => {
|
|
43
|
+
const [vc] = parse(SIMON);
|
|
44
|
+
assert.equal(vc?.displayName, 'Simon Perreault');
|
|
45
|
+
});
|
|
46
|
+
test('N with multiple suffixes (comma-list)', () => {
|
|
47
|
+
const [vc] = parse(SIMON);
|
|
48
|
+
assert.ok(vc?.n);
|
|
49
|
+
assert.deepEqual(vc.n.value.familyNames, ['Perreault']);
|
|
50
|
+
assert.deepEqual(vc.n.value.givenNames, ['Simon']);
|
|
51
|
+
// honorific suffixes: "ing. jr,M.Sc." → two suffixes
|
|
52
|
+
assert.deepEqual(vc.n.value.honorificSuffixes, ['ing. jr', 'M.Sc.']);
|
|
53
|
+
});
|
|
54
|
+
test('BDAY with --MMDD format (no year)', () => {
|
|
55
|
+
const [vc] = parse(SIMON);
|
|
56
|
+
assert.ok(vc?.bday?.dateValue);
|
|
57
|
+
assert.equal(vc.bday.dateValue.year, undefined);
|
|
58
|
+
assert.equal(vc.bday.dateValue.month, 2);
|
|
59
|
+
assert.equal(vc.bday.dateValue.day, 3);
|
|
60
|
+
});
|
|
61
|
+
test('ANNIVERSARY with date-time and offset', () => {
|
|
62
|
+
const [vc] = parse(SIMON);
|
|
63
|
+
assert.ok(vc?.anniversary?.dateValue);
|
|
64
|
+
assert.equal(vc.anniversary.dateValue.year, 2009);
|
|
65
|
+
assert.equal(vc.anniversary.dateValue.month, 8);
|
|
66
|
+
assert.equal(vc.anniversary.dateValue.day, 8);
|
|
67
|
+
assert.ok(vc.anniversary.dateValue.hasTime);
|
|
68
|
+
assert.equal(vc.anniversary.dateValue.hour, 14);
|
|
69
|
+
assert.equal(vc.anniversary.dateValue.minute, 30);
|
|
70
|
+
assert.ok(vc.anniversary.dateValue.utcOffset?.includes('0500'));
|
|
71
|
+
});
|
|
72
|
+
test('LANG properties with PREF', () => {
|
|
73
|
+
const [vc] = parse(SIMON);
|
|
74
|
+
assert.equal(vc?.lang.length, 2);
|
|
75
|
+
const fr = vc?.lang.find(l => l.value === 'fr');
|
|
76
|
+
assert.ok(fr);
|
|
77
|
+
assert.equal(fr.pref, 1);
|
|
78
|
+
});
|
|
79
|
+
test('TEL with quoted TYPE containing comma (TYPE="work,voice")', () => {
|
|
80
|
+
const [vc] = parse(SIMON);
|
|
81
|
+
assert.ok(vc);
|
|
82
|
+
assert.equal(vc.tel.length, 2);
|
|
83
|
+
// TYPE="work,voice" should parse as two types
|
|
84
|
+
const t = vc.tel[0];
|
|
85
|
+
assert.ok(t.type.includes('work'), `Expected work in types: ${JSON.stringify(t.type)}`);
|
|
86
|
+
assert.ok(t.type.includes('voice'), `Expected voice in types: ${JSON.stringify(t.type)}`);
|
|
87
|
+
});
|
|
88
|
+
test('TEL is a URI value', () => {
|
|
89
|
+
const [vc] = parse(SIMON);
|
|
90
|
+
assert.ok(vc);
|
|
91
|
+
assert.ok(vc.tel[0]?.value.startsWith('tel:'));
|
|
92
|
+
assert.ok(vc.tel[0]?.isUri);
|
|
93
|
+
});
|
|
94
|
+
test('GEO URI round-trips', () => {
|
|
95
|
+
const [vc] = parse(SIMON);
|
|
96
|
+
assert.ok(vc);
|
|
97
|
+
assert.equal(vc.geo.length, 1);
|
|
98
|
+
const coords = vc.geo[0]?.coordinates;
|
|
99
|
+
assert.ok(coords);
|
|
100
|
+
assert.ok(Math.abs(coords.latitude - 46.772673) < 0.0001);
|
|
101
|
+
assert.ok(Math.abs(coords.longitude - (-71.282945)) < 0.0001);
|
|
102
|
+
});
|
|
103
|
+
test('TZ as UTC offset', () => {
|
|
104
|
+
const [vc] = parse(SIMON);
|
|
105
|
+
assert.ok(vc);
|
|
106
|
+
assert.equal(vc.tz.length, 1);
|
|
107
|
+
assert.equal(vc.tz[0]?.valueKind, 'utc-offset');
|
|
108
|
+
assert.equal(vc.tz[0]?.value, '-0500');
|
|
109
|
+
});
|
|
110
|
+
test('ADR with empty first two components', () => {
|
|
111
|
+
const [vc] = parse(SIMON);
|
|
112
|
+
assert.ok(vc);
|
|
113
|
+
assert.equal(vc.adr[0]?.value.postOfficeBox, '');
|
|
114
|
+
assert.equal(vc.adr[0]?.value.extendedAddress, 'Suite D2-630');
|
|
115
|
+
assert.equal(vc.adr[0]?.value.streetAddress, '2875 Laurier');
|
|
116
|
+
assert.equal(vc.adr[0]?.value.locality, 'Quebec');
|
|
117
|
+
assert.equal(vc.adr[0]?.value.region, 'QC');
|
|
118
|
+
assert.equal(vc.adr[0]?.value.postalCode, 'G1V 2M2');
|
|
119
|
+
assert.equal(vc.adr[0]?.value.countryName, 'Canada');
|
|
120
|
+
});
|
|
121
|
+
test('round-trips faithfully', () => {
|
|
122
|
+
const [vc] = parse(SIMON);
|
|
123
|
+
assert.ok(vc);
|
|
124
|
+
const out = vc.toString();
|
|
125
|
+
const [vc2] = parse(out);
|
|
126
|
+
assert.ok(vc2);
|
|
127
|
+
assert.equal(vc2.displayName, vc.displayName);
|
|
128
|
+
assert.equal(vc2.tel.length, vc.tel.length);
|
|
129
|
+
assert.equal(vc2.email[0]?.value, vc.email[0]?.value);
|
|
130
|
+
assert.deepEqual(vc2.n?.value.honorificSuffixes, vc.n?.value.honorificSuffixes);
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
// ── vCard 3.0 with base64 PHOTO ───────────────────────────────────────────
|
|
134
|
+
describe('vCard 3.0 - Forrest Gump (base64, v3 TYPE multi-value, LABEL)', () => {
|
|
135
|
+
const FORREST = [
|
|
136
|
+
'BEGIN:VCARD',
|
|
137
|
+
'VERSION:3.0',
|
|
138
|
+
'N:Gump;Forrest;;Mr.;',
|
|
139
|
+
'FN:Forrest Gump',
|
|
140
|
+
'ORG:Bubba Gump Shrimp Co.',
|
|
141
|
+
'TITLE:Shrimp Man',
|
|
142
|
+
'PHOTO;ENCODING=b;TYPE=JPEG:/9j/4AAQSkZJRgABAQ==',
|
|
143
|
+
'TEL;TYPE=WORK,VOICE:(111) 555-1212',
|
|
144
|
+
'TEL;TYPE=HOME,VOICE:(404) 555-1212',
|
|
145
|
+
'ADR;TYPE=WORK:;;100 Waters Edge;Baytown;LA;30314;United States of America',
|
|
146
|
+
'LABEL;TYPE=WORK:100 Waters Edge\\nBaytown\\, LA 30314\\nUnited States of America',
|
|
147
|
+
'EMAIL;TYPE=PREF,INTERNET:forrestgump@example.com',
|
|
148
|
+
'REV:2008-04-24T19:52:43Z',
|
|
149
|
+
'END:VCARD',
|
|
150
|
+
].join('\r\n');
|
|
151
|
+
test('parses v3 without throwing', () => {
|
|
152
|
+
const vcards = parse(FORREST);
|
|
153
|
+
assert.equal(vcards.length, 1);
|
|
154
|
+
assert.equal(vcards[0]?.parsedVersion, '3.0');
|
|
155
|
+
});
|
|
156
|
+
test('FN and N are parsed', () => {
|
|
157
|
+
const [vc] = parse(FORREST);
|
|
158
|
+
assert.equal(vc?.displayName, 'Forrest Gump');
|
|
159
|
+
assert.equal(vc?.n?.value.familyNames[0], 'Gump');
|
|
160
|
+
assert.equal(vc?.n?.value.givenNames[0], 'Forrest');
|
|
161
|
+
assert.deepEqual(vc?.n?.value.honorificPrefixes, ['Mr.']);
|
|
162
|
+
});
|
|
163
|
+
test('TEL with comma-separated TYPE values', () => {
|
|
164
|
+
const [vc] = parse(FORREST);
|
|
165
|
+
assert.ok(vc);
|
|
166
|
+
assert.equal(vc.tel.length, 2);
|
|
167
|
+
// TYPE=WORK,VOICE → types: ['work', 'voice']
|
|
168
|
+
const workTel = vc.tel.find(t => t.type.includes('work'));
|
|
169
|
+
assert.ok(workTel);
|
|
170
|
+
assert.ok(workTel.type.includes('voice'));
|
|
171
|
+
assert.equal(workTel.value, '(111) 555-1212');
|
|
172
|
+
});
|
|
173
|
+
test('PHOTO with ENCODING=b is stored verbatim', () => {
|
|
174
|
+
const [vc] = parse(FORREST);
|
|
175
|
+
assert.ok(vc);
|
|
176
|
+
assert.equal(vc.photo.length, 1);
|
|
177
|
+
// The ENCODING=b flag in v3 — data is stored as-is (already base64)
|
|
178
|
+
assert.ok((vc.photo[0]?.value.length ?? 0) > 0);
|
|
179
|
+
});
|
|
180
|
+
test('REV with extended ISO format is parsed as Date', () => {
|
|
181
|
+
const [vc] = parse(FORREST);
|
|
182
|
+
assert.ok(vc?.rev);
|
|
183
|
+
assert.ok(vc.rev.value instanceof Date);
|
|
184
|
+
assert.equal(vc.rev.value.getUTCFullYear(), 2008);
|
|
185
|
+
assert.equal(vc.rev.value.getUTCMonth(), 3); // April = 3
|
|
186
|
+
});
|
|
187
|
+
test('LABEL stored as unknown property (v3 only)', () => {
|
|
188
|
+
const [vc] = parse(FORREST);
|
|
189
|
+
assert.ok(vc);
|
|
190
|
+
const label = vc.extended.find(p => p.name === 'LABEL');
|
|
191
|
+
assert.ok(label, 'LABEL should be in extended properties');
|
|
192
|
+
});
|
|
193
|
+
test('ADR with all components', () => {
|
|
194
|
+
const [vc] = parse(FORREST);
|
|
195
|
+
assert.ok(vc);
|
|
196
|
+
assert.equal(vc.adr[0]?.value.streetAddress, '100 Waters Edge');
|
|
197
|
+
assert.equal(vc.adr[0]?.value.locality, 'Baytown');
|
|
198
|
+
assert.equal(vc.adr[0]?.value.region, 'LA');
|
|
199
|
+
assert.equal(vc.adr[0]?.value.postalCode, '30314');
|
|
200
|
+
assert.equal(vc.adr[0]?.value.countryName, 'United States of America');
|
|
201
|
+
});
|
|
202
|
+
});
|
|
203
|
+
// ── Apple Contacts item-groups ─────────────────────────────────────────────
|
|
204
|
+
describe('Apple Contacts - item groups and X- extensions', () => {
|
|
205
|
+
const APPLE = [
|
|
206
|
+
'BEGIN:VCARD',
|
|
207
|
+
'VERSION:3.0',
|
|
208
|
+
'N:Public;John;Quinlan;Mr.;Esq.',
|
|
209
|
+
'FN:Mr. John Quinlan Public\\, Esq.',
|
|
210
|
+
'ORG:ABC\\, Inc.',
|
|
211
|
+
'TITLE:Software Architect',
|
|
212
|
+
'item1.EMAIL;type=INTERNET:john@example.com',
|
|
213
|
+
'item1.X-ABLabel:Work',
|
|
214
|
+
'item2.EMAIL;type=INTERNET:jpublic@personal.com',
|
|
215
|
+
'item2.X-ABLabel:Personal',
|
|
216
|
+
'item3.TEL;type=VOICE:+1-555-555-5555',
|
|
217
|
+
'item3.X-ABLabel:_$!<Mobile>!$_',
|
|
218
|
+
'item4.TEL;type=VOICE:+1-555-555-5556',
|
|
219
|
+
'item4.X-ABLabel:_$!<Main>!$_',
|
|
220
|
+
'item5.URL;type=WORK:http://www.example.com',
|
|
221
|
+
'item5.X-ABLabel:_$!<HomePage>!$_',
|
|
222
|
+
'X-ABUID:12345678-1234-1234-1234-123456789012:ABPerson',
|
|
223
|
+
'END:VCARD',
|
|
224
|
+
].join('\r\n');
|
|
225
|
+
test('parses without error', () => {
|
|
226
|
+
const vcards = parse(APPLE);
|
|
227
|
+
assert.equal(vcards.length, 1);
|
|
228
|
+
});
|
|
229
|
+
test('FN with escaped comma round-trips', () => {
|
|
230
|
+
const [vc] = parse(APPLE);
|
|
231
|
+
assert.ok(vc);
|
|
232
|
+
assert.equal(vc.displayName, 'Mr. John Quinlan Public, Esq.');
|
|
233
|
+
});
|
|
234
|
+
test('ORG with escaped comma', () => {
|
|
235
|
+
const [vc] = parse(APPLE);
|
|
236
|
+
assert.ok(vc);
|
|
237
|
+
assert.equal(vc.org[0]?.value.name, 'ABC, Inc.');
|
|
238
|
+
});
|
|
239
|
+
test('grouped EMAIL properties preserve group name', () => {
|
|
240
|
+
const [vc] = parse(APPLE);
|
|
241
|
+
assert.ok(vc);
|
|
242
|
+
assert.equal(vc.email.length, 2);
|
|
243
|
+
assert.equal(vc.email[0]?.group, 'item1');
|
|
244
|
+
assert.equal(vc.email[1]?.group, 'item2');
|
|
245
|
+
});
|
|
246
|
+
test('grouped TEL properties preserve group name', () => {
|
|
247
|
+
const [vc] = parse(APPLE);
|
|
248
|
+
assert.ok(vc);
|
|
249
|
+
assert.equal(vc.tel.length, 2);
|
|
250
|
+
assert.equal(vc.tel[0]?.group, 'item3');
|
|
251
|
+
assert.equal(vc.tel[1]?.group, 'item4');
|
|
252
|
+
});
|
|
253
|
+
test('X-ABLabel stored in extended properties', () => {
|
|
254
|
+
const [vc] = parse(APPLE);
|
|
255
|
+
assert.ok(vc);
|
|
256
|
+
const labels = vc.extended.filter(p => p.name === 'X-ABLABEL');
|
|
257
|
+
assert.equal(labels.length, 5);
|
|
258
|
+
assert.equal(labels[0]?.group, 'item1');
|
|
259
|
+
assert.equal(labels[0]?.rawValue, 'Work');
|
|
260
|
+
// Apple's special label tokens
|
|
261
|
+
assert.ok(labels[2]?.rawValue.includes('Mobile'));
|
|
262
|
+
});
|
|
263
|
+
test('X-ABUID stored in extended properties', () => {
|
|
264
|
+
const [vc] = parse(APPLE);
|
|
265
|
+
assert.ok(vc);
|
|
266
|
+
const abuid = vc.extended.find(p => p.name === 'X-ABUID');
|
|
267
|
+
assert.ok(abuid);
|
|
268
|
+
assert.ok(abuid.rawValue.includes('12345678'));
|
|
269
|
+
});
|
|
270
|
+
test('URL with group is parsed', () => {
|
|
271
|
+
const [vc] = parse(APPLE);
|
|
272
|
+
assert.ok(vc);
|
|
273
|
+
assert.equal(vc.url.length, 1);
|
|
274
|
+
assert.equal(vc.url[0]?.group, 'item5');
|
|
275
|
+
assert.equal(vc.url[0]?.value, 'http://www.example.com');
|
|
276
|
+
});
|
|
277
|
+
});
|
|
278
|
+
// ── Multilingual vCard with Unicode ───────────────────────────────────────
|
|
279
|
+
describe('Multilingual vCard with ALTID and Unicode', () => {
|
|
280
|
+
const JP = [
|
|
281
|
+
'BEGIN:VCARD',
|
|
282
|
+
'VERSION:4.0',
|
|
283
|
+
'FN;ALTID=1;LANGUAGE=ja:山田太郎',
|
|
284
|
+
'FN;ALTID=1;LANGUAGE=en:Yamada Taro',
|
|
285
|
+
'N;ALTID=1;LANGUAGE=ja:山田;太郎;;;',
|
|
286
|
+
'N;ALTID=1;LANGUAGE=en:Yamada;Taro;;;',
|
|
287
|
+
'ORG;LANGUAGE=ja:株式会社ABC',
|
|
288
|
+
'ORG;LANGUAGE=en:ABC Corporation',
|
|
289
|
+
'END:VCARD',
|
|
290
|
+
].join('\r\n');
|
|
291
|
+
test('parses Unicode characters correctly', () => {
|
|
292
|
+
const [vc] = parse(JP);
|
|
293
|
+
assert.ok(vc);
|
|
294
|
+
assert.equal(vc.fn.length, 2);
|
|
295
|
+
const jaFN = vc.fn.find(f => f.language === 'ja');
|
|
296
|
+
assert.ok(jaFN);
|
|
297
|
+
assert.equal(jaFN.value, '山田太郎');
|
|
298
|
+
});
|
|
299
|
+
test('ALTID groups are preserved', () => {
|
|
300
|
+
const [vc] = parse(JP);
|
|
301
|
+
assert.ok(vc);
|
|
302
|
+
assert.equal(vc.fn[0]?.altid, '1');
|
|
303
|
+
assert.equal(vc.fn[1]?.altid, '1');
|
|
304
|
+
});
|
|
305
|
+
test('English version accessible via LANGUAGE param', () => {
|
|
306
|
+
const [vc] = parse(JP);
|
|
307
|
+
assert.ok(vc);
|
|
308
|
+
const enFN = vc.fn.find(f => f.language === 'en');
|
|
309
|
+
assert.equal(enFN?.value, 'Yamada Taro');
|
|
310
|
+
});
|
|
311
|
+
test('Japanese N components are correct', () => {
|
|
312
|
+
const [vc] = parse(JP);
|
|
313
|
+
assert.ok(vc);
|
|
314
|
+
// When multiple N are present, the last one wins (v4 cardinality *1)
|
|
315
|
+
// Both should parse to Japanese or English family/given
|
|
316
|
+
assert.ok(vc.n);
|
|
317
|
+
});
|
|
318
|
+
test('round-trips Unicode without corruption', () => {
|
|
319
|
+
const [vc] = parse(JP);
|
|
320
|
+
assert.ok(vc);
|
|
321
|
+
const out = vc.toString();
|
|
322
|
+
const [vc2] = parse(out);
|
|
323
|
+
assert.ok(vc2);
|
|
324
|
+
const jaFN = vc2.fn.find(f => f.language === 'ja');
|
|
325
|
+
assert.equal(jaFN?.value, '山田太郎');
|
|
326
|
+
});
|
|
327
|
+
test('generated output has correct byte-level line folding for Unicode', () => {
|
|
328
|
+
const [vc] = parse(JP);
|
|
329
|
+
assert.ok(vc);
|
|
330
|
+
const out = vc.toString();
|
|
331
|
+
for (const line of out.split('\r\n').filter(Boolean)) {
|
|
332
|
+
assert.ok(Buffer.byteLength(line, 'utf8') <= 75, `Line exceeds 75 octets: "${line}" (${Buffer.byteLength(line, 'utf8')} bytes)`);
|
|
333
|
+
}
|
|
334
|
+
});
|
|
335
|
+
});
|
|
336
|
+
// ── vCard 2.1 QUOTED-PRINTABLE ────────────────────────────────────────────
|
|
337
|
+
describe('vCard 2.1 QUOTED-PRINTABLE tolerance', () => {
|
|
338
|
+
const QP = [
|
|
339
|
+
'BEGIN:VCARD',
|
|
340
|
+
'VERSION:2.1',
|
|
341
|
+
'N;CHARSET=UTF-8;ENCODING=QUOTED-PRINTABLE:M=C3=BCller;Fr=C3=A4nk;;;',
|
|
342
|
+
'FN;CHARSET=UTF-8;ENCODING=QUOTED-PRINTABLE:Fr=C3=A4nk M=C3=BCller',
|
|
343
|
+
'TEL;WORK:+49-69-1234567',
|
|
344
|
+
'END:VCARD',
|
|
345
|
+
].join('\r\n');
|
|
346
|
+
test('parses without throwing', () => {
|
|
347
|
+
const vcards = parse(QP);
|
|
348
|
+
assert.equal(vcards.length, 1);
|
|
349
|
+
assert.equal(vcards[0]?.parsedVersion, '2.1');
|
|
350
|
+
});
|
|
351
|
+
test('QUOTED-PRINTABLE decoded: ä (=C3=A4) and ü (=C3=BC)', () => {
|
|
352
|
+
const [vc] = parse(QP);
|
|
353
|
+
assert.ok(vc);
|
|
354
|
+
// Fr=C3=A4nk = Fränk, M=C3=BCller = Müller
|
|
355
|
+
assert.equal(vc.displayName, 'Fränk Müller');
|
|
356
|
+
});
|
|
357
|
+
test('N structured name decoded correctly', () => {
|
|
358
|
+
const [vc] = parse(QP);
|
|
359
|
+
assert.ok(vc?.n);
|
|
360
|
+
assert.equal(vc.n.value.familyNames[0], 'Müller');
|
|
361
|
+
assert.equal(vc.n.value.givenNames[0], 'Fränk');
|
|
362
|
+
});
|
|
363
|
+
test('TEL without VALUE=uri is stored as text', () => {
|
|
364
|
+
const [vc] = parse(QP);
|
|
365
|
+
assert.ok(vc);
|
|
366
|
+
assert.equal(vc.tel.length, 1);
|
|
367
|
+
assert.equal(vc.tel[0]?.value, '+49-69-1234567');
|
|
368
|
+
});
|
|
369
|
+
});
|
|
370
|
+
// ── KIND:group with MEMBER ────────────────────────────────────────────────
|
|
371
|
+
describe('KIND:group with MEMBER URIs', () => {
|
|
372
|
+
const GROUP = [
|
|
373
|
+
'BEGIN:VCARD',
|
|
374
|
+
'VERSION:4.0',
|
|
375
|
+
'KIND:group',
|
|
376
|
+
'FN:The Doe family',
|
|
377
|
+
'MEMBER:urn:uuid:03a0e51f-d1aa-4385-8a53-e29025acd8af',
|
|
378
|
+
'MEMBER:urn:uuid:b8767877-b4a1-4c70-9acc-505d3819e519',
|
|
379
|
+
'MEMBER:mailto:sister@example.com',
|
|
380
|
+
'END:VCARD',
|
|
381
|
+
].join('\r\n');
|
|
382
|
+
test('parses KIND:group', () => {
|
|
383
|
+
const [vc] = parse(GROUP);
|
|
384
|
+
assert.ok(vc?.kind);
|
|
385
|
+
assert.equal(vc.kind.value, 'group');
|
|
386
|
+
});
|
|
387
|
+
test('MEMBER URIs are all parsed', () => {
|
|
388
|
+
const [vc] = parse(GROUP);
|
|
389
|
+
assert.ok(vc);
|
|
390
|
+
assert.equal(vc.member.length, 3);
|
|
391
|
+
});
|
|
392
|
+
test('urn:uuid MEMBER URI preserved', () => {
|
|
393
|
+
const [vc] = parse(GROUP);
|
|
394
|
+
assert.ok(vc);
|
|
395
|
+
assert.ok(vc.member[0]?.value.startsWith('urn:uuid:'));
|
|
396
|
+
});
|
|
397
|
+
test('mailto MEMBER URI preserved', () => {
|
|
398
|
+
const [vc] = parse(GROUP);
|
|
399
|
+
assert.ok(vc);
|
|
400
|
+
assert.ok(vc.member.some(m => m.value.startsWith('mailto:')));
|
|
401
|
+
});
|
|
402
|
+
test('round-trips correctly', () => {
|
|
403
|
+
const [vc] = parse(GROUP);
|
|
404
|
+
assert.ok(vc);
|
|
405
|
+
const out = vc.toString();
|
|
406
|
+
const [vc2] = parse(out);
|
|
407
|
+
assert.ok(vc2);
|
|
408
|
+
assert.equal(vc2.kind?.value, 'group');
|
|
409
|
+
assert.equal(vc2.member.length, 3);
|
|
410
|
+
});
|
|
411
|
+
});
|
|
412
|
+
// ── Complex ADR with GEO and LABEL parameters ──────────────────────────────
|
|
413
|
+
describe('ADR with GEO parameter and LABEL', () => {
|
|
414
|
+
const COMPLEX_ADR = [
|
|
415
|
+
'BEGIN:VCARD',
|
|
416
|
+
'VERSION:4.0',
|
|
417
|
+
'FN:John Q. Public',
|
|
418
|
+
'ADR;GEO="geo:12.3457,78.910";LABEL="Mr. John Q. Public\\, Esq.\\nMail Drop: TNE QB\\n123 Main Street":;;123 Main Street;Any Town;CA;91921;U.S.A.',
|
|
419
|
+
'END:VCARD',
|
|
420
|
+
].join('\r\n');
|
|
421
|
+
test('parses ADR with extra parameters', () => {
|
|
422
|
+
const [vc] = parse(COMPLEX_ADR);
|
|
423
|
+
assert.ok(vc);
|
|
424
|
+
assert.equal(vc.adr.length, 1);
|
|
425
|
+
assert.equal(vc.adr[0]?.value.streetAddress, '123 Main Street');
|
|
426
|
+
assert.equal(vc.adr[0]?.value.locality, 'Any Town');
|
|
427
|
+
assert.equal(vc.adr[0]?.value.region, 'CA');
|
|
428
|
+
assert.equal(vc.adr[0]?.value.postalCode, '91921');
|
|
429
|
+
assert.equal(vc.adr[0]?.value.countryName, 'U.S.A.');
|
|
430
|
+
});
|
|
431
|
+
test('GEO parameter is preserved in params map', () => {
|
|
432
|
+
const [vc] = parse(COMPLEX_ADR);
|
|
433
|
+
assert.ok(vc);
|
|
434
|
+
const geo = vc.adr[0]?.params.get('GEO');
|
|
435
|
+
assert.ok(geo);
|
|
436
|
+
const geoStr = Array.isArray(geo) ? geo[0] : geo;
|
|
437
|
+
assert.ok(geoStr?.includes('12.3457'));
|
|
438
|
+
});
|
|
439
|
+
test('LABEL parameter with escaped chars is preserved', () => {
|
|
440
|
+
const [vc] = parse(COMPLEX_ADR);
|
|
441
|
+
assert.ok(vc);
|
|
442
|
+
const label = vc.adr[0]?.label;
|
|
443
|
+
assert.ok(label);
|
|
444
|
+
assert.ok(label.includes('John Q. Public'));
|
|
445
|
+
});
|
|
446
|
+
});
|
|
447
|
+
// ── DATE/TIME format variations ───────────────────────────────────────────
|
|
448
|
+
describe('DATE/TIME format variations', () => {
|
|
449
|
+
const DATES = [
|
|
450
|
+
'BEGIN:VCARD',
|
|
451
|
+
'VERSION:4.0',
|
|
452
|
+
'FN:Test Subject',
|
|
453
|
+
'BDAY:19960415',
|
|
454
|
+
'END:VCARD',
|
|
455
|
+
].join('\r\n');
|
|
456
|
+
const PARTIAL_DATE = [
|
|
457
|
+
'BEGIN:VCARD',
|
|
458
|
+
'VERSION:4.0',
|
|
459
|
+
'FN:Test Subject',
|
|
460
|
+
'BDAY:--0415',
|
|
461
|
+
'END:VCARD',
|
|
462
|
+
].join('\r\n');
|
|
463
|
+
const TEXT_DATE = [
|
|
464
|
+
'BEGIN:VCARD',
|
|
465
|
+
'VERSION:4.0',
|
|
466
|
+
'FN:Test Subject',
|
|
467
|
+
'BDAY;VALUE=text:circa 1800',
|
|
468
|
+
'END:VCARD',
|
|
469
|
+
].join('\r\n');
|
|
470
|
+
const YEAR_ONLY = [
|
|
471
|
+
'BEGIN:VCARD',
|
|
472
|
+
'VERSION:4.0',
|
|
473
|
+
'FN:Test Subject',
|
|
474
|
+
'BDAY:1990',
|
|
475
|
+
'END:VCARD',
|
|
476
|
+
].join('\r\n');
|
|
477
|
+
const HYPHENATED = [
|
|
478
|
+
'BEGIN:VCARD',
|
|
479
|
+
'VERSION:4.0',
|
|
480
|
+
'FN:Test Subject',
|
|
481
|
+
'BDAY:1990-04-15',
|
|
482
|
+
'END:VCARD',
|
|
483
|
+
].join('\r\n');
|
|
484
|
+
test('full YYYYMMDD date', () => {
|
|
485
|
+
const [vc] = parse(DATES);
|
|
486
|
+
assert.ok(vc?.bday?.dateValue);
|
|
487
|
+
assert.equal(vc.bday.dateValue.year, 1996);
|
|
488
|
+
assert.equal(vc.bday.dateValue.month, 4);
|
|
489
|
+
assert.equal(vc.bday.dateValue.day, 15);
|
|
490
|
+
});
|
|
491
|
+
test('--MMDD partial date (no year)', () => {
|
|
492
|
+
const [vc] = parse(PARTIAL_DATE);
|
|
493
|
+
assert.ok(vc?.bday?.dateValue);
|
|
494
|
+
assert.equal(vc.bday.dateValue.year, undefined);
|
|
495
|
+
assert.equal(vc.bday.dateValue.month, 4);
|
|
496
|
+
assert.equal(vc.bday.dateValue.day, 15);
|
|
497
|
+
});
|
|
498
|
+
test('VALUE=text date stored as text', () => {
|
|
499
|
+
const [vc] = parse(TEXT_DATE);
|
|
500
|
+
assert.ok(vc?.bday);
|
|
501
|
+
assert.equal(vc.bday.dateValue, null);
|
|
502
|
+
assert.equal(vc.bday.textValue, 'circa 1800');
|
|
503
|
+
});
|
|
504
|
+
test('year-only BDAY', () => {
|
|
505
|
+
const [vc] = parse(YEAR_ONLY);
|
|
506
|
+
assert.ok(vc?.bday?.dateValue);
|
|
507
|
+
assert.equal(vc.bday.dateValue.year, 1990);
|
|
508
|
+
assert.equal(vc.bday.dateValue.month, undefined);
|
|
509
|
+
assert.equal(vc.bday.dateValue.day, undefined);
|
|
510
|
+
});
|
|
511
|
+
test('hyphenated ISO 8601 extended format YYYY-MM-DD', () => {
|
|
512
|
+
const [vc] = parse(HYPHENATED);
|
|
513
|
+
assert.ok(vc?.bday?.dateValue);
|
|
514
|
+
assert.equal(vc.bday.dateValue.year, 1990);
|
|
515
|
+
assert.equal(vc.bday.dateValue.month, 4);
|
|
516
|
+
assert.equal(vc.bday.dateValue.day, 15);
|
|
517
|
+
});
|
|
518
|
+
test('VALUE=text BDAY round-trips', () => {
|
|
519
|
+
const [vc] = parse(TEXT_DATE);
|
|
520
|
+
assert.ok(vc);
|
|
521
|
+
const out = vc.toString();
|
|
522
|
+
const [vc2] = parse(out);
|
|
523
|
+
assert.ok(vc2?.bday);
|
|
524
|
+
assert.equal(vc2.bday.textValue, 'circa 1800');
|
|
525
|
+
});
|
|
526
|
+
});
|
|
527
|
+
// ── Semicolon escaping edge cases ─────────────────────────────────────────
|
|
528
|
+
describe('Semicolon and special character escaping edge cases', () => {
|
|
529
|
+
test('escaped semicolon in ORG component preserved', () => {
|
|
530
|
+
const card = [
|
|
531
|
+
'BEGIN:VCARD',
|
|
532
|
+
'VERSION:3.0',
|
|
533
|
+
'FN:Test',
|
|
534
|
+
'N:Smith;John;Lee;Dr.;PhD',
|
|
535
|
+
'ORG:Company\\;Inc.;Sales\\;Marketing',
|
|
536
|
+
'END:VCARD',
|
|
537
|
+
].join('\r\n');
|
|
538
|
+
const [vc] = parse(card);
|
|
539
|
+
assert.ok(vc);
|
|
540
|
+
// ORG: "Company;Inc." is org name, "Sales;Marketing" is unit
|
|
541
|
+
assert.equal(vc.org[0]?.value.name, 'Company;Inc.');
|
|
542
|
+
assert.deepEqual(vc.org[0]?.value.units, ['Sales;Marketing']);
|
|
543
|
+
});
|
|
544
|
+
test('escaped semicolons in NICKNAME (text-list)', () => {
|
|
545
|
+
const card = [
|
|
546
|
+
'BEGIN:VCARD',
|
|
547
|
+
'VERSION:3.0',
|
|
548
|
+
'FN:Test',
|
|
549
|
+
'NICKNAME:Johnny\\;The\\;Genius',
|
|
550
|
+
'END:VCARD',
|
|
551
|
+
].join('\r\n');
|
|
552
|
+
const [vc] = parse(card);
|
|
553
|
+
assert.ok(vc);
|
|
554
|
+
// Single value with literal semicolons
|
|
555
|
+
assert.equal(vc.nickname[0]?.values[0], 'Johnny;The;Genius');
|
|
556
|
+
});
|
|
557
|
+
test('N components with semicolons as delimiters (not escaped)', () => {
|
|
558
|
+
const card = [
|
|
559
|
+
'BEGIN:VCARD',
|
|
560
|
+
'VERSION:4.0',
|
|
561
|
+
'FN:John Smith',
|
|
562
|
+
'N:Smith;John;Lee;Dr.;PhD',
|
|
563
|
+
'END:VCARD',
|
|
564
|
+
].join('\r\n');
|
|
565
|
+
const [vc] = parse(card);
|
|
566
|
+
assert.ok(vc?.n);
|
|
567
|
+
assert.deepEqual(vc.n.value.familyNames, ['Smith']);
|
|
568
|
+
assert.deepEqual(vc.n.value.givenNames, ['John']);
|
|
569
|
+
assert.deepEqual(vc.n.value.additionalNames, ['Lee']);
|
|
570
|
+
assert.deepEqual(vc.n.value.honorificPrefixes, ['Dr.']);
|
|
571
|
+
assert.deepEqual(vc.n.value.honorificSuffixes, ['PhD']);
|
|
572
|
+
});
|
|
573
|
+
test('NOTE with escaped newlines and commas', () => {
|
|
574
|
+
const card = [
|
|
575
|
+
'BEGIN:VCARD',
|
|
576
|
+
'VERSION:3.0',
|
|
577
|
+
'FN:Test',
|
|
578
|
+
'NOTE:Mythical Manager\\nHyjinx Software Division\\nBabsCo\\, Inc.',
|
|
579
|
+
'END:VCARD',
|
|
580
|
+
].join('\r\n');
|
|
581
|
+
const [vc] = parse(card);
|
|
582
|
+
assert.ok(vc);
|
|
583
|
+
assert.equal(vc.note[0]?.value, 'Mythical Manager\nHyjinx Software Division\nBabsCo, Inc.');
|
|
584
|
+
});
|
|
585
|
+
test('backslash followed by unknown char is preserved literally', () => {
|
|
586
|
+
const card = [
|
|
587
|
+
'BEGIN:VCARD',
|
|
588
|
+
'VERSION:4.0',
|
|
589
|
+
'FN:Test',
|
|
590
|
+
'NOTE:path\\: C:\\\\Users\\\\test',
|
|
591
|
+
'END:VCARD',
|
|
592
|
+
].join('\r\n');
|
|
593
|
+
const [vc] = parse(card);
|
|
594
|
+
assert.ok(vc);
|
|
595
|
+
// \\ → \, \: → (literal \\:, since \: is not a defined escape, keep \\: as-is... but actually unescapeText strips the backslash)
|
|
596
|
+
// In RFC: "In text value types, any character may be escaped with a backslash"
|
|
597
|
+
// So \: → :
|
|
598
|
+
// Actually per RFC 6350: the defined escapes are \\, \n, \;, \,
|
|
599
|
+
// Undefined escapes like \: are parsed as the next char
|
|
600
|
+
assert.ok(vc.note[0]?.value.includes('C:\\Users\\test'));
|
|
601
|
+
});
|
|
602
|
+
test('FN with colon in value', () => {
|
|
603
|
+
// Colon appears after the property name separator — value starts there
|
|
604
|
+
// But a colon IN the value should not need escaping
|
|
605
|
+
const card = [
|
|
606
|
+
'BEGIN:VCARD',
|
|
607
|
+
'VERSION:4.0',
|
|
608
|
+
'FN:Dr. Jones: Adventurer',
|
|
609
|
+
'END:VCARD',
|
|
610
|
+
].join('\r\n');
|
|
611
|
+
const [vc] = parse(card);
|
|
612
|
+
assert.ok(vc);
|
|
613
|
+
assert.equal(vc.displayName, 'Dr. Jones: Adventurer');
|
|
614
|
+
});
|
|
615
|
+
});
|
|
616
|
+
// ── Multiple vCards and edge formats ──────────────────────────────────────
|
|
617
|
+
describe('Multi-vCard and format edge cases', () => {
|
|
618
|
+
test('empty BDAY (just --) does not crash', () => {
|
|
619
|
+
const card = [
|
|
620
|
+
'BEGIN:VCARD',
|
|
621
|
+
'VERSION:4.0',
|
|
622
|
+
'FN:Test',
|
|
623
|
+
'BDAY:',
|
|
624
|
+
'END:VCARD',
|
|
625
|
+
].join('\r\n');
|
|
626
|
+
assert.doesNotThrow(() => parse(card));
|
|
627
|
+
});
|
|
628
|
+
test('property with no value after colon is tolerated', () => {
|
|
629
|
+
const card = [
|
|
630
|
+
'BEGIN:VCARD',
|
|
631
|
+
'VERSION:4.0',
|
|
632
|
+
'FN:Test',
|
|
633
|
+
'NOTE:',
|
|
634
|
+
'END:VCARD',
|
|
635
|
+
].join('\r\n');
|
|
636
|
+
const [vc] = parse(card);
|
|
637
|
+
assert.ok(vc);
|
|
638
|
+
assert.equal(vc.note.length, 1);
|
|
639
|
+
assert.equal(vc.note[0]?.value, '');
|
|
640
|
+
});
|
|
641
|
+
test('completely unknown property is stored in extended', () => {
|
|
642
|
+
const card = [
|
|
643
|
+
'BEGIN:VCARD',
|
|
644
|
+
'VERSION:4.0',
|
|
645
|
+
'FN:Test',
|
|
646
|
+
'X-SOME-CUSTOM:somevalue',
|
|
647
|
+
'END:VCARD',
|
|
648
|
+
].join('\r\n');
|
|
649
|
+
const [vc] = parse(card);
|
|
650
|
+
assert.ok(vc);
|
|
651
|
+
const x = vc.extended.find(p => p.name === 'X-SOME-CUSTOM');
|
|
652
|
+
assert.ok(x);
|
|
653
|
+
assert.equal(x.rawValue, 'somevalue');
|
|
654
|
+
});
|
|
655
|
+
test('multiple FN properties are all stored', () => {
|
|
656
|
+
// v4 allows 1+ FN properties
|
|
657
|
+
const card = [
|
|
658
|
+
'BEGIN:VCARD',
|
|
659
|
+
'VERSION:4.0',
|
|
660
|
+
'FN;LANGUAGE=en:John Doe',
|
|
661
|
+
'FN;LANGUAGE=fr:Jean Dupont',
|
|
662
|
+
'END:VCARD',
|
|
663
|
+
].join('\r\n');
|
|
664
|
+
const [vc] = parse(card);
|
|
665
|
+
assert.ok(vc);
|
|
666
|
+
assert.equal(vc.fn.length, 2);
|
|
667
|
+
});
|
|
668
|
+
test('multiple ORG properties are all stored', () => {
|
|
669
|
+
const card = [
|
|
670
|
+
'BEGIN:VCARD',
|
|
671
|
+
'VERSION:4.0',
|
|
672
|
+
'FN:Test',
|
|
673
|
+
'ORG:Primary Corp',
|
|
674
|
+
'ORG;TYPE=work:Secondary Inc.',
|
|
675
|
+
'END:VCARD',
|
|
676
|
+
].join('\r\n');
|
|
677
|
+
const [vc] = parse(card);
|
|
678
|
+
assert.ok(vc);
|
|
679
|
+
assert.equal(vc.org.length, 2);
|
|
680
|
+
});
|
|
681
|
+
test('case-insensitive property names', () => {
|
|
682
|
+
const card = [
|
|
683
|
+
'BEGIN:VCARD',
|
|
684
|
+
'VERSION:4.0',
|
|
685
|
+
'fn:John Doe',
|
|
686
|
+
'email:john@example.com',
|
|
687
|
+
'END:VCARD',
|
|
688
|
+
].join('\r\n');
|
|
689
|
+
const [vc] = parse(card);
|
|
690
|
+
assert.ok(vc);
|
|
691
|
+
assert.equal(vc.displayName, 'John Doe');
|
|
692
|
+
assert.equal(vc.email[0]?.value, 'john@example.com');
|
|
693
|
+
});
|
|
694
|
+
test('case-insensitive VERSION:4.0', () => {
|
|
695
|
+
const card = 'begin:vcard\r\nversion:4.0\r\nfn:Test\r\nend:vcard\r\n';
|
|
696
|
+
const vcards = parse(card);
|
|
697
|
+
assert.equal(vcards.length, 1);
|
|
698
|
+
assert.equal(vcards[0]?.displayName, 'Test');
|
|
699
|
+
});
|
|
700
|
+
test('CRLF-only blank lines between vCards are ignored', () => {
|
|
701
|
+
const multi = [
|
|
702
|
+
'BEGIN:VCARD', 'VERSION:4.0', 'FN:Alice', 'END:VCARD',
|
|
703
|
+
'', '',
|
|
704
|
+
'BEGIN:VCARD', 'VERSION:4.0', 'FN:Bob', 'END:VCARD',
|
|
705
|
+
].join('\r\n');
|
|
706
|
+
const vcards = parse(multi);
|
|
707
|
+
assert.equal(vcards.length, 2);
|
|
708
|
+
assert.equal(vcards[0]?.displayName, 'Alice');
|
|
709
|
+
assert.equal(vcards[1]?.displayName, 'Bob');
|
|
710
|
+
});
|
|
711
|
+
test('content before first BEGIN:VCARD is ignored', () => {
|
|
712
|
+
const card = 'This is not a vCard\r\n' + [
|
|
713
|
+
'BEGIN:VCARD', 'VERSION:4.0', 'FN:Test', 'END:VCARD',
|
|
714
|
+
].join('\r\n');
|
|
715
|
+
const vcards = parse(card);
|
|
716
|
+
assert.equal(vcards.length, 1);
|
|
717
|
+
assert.equal(vcards[0]?.displayName, 'Test');
|
|
718
|
+
});
|
|
719
|
+
test('unclosed vCard is parsed tolerantly', () => {
|
|
720
|
+
const card = ['BEGIN:VCARD', 'VERSION:4.0', 'FN:Test'].join('\r\n');
|
|
721
|
+
const vcards = parse(card);
|
|
722
|
+
// Should still yield one vCard with a warning
|
|
723
|
+
assert.equal(vcards.length, 1);
|
|
724
|
+
assert.equal(vcards[0]?.displayName, 'Test');
|
|
725
|
+
assert.ok(vcards[0]?.parseWarnings.some(w => w.message.includes('END:VCARD')));
|
|
726
|
+
});
|
|
727
|
+
});
|
|
728
|
+
// ── Timezone format variations ─────────────────────────────────────────────
|
|
729
|
+
describe('Timezone format variations', () => {
|
|
730
|
+
test('TZ as bare UTC offset (no VALUE param)', () => {
|
|
731
|
+
const card = [
|
|
732
|
+
'BEGIN:VCARD', 'VERSION:4.0', 'FN:Test', 'TZ:-0500', 'END:VCARD',
|
|
733
|
+
].join('\r\n');
|
|
734
|
+
const [vc] = parse(card);
|
|
735
|
+
assert.ok(vc);
|
|
736
|
+
assert.equal(vc.tz.length, 1);
|
|
737
|
+
assert.equal(vc.tz[0]?.valueKind, 'utc-offset');
|
|
738
|
+
assert.equal(vc.tz[0]?.value, '-0500');
|
|
739
|
+
});
|
|
740
|
+
test('TZ as colon-format UTC offset', () => {
|
|
741
|
+
const card = [
|
|
742
|
+
'BEGIN:VCARD', 'VERSION:4.0', 'FN:Test', 'TZ:-05:00', 'END:VCARD',
|
|
743
|
+
].join('\r\n');
|
|
744
|
+
const [vc] = parse(card);
|
|
745
|
+
assert.ok(vc);
|
|
746
|
+
assert.equal(vc.tz[0]?.valueKind, 'utc-offset');
|
|
747
|
+
assert.equal(vc.tz[0]?.value, '-05:00');
|
|
748
|
+
});
|
|
749
|
+
test('TZ as IANA text (VALUE=text)', () => {
|
|
750
|
+
const card = [
|
|
751
|
+
'BEGIN:VCARD', 'VERSION:4.0', 'FN:Test',
|
|
752
|
+
'TZ;VALUE=text:America/New_York',
|
|
753
|
+
'END:VCARD',
|
|
754
|
+
].join('\r\n');
|
|
755
|
+
const [vc] = parse(card);
|
|
756
|
+
assert.ok(vc);
|
|
757
|
+
assert.equal(vc.tz[0]?.valueKind, 'text');
|
|
758
|
+
assert.equal(vc.tz[0]?.value, 'America/New_York');
|
|
759
|
+
});
|
|
760
|
+
test('TZ as URI (VALUE=uri)', () => {
|
|
761
|
+
const card = [
|
|
762
|
+
'BEGIN:VCARD', 'VERSION:4.0', 'FN:Test',
|
|
763
|
+
'TZ;VALUE=uri:https://www.iana.org/time-zones',
|
|
764
|
+
'END:VCARD',
|
|
765
|
+
].join('\r\n');
|
|
766
|
+
const [vc] = parse(card);
|
|
767
|
+
assert.ok(vc);
|
|
768
|
+
assert.equal(vc.tz[0]?.valueKind, 'uri');
|
|
769
|
+
});
|
|
770
|
+
});
|
|
771
|
+
// ── GENDER with identity text ─────────────────────────────────────────────
|
|
772
|
+
describe('GENDER property variations', () => {
|
|
773
|
+
test('M (male)', () => {
|
|
774
|
+
const card = ['BEGIN:VCARD', 'VERSION:4.0', 'FN:Test', 'GENDER:M', 'END:VCARD'].join('\r\n');
|
|
775
|
+
const [vc] = parse(card);
|
|
776
|
+
assert.equal(vc?.gender?.value.sex, 'M');
|
|
777
|
+
assert.equal(vc?.gender?.value.identity, undefined);
|
|
778
|
+
});
|
|
779
|
+
test('F (female)', () => {
|
|
780
|
+
const card = ['BEGIN:VCARD', 'VERSION:4.0', 'FN:Test', 'GENDER:F', 'END:VCARD'].join('\r\n');
|
|
781
|
+
const [vc] = parse(card);
|
|
782
|
+
assert.equal(vc?.gender?.value.sex, 'F');
|
|
783
|
+
});
|
|
784
|
+
test('O with identity text', () => {
|
|
785
|
+
const card = [
|
|
786
|
+
'BEGIN:VCARD', 'VERSION:4.0', 'FN:Test',
|
|
787
|
+
'GENDER:O;non-binary',
|
|
788
|
+
'END:VCARD',
|
|
789
|
+
].join('\r\n');
|
|
790
|
+
const [vc] = parse(card);
|
|
791
|
+
assert.equal(vc?.gender?.value.sex, 'O');
|
|
792
|
+
assert.equal(vc?.gender?.value.identity, 'non-binary');
|
|
793
|
+
});
|
|
794
|
+
test('U (unknown)', () => {
|
|
795
|
+
const card = ['BEGIN:VCARD', 'VERSION:4.0', 'FN:Test', 'GENDER:U', 'END:VCARD'].join('\r\n');
|
|
796
|
+
const [vc] = parse(card);
|
|
797
|
+
assert.equal(vc?.gender?.value.sex, 'U');
|
|
798
|
+
});
|
|
799
|
+
test('N (not applicable)', () => {
|
|
800
|
+
const card = ['BEGIN:VCARD', 'VERSION:4.0', 'FN:Test', 'GENDER:N', 'END:VCARD'].join('\r\n');
|
|
801
|
+
const [vc] = parse(card);
|
|
802
|
+
assert.equal(vc?.gender?.value.sex, 'N');
|
|
803
|
+
});
|
|
804
|
+
test('empty sex with identity', () => {
|
|
805
|
+
const card = [
|
|
806
|
+
'BEGIN:VCARD', 'VERSION:4.0', 'FN:Test',
|
|
807
|
+
'GENDER:;it/its',
|
|
808
|
+
'END:VCARD',
|
|
809
|
+
].join('\r\n');
|
|
810
|
+
const [vc] = parse(card);
|
|
811
|
+
assert.equal(vc?.gender?.value.sex, '');
|
|
812
|
+
assert.equal(vc?.gender?.value.identity, 'it/its');
|
|
813
|
+
});
|
|
814
|
+
test('GENDER with identity round-trips', () => {
|
|
815
|
+
const card = [
|
|
816
|
+
'BEGIN:VCARD', 'VERSION:4.0', 'FN:Test',
|
|
817
|
+
'GENDER:O;non-binary',
|
|
818
|
+
'END:VCARD',
|
|
819
|
+
].join('\r\n');
|
|
820
|
+
const [vc] = parse(card);
|
|
821
|
+
assert.ok(vc);
|
|
822
|
+
const out = vc.toString();
|
|
823
|
+
const [vc2] = parse(out);
|
|
824
|
+
assert.equal(vc2?.gender?.value.sex, 'O');
|
|
825
|
+
assert.equal(vc2?.gender?.value.identity, 'non-binary');
|
|
826
|
+
});
|
|
827
|
+
});
|
|
828
|
+
// ── RELATED property ──────────────────────────────────────────────────────
|
|
829
|
+
describe('RELATED property', () => {
|
|
830
|
+
test('RELATED as URI', () => {
|
|
831
|
+
const card = [
|
|
832
|
+
'BEGIN:VCARD', 'VERSION:4.0', 'FN:Test',
|
|
833
|
+
'RELATED;TYPE=spouse:urn:uuid:f81d4fae-7dec-11d0-a765-00a0c91e6bf6',
|
|
834
|
+
'END:VCARD',
|
|
835
|
+
].join('\r\n');
|
|
836
|
+
const [vc] = parse(card);
|
|
837
|
+
assert.ok(vc);
|
|
838
|
+
assert.equal(vc.related.length, 1);
|
|
839
|
+
assert.ok(vc.related[0]?.isUri);
|
|
840
|
+
assert.ok(vc.related[0]?.value.startsWith('urn:uuid:'));
|
|
841
|
+
assert.ok(vc.related[0]?.type.includes('spouse'));
|
|
842
|
+
});
|
|
843
|
+
test('RELATED as text (VALUE=text)', () => {
|
|
844
|
+
const card = [
|
|
845
|
+
'BEGIN:VCARD', 'VERSION:4.0', 'FN:Test',
|
|
846
|
+
'RELATED;VALUE=text;TYPE=friend:Jane Doe',
|
|
847
|
+
'END:VCARD',
|
|
848
|
+
].join('\r\n');
|
|
849
|
+
const [vc] = parse(card);
|
|
850
|
+
assert.ok(vc);
|
|
851
|
+
assert.equal(vc.related[0]?.value, 'Jane Doe');
|
|
852
|
+
assert.equal(vc.related[0]?.isUri, false);
|
|
853
|
+
});
|
|
854
|
+
});
|
|
855
|
+
// ── CLIENTPIDMAP ──────────────────────────────────────────────────────────
|
|
856
|
+
describe('CLIENTPIDMAP property', () => {
|
|
857
|
+
test('parses pid number and URI', () => {
|
|
858
|
+
const card = [
|
|
859
|
+
'BEGIN:VCARD',
|
|
860
|
+
'VERSION:4.0',
|
|
861
|
+
'FN:Test',
|
|
862
|
+
'CLIENTPIDMAP:1;urn:uuid:3df403f4-5924-4bb7-b077-3c711d9eb34b',
|
|
863
|
+
'END:VCARD',
|
|
864
|
+
].join('\r\n');
|
|
865
|
+
const [vc] = parse(card);
|
|
866
|
+
assert.ok(vc);
|
|
867
|
+
assert.equal(vc.clientpidmap.length, 1);
|
|
868
|
+
assert.equal(vc.clientpidmap[0]?.value.pid, 1);
|
|
869
|
+
assert.ok(vc.clientpidmap[0]?.value.uri.startsWith('urn:uuid:'));
|
|
870
|
+
});
|
|
871
|
+
test('multiple CLIENTPIDMAP entries', () => {
|
|
872
|
+
const card = [
|
|
873
|
+
'BEGIN:VCARD',
|
|
874
|
+
'VERSION:4.0',
|
|
875
|
+
'FN:Test',
|
|
876
|
+
'CLIENTPIDMAP:1;urn:uuid:aaa',
|
|
877
|
+
'CLIENTPIDMAP:2;urn:uuid:bbb',
|
|
878
|
+
'END:VCARD',
|
|
879
|
+
].join('\r\n');
|
|
880
|
+
const [vc] = parse(card);
|
|
881
|
+
assert.ok(vc);
|
|
882
|
+
assert.equal(vc.clientpidmap.length, 2);
|
|
883
|
+
assert.equal(vc.clientpidmap[0]?.value.pid, 1);
|
|
884
|
+
assert.equal(vc.clientpidmap[1]?.value.pid, 2);
|
|
885
|
+
});
|
|
886
|
+
});
|
|
887
|
+
// ── Long line folding with real data ─────────────────────────────────────
|
|
888
|
+
describe('Long-line folding with real data', () => {
|
|
889
|
+
test('very long NOTE is folded and unfolded correctly', () => {
|
|
890
|
+
const longNote = 'This is a very long note that goes on and on and on. ' +
|
|
891
|
+
'It contains important information that exceeds the 75 octet limit significantly. ' +
|
|
892
|
+
'The line folding mechanism must correctly handle this case.';
|
|
893
|
+
const vc = VCard.create('Test');
|
|
894
|
+
vc.note.push(new NoteProperty(longNote));
|
|
895
|
+
const out = vc.toString();
|
|
896
|
+
// All lines must be ≤ 75 octets
|
|
897
|
+
for (const line of out.split('\r\n').filter(Boolean)) {
|
|
898
|
+
assert.ok(Buffer.byteLength(line, 'utf8') <= 75, `Line too long (${Buffer.byteLength(line, 'utf8')} bytes): "${line.slice(0, 40)}..."`);
|
|
899
|
+
}
|
|
900
|
+
// Round-trip must preserve the note
|
|
901
|
+
const [vc2] = parse(out);
|
|
902
|
+
assert.equal(vc2?.note[0]?.value, longNote);
|
|
903
|
+
});
|
|
904
|
+
test('UID as urn:uuid round-trips correctly', () => {
|
|
905
|
+
const uid = 'urn:uuid:f81d4fae-7dec-11d0-a765-00a0c91e6bf6';
|
|
906
|
+
const card = [
|
|
907
|
+
'BEGIN:VCARD', 'VERSION:4.0', 'FN:Test',
|
|
908
|
+
`UID:${uid}`,
|
|
909
|
+
'END:VCARD',
|
|
910
|
+
].join('\r\n');
|
|
911
|
+
const [vc] = parse(card);
|
|
912
|
+
assert.ok(vc?.uid);
|
|
913
|
+
assert.equal(vc.uid.value, uid);
|
|
914
|
+
const out = vc.toString();
|
|
915
|
+
const [vc2] = parse(out);
|
|
916
|
+
assert.equal(vc2?.uid?.value, uid);
|
|
917
|
+
});
|
|
918
|
+
test('data: URI in PHOTO survives round-trip', () => {
|
|
919
|
+
// Abbreviated base64 for test
|
|
920
|
+
const dataUri = 'data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAEBAQEB';
|
|
921
|
+
const vc = VCard.create('Test');
|
|
922
|
+
vc.photo.push(new PhotoProperty(dataUri));
|
|
923
|
+
const out = vc.toString();
|
|
924
|
+
const [vc2] = parse(out);
|
|
925
|
+
assert.ok(vc2);
|
|
926
|
+
assert.equal(vc2.photo.length, 1);
|
|
927
|
+
assert.ok(vc2.photo[0]?.value.startsWith('data:image/jpeg'));
|
|
928
|
+
});
|
|
929
|
+
});
|
|
930
|
+
// ── toJSON coverage ────────────────────────────────────────────────────────
|
|
931
|
+
describe('toJSON()', () => {
|
|
932
|
+
test('gender appears in JSON', () => {
|
|
933
|
+
const [vc] = parse([
|
|
934
|
+
'BEGIN:VCARD', 'VERSION:4.0', 'FN:Test', 'GENDER:M', 'END:VCARD',
|
|
935
|
+
].join('\r\n'));
|
|
936
|
+
assert.ok(vc);
|
|
937
|
+
const json = vc.toJSON();
|
|
938
|
+
assert.deepEqual(json.gender, { sex: 'M' });
|
|
939
|
+
});
|
|
940
|
+
test('rev appears as ISO string in JSON', () => {
|
|
941
|
+
const [vc] = parse([
|
|
942
|
+
'BEGIN:VCARD', 'VERSION:4.0', 'FN:Test', 'REV:20240101T120000Z', 'END:VCARD',
|
|
943
|
+
].join('\r\n'));
|
|
944
|
+
assert.ok(vc);
|
|
945
|
+
const json = vc.toJSON();
|
|
946
|
+
assert.ok(typeof json.rev === 'string');
|
|
947
|
+
assert.ok(json.rev.startsWith('2024'));
|
|
948
|
+
});
|
|
949
|
+
});
|
|
950
|
+
//# sourceMappingURL=edgecases.test.js.map
|