Files
nim-vcard/tests/tvcard4.nim
Jonathan Bernard e3f0fdc1ab Refactor vCard validation and canonicalize VERSION handling
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
2026-03-28 19:41:09 -05:00

267 lines
8.6 KiB
Nim

import std/[options, sequtils, strutils, tables, unittest]
import zero_functional
import vcard
import vcard/vcard4
suite "vcard/vcard4":
test "vcard4/private tests":
runVcard4PrivateTests()
let v4ExampleStr = readFile("tests/allen.foster.v4.vcf")
let testVCardTemplate =
"BEGIN:VCARD\r\n" &
"VERSION:4.0\r\n" &
"$#" &
"END:VCARD\r\n"
test "parseVCard4":
check parseVCards(v4ExampleStr).len == 1
test "parseVCard4File":
check parseVCardsFromFile("tests/allen.foster.v4.vcf").len == 1
# TODO: remove cast after finishing VCard4 implementation
let v4Ex = cast[VCard4](parseVCards(v4ExampleStr)[0])
test "RFC 6350 author's VCard":
let vcardStr =
"BEGIN:VCARD\r\n" &
"VERSION:4.0\r\n" &
"FN:Simon Perreault\r\n" &
"N:Perreault;Simon;;;ing. jr,M.Sc.\r\n" &
"BDAY:--0203\r\n" &
"ANNIVERSARY:20090808T1430-0500\r\n" &
"GENDER:M\r\n" &
"LANG;PREF=1:fr\r\n" &
"LANG;PREF=2:en\r\n" &
"ORG;TYPE=work:Viagenie\r\n" &
"ADR;TYPE=work:;Suite D2-630;2875 Laurier;\r\n" &
" Quebec;QC;G1V 2M2;Canada\r\n" &
"TEL;VALUE=uri;TYPE=\"work,voice\";PREF=1:tel:+1-418-656-9254;ext=102\r\n" &
"TEL;VALUE=uri;TYPE=\"work,cell,voice,video,text\":tel:+1-418-262-6501\r\n" &
"EMAIL;TYPE=work:simon.perreault@viagenie.ca\r\n" &
"GEO;TYPE=work:geo:46.772673,-71.282945\r\n" &
"KEY;TYPE=work;VALUE=uri:\r\n" &
" http://www.viagenie.ca/simon.perreault/simon.asc\r\n" &
"TZ:-0500\r\n" &
"URL;TYPE=home:http://nomis80.org\r\n" &
"END:VCARD\r\n"
let vcards = parseVCards(vcardStr)
check vcards.len == 1
let sp = cast[VCard4](vcards[0])
check:
sp.fn.len == 1
sp.fn[0].value == "Simon Perreault"
sp.gender.isSome
sp.gender.get.sex == some(VC4_Sex.Male)
sp.gender.get.genderIdentity.isNone
sp.lang.len == 2
sp.lang --> map(it.value) == @["fr", "en"]
test "custom properties are serialized":
let email = newVC4_Email(
value ="john.smith@testco.test",
types = @["work", "internet"],
params = @[("PREF", @["1"]), ("X-ATTACHMENT-LIMIT", @["25MB"])])
let serialized = serialize(email)
check:
serialized.startsWith("EMAIL;")
serialized.endsWith(":john.smith@testco.test")
serialized.contains(";PREF=1")
serialized.contains(";TYPE=work,internet")
serialized.contains(";X-ATTACHMENT-LIMIT=25MB")
serialized.count(';') == 3
test "can parse properties with escaped characters":
check v4Ex.note.len == 1
let note = v4Ex.note[0]
check note.value ==
"This is an example, for clarity; in text value cases the parser " &
"will recognize escape values for ',', '\\', and newlines. For " &
"example:" &
"\n\t123 Flagstaff Road" &
"\n\tPlaceville, MA"
test "can parse parameters with escaped characters":
let prop = v4Ex.customProp("X-CUSTOM-EXAMPLE")[0]
check prop.value ==
"This is an example, for clarity; in straight value cases, the parser " &
"does not recognize any escape values, as the meaning of the content " &
"is implementation-specific."
let param1 = prop.params --> filter(it.name == "PARAM")
let label = prop.params --> filter(it.name == "LABEL")
check:
param1.len == 1
param1[0].values == @["How one says, \"Hello.\""]
label.len == 1
label[0].values == @["^top\nsecond line"]
test "Data URIs are parsed correctly":
let expectedB64 = readFile("tests/allen.foster.jpg.uri")
check:
v4Ex.photo.len == 2
v4Ex.photo[0].altId == some("1")
v4Ex.photo[0].value ==
"https://tile.loc.gov/storage-services/service/pnp/bellcm/02200/02297r.jpg"
v4Ex.photo[0].valueType == some("uri")
v4Ex.photo[1].altId == some("1")
v4Ex.photo[1].value == expectedB64
v4Ex.photo[1].valueType.isNone
test "URI-type properties are parsed correctly":
# Covers SOURCE, PHOTO, IMPP, GEO, LOGO, MEMBER, SOUND, URL, FBURL,
# CALADRURI, and CALURI
check:
v4Ex.source.len == 1
v4Ex.source[0].value == "https://carddav.fosters.test/allen.vcf"
v4Ex.source[0].valueType == some("uri")
v4Ex.url.len == 1
v4Ex.url[0].value == "https://allen.fosters.test/"
test "URI-type properties are serialized correctly":
# Covers SOURCE, PHOTO, IMPP, GEO, LOGO, MEMBER, SOUND, URL, FBURL,
# CALADRURI, and CALURI
let src = newVC4_Source(value="https://carddav.example.test/john-smith.vcf")
check serialize(src) == "SOURCE:https://carddav.example.test/john-smith.vcf"
test "Single-text properties are parsed correctly":
# Covers KIND, XML, FN, NICKNAME, EMAIL, LANG, TZ, TITLE, ROLE, ORG, NOTE,
# PRODID, and VERSION
check:
v4Ex.kind.isSome
v4Ex.kind.get.value == "individual"
v4Ex.nickname.len == 2
v4Ex.nickname[0].value == @["Jack Jr."]
v4Ex.nickname[1].value == @["Doc A"]
v4Ex.fn.len == 1
v4Ex.fn[0].value == "Dr. Allen Foster"
v4Ex.email.len == 2
v4Ex.email[0].value == "jack.foster@company.test"
v4Ex.email[0].types == @["work"]
test "URI or Text properties are parsed correctly":
# Covers TEL, RELATED, UID, KEY
check:
v4Ex.tel.len == 3
v4ex.tel[0].types == @[$VC4_TelType.ttCell]
v4Ex.tel[0].value == "+1 555-123-4567"
v4Ex.tel[2].types == @[$VC4_TelType.ttWork,$VC4_TelType.ttVoice]
v4Ex.tel[2].valueType == some($vtUri)
v4Ex.tel[2].value == "tel:+1-555-874-1234"
test "N is parsed correctly":
check:
v4Ex.n.isSome
v4Ex.n.get.given == @["Jack"]
v4Ex.n.get.family == @["Foster"]
v4Ex.n.get.additional == @["John", "Allen"]
v4Ex.n.get.prefixes == @["Dr."]
v4Ex.n.get.suffixes == @["II"]
test "BDAY is parsed correctly":
check:
v4Ex.bday.isSome
v4Ex.bday.get.value == "--1224"
v4Ex.bday.get.year.isNone
v4Ex.bday.get.month == some(12)
v4Ex.bday.get.day == some(24)
test "ANNIVERSARY is parsed correctly":
check:
v4Ex.anniversary.isSome
v4Ex.anniversary.get.value == "20140612T163000-0500"
v4Ex.anniversary.get.year == some(2014)
v4Ex.anniversary.get.hour == some(16)
v4Ex.anniversary.get.minute == some(30)
v4Ex.anniversary.get.timezone == some("-0500")
test "GENDER is parsed correctly":
check:
v4Ex.gender.isSome
v4Ex.gender.get.sex == some(VC4_Sex.Male)
v4Ex.gender.get.genderIdentity == some("male")
#[
test "CATEGORIES is parsed correctly":
test "REV is parsed correctly":
test "CLIENTPIDMAP is parsed correctly":
]#
test "unknown properties are parsed correctly":
check v4Ex.customProp("MADE-UP-PROP").len == 1
let madeUpProp = v4Ex.customProp("MADE-UP-PROP")[0]
check:
madeUpProp.name == "MADE-UP-PROP"
madeUpProp.value == "Sample value for my made-up prop."
let cardWithAltBdayStr = testVCardTemplate % [(
"FN:Simon Perreault\r\n" &
"BDAY;VALUE=text;ALTID=1:20th century\r\n" &
"BDAY;VALUE=date-and-or-time;ALTID=1:19650321\r\n"
)]
test "single-cardinality properties allow multiples with ALTID":
check parseVCards(cardWithAltBdayStr).len == 1
test "single-cardinality properties reject multiples without ALTID":
expect(VCardParsingError):
discard parseVCards(testVCardTemplate % [(
"FN:Simon Perreault\r\n" &
"BDAY;VALUE=text:20th century\r\n" &
"BDAY;VALUE=date-and-or-time:19650321\r\n"
)])
let hasAltBdays = cast[VCard4](parseVCards(cardWithAltBdayStr)[0])
test "properties with cardinality 1 and altids return the first found by default":
check:
hasAltBdays.bday.isSome
hasAltBdays.bday.get.value == "20th century"
hasAltBdays.bday.get.year.isNone
test "allAlternatives":
check:
hasAltBdays.content.len == 3
hasAltBdays.bday.isSome
hasAltBdays.content.countIt(it of VC4_Version) == 0
let allBdays = allAlternatives[VC4_Bday](hasAltBdays)
check:
allBdays.len == 1
allBdays.contains("1")
allBdays["1"].len == 2
let bday0 = allBdays["1"][0]
check:
bday0.value == "20th century"
bday0.year.isNone
bday0.month.isNone
bday0.day.isNone
bday0.hour.isNone
bday0.minute.isNone
bday0.second.isNone
bday0.timezone.isNone
let bday1 = allBDays["1"][1]
check:
bday1.value == "19650321"
bday1.year == some(1965)
bday1.month == some(3)
bday1.day == some(21)
bday1.hour.isNone
bday1.minute.isNone
bday1.second.isNone
test "PREF ordering":
check:
v4Ex.nickname --> map(it.value) == @[@["Jack Jr."], @["Doc A"]]
v4Ex.nickname.inPrefOrder --> map(it.value) == @[@["Doc A"], @["Jack Jr."]]