Move whole-card cardinality checks into overloaded validate helpers in the vCard 3 and vCard 4 modules, and have readVCard invoke those validators after parsing instead of hard-coding property checks in the shared parser. For vCard 3, validate property cardinality directly from propertyCardMap and add regression coverage for duplicate single-cardinality properties. For vCard 4, validate from propertyCardMap as well, while preserving ALTID semantics for single-cardinality properties so alternate representations remain valid and duplicate ungrouped properties are rejected. Rework VERSION handling to treat it as parser metadata rather than stored card content. Parsing still requires exactly one canonical VERSION line and rejects missing or duplicate occurrences, but parsed cards no longer retain VC3_Version or VC4_Version objects in content. Accessors and serializers continue to expose and emit the canonical version for the concrete card type, which keeps parsed and programmatically constructed cards on the same model. Update the vCard 3 and vCard 4 test suites to cover the new validation path, confirm VERSION is not persisted in content, and adjust fixture expectations to match the canonical VERSION model. AI-Assisted: yes AI-Tool: OpenAI Codex / gpt-5.4 xhigh
323 lines
10 KiB
Nim
323 lines
10 KiB
Nim
import std/[options, sequtils, strutils, times, unittest]
|
|
import zero_functional
|
|
|
|
import vcard
|
|
import vcard/vcard3
|
|
|
|
template vcard3Doc(lines: varargs[string]): string =
|
|
"BEGIN:VCARD\r\n" &
|
|
lines.join("\r\n") &
|
|
"\r\nEND:VCARD\r\n"
|
|
|
|
proc parseSingleVCard3(content: string): VCard3 =
|
|
cast[VCard3](parseVCards(content)[0])
|
|
|
|
proc newMinimalVCard3(): VCard3 =
|
|
result = VCard3(parsedVersion: VCardV3)
|
|
result.add(newVC3_Fn("John Smith"))
|
|
result.add(newVC3_N(family = @["Smith"], given = @["John"]))
|
|
|
|
suite "vcard/vcard3":
|
|
|
|
test "vcard3/private tests":
|
|
runVcard3PrivateTests()
|
|
|
|
let jdbVCard = readFile("tests/jdb.vcf")
|
|
|
|
test "parseVCard3":
|
|
check parseVCards(jdbVCard).len == 1
|
|
|
|
test "parseVCard3File":
|
|
check parseVCardsFromFile("tests/jdb.vcf").len == 1
|
|
|
|
# TODO: remove cast after finishing VCard4 implementation
|
|
let jdb = cast[VCard3](parseVCards(jdbVCard)[0])
|
|
|
|
test "email is parsed correctly":
|
|
check:
|
|
jdb.email.len == 7
|
|
jdb.email[0].value == "jonathan@jdbernard.com"
|
|
jdb.email[0].emailType.contains("pref")
|
|
jdb.email[0].emailType.contains("home")
|
|
jdb.email[1].value == "jdb@jdb-software.com"
|
|
jdb.email[1].emailType.contains("work")
|
|
jdb.email[2].group.isSome
|
|
jdb.email[2].group.get == "email2"
|
|
jdb.email[6].value == "jbernard@vectra.ai"
|
|
jdb.email[6].emailType.contains("work")
|
|
|
|
test "tel is parsed correctly":
|
|
check:
|
|
jdb.tel.len == 2
|
|
jdb.tel[0].value == "(512) 777-1602"
|
|
jdb.tel[0].telType.contains("CELL")
|
|
|
|
test "RFC2426 Author's VCards":
|
|
let vcardsStr =
|
|
"BEGIN:vCard\r\n" &
|
|
"VERSION:3.0\r\n" &
|
|
"FN:Frank Dawson\r\n" &
|
|
"N:Dawson;Frank;;;\r\n" &
|
|
"ORG:Lotus Development Corporation\r\n" &
|
|
"ADR;TYPE=WORK,POSTAL,PARCEL:;;6544 Battleford Drive\r\n" &
|
|
" ;Raleigh;NC;27613-3502;U.S.A.\r\n" &
|
|
"TEL;TYPE=VOICE,MSG,WORK:+1-919-676-9515\r\n" &
|
|
"TEL;TYPE=FAX,WORK:+1-919-676-9564\r\n" &
|
|
"EMAIL;TYPE=INTERNET,PREF:Frank_Dawson@Lotus.com\r\n" &
|
|
"EMAIL;TYPE=INTERNET:fdawson@earthlink.net\r\n" &
|
|
"URL:http://home.earthlink.net/~fdawson\r\n" &
|
|
"END:vCard\r\n" &
|
|
"\r\n" &
|
|
"\r\n" &
|
|
"BEGIN:vCard\r\n" &
|
|
"VERSION:3.0\r\n" &
|
|
"FN:Tim Howes\r\n" &
|
|
"N:Howes;Tim;;;\r\n" &
|
|
"ORG:Netscape Communications Corp.\r\n" &
|
|
"ADR;TYPE=WORK:;;501 E. Middlefield Rd.;Mountain View;\r\n" &
|
|
" CA; 94043;U.S.A.\r\n" &
|
|
"TEL;TYPE=VOICE,MSG,WORK:+1-415-937-3419\r\n" &
|
|
"TEL;TYPE=FAX,WORK:+1-415-528-4164\r\n" &
|
|
"EMAIL;TYPE=INTERNET:howes@netscape.com\r\n" &
|
|
"END:vCard\r\n"
|
|
|
|
let vcards = parseVCards(vcardsStr)
|
|
check:
|
|
vcards.len == 2
|
|
cast[VCard3](vcards[0]).fn.value == "Frank Dawson"
|
|
cast[VCard3](vcards[0]).email.len == 2
|
|
(cast[VCard3](vcards[0]).email --> find(it.emailType.contains("PREF"))).isSome
|
|
|
|
test "spec: parser rejects cards missing VERSION":
|
|
expect(VCardParsingError):
|
|
discard parseVCards(vcard3Doc(
|
|
"FN:John Smith",
|
|
"N:Smith;John;;;"))
|
|
|
|
test "spec: parser rejects cards missing FN":
|
|
expect(VCardParsingError):
|
|
discard parseVCards(vcard3Doc(
|
|
"VERSION:3.0",
|
|
"N:Smith;John;;;"))
|
|
|
|
test "spec: parser rejects cards missing N":
|
|
expect(VCardParsingError):
|
|
discard parseVCards(vcard3Doc(
|
|
"VERSION:3.0",
|
|
"FN:John Smith"))
|
|
|
|
test "spec: parser rejects duplicate single-cardinality vCard 3 properties":
|
|
expect(VCardParsingError):
|
|
discard parseVCards(vcard3Doc(
|
|
"VERSION:3.0",
|
|
"FN:John Smith",
|
|
"N:Smith;John;;;",
|
|
"BDAY:2000-01-01",
|
|
"BDAY:2000-01-02"))
|
|
|
|
test "spec: VERSION is not stored as vCard 3 content":
|
|
let parsed = parseSingleVCard3(vcard3Doc(
|
|
"VERSION:3.0",
|
|
"FN:John Smith",
|
|
"N:Smith;John;;;"))
|
|
|
|
check:
|
|
parsed.version.value == "3.0"
|
|
parsed.content.countIt(it of VC3_Version) == 0
|
|
|
|
test "spec: simple text values decode RFC 2426 escapes when parsing":
|
|
let parsed = parseSingleVCard3(vcard3Doc(
|
|
"VERSION:3.0",
|
|
r"FN:Jane\, Smith\; Esq.\\Office\nSecond line",
|
|
"N:Smith;Jane;;;"))
|
|
|
|
check parsed.fn.value == "Jane, Smith; Esq.\\Office\nSecond line"
|
|
|
|
test "spec: affected text properties decode RFC 2426 escapes when parsing":
|
|
let parsed = parseSingleVCard3(vcard3Doc(
|
|
"VERSION:3.0",
|
|
"FN:John Smith",
|
|
"N:Smith;John;;;",
|
|
r"NICKNAME:Johnny\, Jr.\nTwo",
|
|
r"LABEL:123 Main St.\nSuite 100\; Mail Stop",
|
|
r"MAILER:Mailer\\Pro",
|
|
r"TITLE:Lead\; Engineer",
|
|
r"ROLE:Ops\, Support",
|
|
r"PRODID:-//Example\\Corp//EN",
|
|
r"SORT-STRING:Smith\, John"))
|
|
|
|
check:
|
|
parsed.nickname.isSome
|
|
parsed.nickname.get.value == "Johnny, Jr.\nTwo"
|
|
parsed.label.len == 1
|
|
parsed.label[0].value == "123 Main St.\nSuite 100; Mail Stop"
|
|
parsed.mailer.len == 1
|
|
parsed.mailer[0].value == "Mailer\\Pro"
|
|
parsed.title.len == 1
|
|
parsed.title[0].value == "Lead; Engineer"
|
|
parsed.role.len == 1
|
|
parsed.role[0].value == "Ops, Support"
|
|
parsed.prodid.isSome
|
|
parsed.prodid.get.value == "-//Example\\Corp//EN"
|
|
parsed.sortstring.isSome
|
|
parsed.sortstring.get.value == "Smith, John"
|
|
|
|
test "spec: simple text values escape special characters when serializing":
|
|
let vc = newMinimalVCard3()
|
|
vc.set(newVC3_Fn("Jane, Smith; Esq.\\Office\nSecond line"))
|
|
|
|
check ($vc).contains(r"FN:Jane\, Smith\; Esq.\\Office\nSecond line")
|
|
|
|
test "spec: structured text values escape special characters when serializing":
|
|
let vc = VCard3(parsedVersion: VCardV3)
|
|
vc.add(newVC3_Fn("John Smith"))
|
|
vc.add(newVC3_N(
|
|
family = @["Smith, Sr."],
|
|
given = @["John;Jack"],
|
|
additional = @["Back\\Slash"],
|
|
prefixes = @["Dr.\nProf."],
|
|
suffixes = @["III"]))
|
|
|
|
check ($vc).contains(r"N:Smith\, Sr.;John\;Jack;Back\\Slash;Dr.\nProf.;III")
|
|
|
|
test "spec: list text values escape special characters when serializing":
|
|
let vc = newMinimalVCard3()
|
|
vc.add(newVC3_Categories(@["alpha,beta", "semi;colon", "slash\\value"]))
|
|
|
|
check ($vc).contains(r"CATEGORIES:alpha\,beta,semi\;colon,slash\\value")
|
|
|
|
test "spec: inline binary values round-trip without double encoding":
|
|
let payload = "aGVsbG8="
|
|
let serialized = $parseSingleVCard3(vcard3Doc(
|
|
"VERSION:3.0",
|
|
"FN:John Smith",
|
|
"N:Smith;John;;;",
|
|
"PHOTO;ENCODING=b;TYPE=JPEG:" & payload,
|
|
"LOGO;ENCODING=b;TYPE=GIF:" & payload,
|
|
"SOUND;ENCODING=b;TYPE=WAVE:" & payload,
|
|
"KEY;ENCODING=b;TYPE=PGP:" & payload))
|
|
|
|
check:
|
|
serialized.contains("PHOTO;ENCODING=b;TYPE=JPEG:" & payload)
|
|
serialized.contains("LOGO;ENCODING=b;TYPE=GIF:" & payload)
|
|
serialized.contains("SOUND;ENCODING=b;TYPE=WAVE:" & payload)
|
|
serialized.contains("KEY;ENCODING=b;TYPE=PGP:" & payload)
|
|
|
|
test "spec: PHOTO, LOGO, and SOUND may round-trip as uris":
|
|
let serialized = $parseSingleVCard3(vcard3Doc(
|
|
"VERSION:3.0",
|
|
"FN:John Smith",
|
|
"N:Smith;John;;;",
|
|
"PHOTO;VALUE=uri:http://example.test/photo.jpg",
|
|
"LOGO;VALUE=uri:http://example.test/logo.gif",
|
|
"SOUND;VALUE=uri:http://example.test/sound.wav"))
|
|
|
|
check:
|
|
serialized.contains("PHOTO;VALUE=uri:http://example.test/photo.jpg")
|
|
serialized.contains("LOGO;VALUE=uri:http://example.test/logo.gif")
|
|
serialized.contains("SOUND;VALUE=uri:http://example.test/sound.wav")
|
|
|
|
test "spec: KEY does not allow uri values":
|
|
expect(VCardParsingError):
|
|
discard parseVCards(vcard3Doc(
|
|
"VERSION:3.0",
|
|
"FN:John Smith",
|
|
"N:Smith;John;;;",
|
|
"KEY;VALUE=uri:http://example.test/key.asc"))
|
|
|
|
test "spec: KEY may contain text values that look like uris":
|
|
let serialized = $parseSingleVCard3(vcard3Doc(
|
|
"VERSION:3.0",
|
|
"FN:John Smith",
|
|
"N:Smith;John;;;",
|
|
"KEY;TYPE=PGP:http://example.test/key.pgp"))
|
|
|
|
check serialized.contains("KEY;TYPE=PGP:http://example.test/key.pgp")
|
|
|
|
test "spec: KEY parameters must use name=value syntax in vCard 3":
|
|
expect(VCardParsingError):
|
|
discard parseVCards(vcard3Doc(
|
|
"VERSION:3.0",
|
|
"FN:John Smith",
|
|
"N:Smith;John;;;",
|
|
"KEY;PGP:http://example.test/key.pgp"))
|
|
|
|
test "spec: quoted parameter values are accepted":
|
|
let parsed = parseSingleVCard3(vcard3Doc(
|
|
"VERSION:3.0",
|
|
"FN;LANGUAGE=\"en\":John Smith",
|
|
"LABEL;TYPE=\"HOME,POSTAL\":123 Main St.",
|
|
"N:Smith;John;;;"))
|
|
|
|
check:
|
|
parsed.fn.language == some("en")
|
|
parsed.label.len == 1
|
|
parsed.label[0].adrType == @["HOME,POSTAL"]
|
|
|
|
test "spec: PROFILE is exposed as the standard property type":
|
|
let parsed = parseSingleVCard3(vcard3Doc(
|
|
"VERSION:3.0",
|
|
"PROFILE:VCARD",
|
|
"FN:John Smith",
|
|
"N:Smith;John;;;"))
|
|
|
|
check:
|
|
parsed.profile.isSome
|
|
($parsed).contains("PROFILE:VCARD")
|
|
|
|
test "spec: AGENT uri values survive parse and serialize":
|
|
let serialized = $parseSingleVCard3(vcard3Doc(
|
|
"VERSION:3.0",
|
|
"FN:John Smith",
|
|
"N:Smith;John;;;",
|
|
"AGENT;VALUE=uri:mailto:assistant@example.com"))
|
|
|
|
check serialized.contains("AGENT;VALUE=uri:mailto:assistant@example.com")
|
|
|
|
test "spec: constructed AGENT values serialize with their content":
|
|
let vc = newMinimalVCard3()
|
|
vc.add(newVC3_Agent("mailto:assistant@example.com", isInline = false))
|
|
|
|
check ($vc).contains("AGENT;VALUE=uri:mailto:assistant@example.com")
|
|
|
|
test "spec: folded lines may continue with horizontal tab":
|
|
let parsed = parseSingleVCard3(
|
|
"BEGIN:VCARD\r\n" &
|
|
"VERSION:3.0\r\n" &
|
|
"FN:John Smith\r\n" &
|
|
"N:Smith;John;;;\r\n" &
|
|
"NOTE:one two \r\n" &
|
|
"\tthree four\r\n" &
|
|
"END:VCARD\r\n")
|
|
|
|
check:
|
|
parsed.note.len == 1
|
|
parsed.note[0].value == "one two three four"
|
|
|
|
test "spec: group names may contain hyphens":
|
|
let parsed = parseSingleVCard3(vcard3Doc(
|
|
"VERSION:3.0",
|
|
"FN:John Smith",
|
|
"N:Smith;John;;;",
|
|
"item-1.EMAIL:test@example.com"))
|
|
|
|
check:
|
|
parsed.email.len == 1
|
|
parsed.email[0].group == some("item-1")
|
|
|
|
test "spec: REV with VALUE=date serializes as a date":
|
|
let vc = newMinimalVCard3()
|
|
vc.add(newVC3_Rev(
|
|
value = parse("2000-01-02", "yyyy-MM-dd"),
|
|
valueType = some("date")))
|
|
|
|
check ($vc).contains("REV;VALUE=date:2000-01-02")
|
|
|
|
test "spec: KEY defaults to text rather than uri":
|
|
let vc = newMinimalVCard3()
|
|
vc.add(newVC3_Key("ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQC"))
|
|
|
|
check:
|
|
($vc).contains("KEY:ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQC")
|
|
not ($vc).contains("KEY;VALUE=uri:")
|