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
267 lines
8.6 KiB
Nim
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."]]
|