import Foundation import Testing @testable import IPodSyncKit @Suite("Sanitizing existing .vcf files") struct VCardFileSanitizerTests { /// Mixed 2.1/3.0, an embedded photo, a CRM extension, an Apple property /// group, and a quoted-printable name — i.e. what a real export looks like. private let sample = """ BEGIN:VCARD VERSION:3.0 N:Lovelace;Ada;;; FN:Ada Lovelace TEL;TYPE=CELL:+15550100 PHOTO;ENCODING=b;TYPE=JPEG:/9j/4AAQSkZJRgABAQAAAQABAAD X-CRM-ID:12345 item1.EMAIL;TYPE=INTERNET:ada@example.com item1.X-ABLabel:_$!!$_ END:VCARD BEGIN:VCARD VERSION:2.1 N;ENCODING=QUOTED-PRINTABLE;CHARSET=UTF-8:Fleming;Ren=C3=A9e;;; FN;ENCODING=QUOTED-PRINTABLE;CHARSET=UTF-8:Ren=C3=A9e Fleming TEL;HOME;VOICE:+15550111 END:VCARD """ @Test("Each card becomes its own file") func splitsCards() { let result = VCardFileSanitizer.sanitize(text: sample) #expect(result.files.count == 2) #expect(result.files[0].suggestedName == "Ada Lovelace") #expect(result.files.allSatisfy { $0.contents.hasPrefix("BEGIN:VCARD\r\n") }) #expect(result.files.allSatisfy { $0.contents.hasSuffix("END:VCARD\r\n") }) } @Test("Binary payloads and X- extensions are removed") func stripsProblemProperties() { let contents = VCardFileSanitizer.sanitize(text: sample).files[0].contents #expect(!contents.contains("PHOTO")) #expect(!contents.contains("X-CRM-ID")) #expect(!contents.contains("X-ABLabel")) #expect(contents.contains("TEL;TYPE=CELL:+15550100")) } @Test("Apple's property groups are unwrapped") func removesGroupPrefixes() { let contents = VCardFileSanitizer.sanitize(text: sample).files[0].contents #expect(contents.contains("EMAIL;TYPE=INTERNET:ada@example.com")) #expect(!contents.contains("item1.")) } @Test("Quoted-printable is decoded, and the filename uses the decoded name") func decodesQuotedPrintable() { let file = VCardFileSanitizer.sanitize(text: sample).files[1] #expect(file.contents.contains("FN:Renée Fleming")) #expect(!file.contents.contains("QUOTED-PRINTABLE")) #expect(!file.contents.contains("CHARSET")) // The bug worth guarding: naming from the raw field yields "Ren=C3=A9e". #expect(file.suggestedName == "Renée Fleming") } @Test("Soft line breaks in quoted-printable values are rejoined") func rejoinsSoftLineBreaks() { let text = """ BEGIN:VCARD VERSION:2.1 FN;ENCODING=QUOTED-PRINTABLE;CHARSET=UTF-8:Ren= =C3=A9e Fleming END:VCARD """ let file = VCardFileSanitizer.sanitize(text: text).files[0] #expect(file.contents.contains("FN:Renée Fleming")) } @Test("Cards without END:VCARD are reported, not silently written") func unterminatedCard() { let result = VCardFileSanitizer.sanitize(text: "BEGIN:VCARD\nFN:Broken") #expect(result.files.isEmpty) #expect(result.warnings.contains { $0.contains("END:VCARD") }) } @Test("A file with no cards is flagged") func notAVCard() { let result = VCardFileSanitizer.sanitize(text: "hello, world") #expect(result.files.isEmpty) #expect(result.warnings.contains { $0.contains("No vCards") }) } @Test("Names fall back from FN to N to ORG") func nameFallbacks() { #expect(VCardFileSanitizer.displayName(in: ["N:Smith;John;;;"]) == "John Smith") #expect(VCardFileSanitizer.displayName(in: ["ORG:Acme;Sales"]) == "Acme") #expect(VCardFileSanitizer.displayName(in: ["TEL:123"]) == "contact") } } @Suite("Sanitizing existing .ics files") struct ICalendarFileSanitizerTests { private let zone = TimeZone(identifier: "America/Toronto")! private let sample = """ BEGIN:VCALENDAR VERSION:2.0 PRODID:-//Apple Inc.//macOS 26.6//EN CALSCALE:GREGORIAN BEGIN:VTIMEZONE TZID:America/Toronto BEGIN:DAYLIGHT TZOFFSETFROM:-0500 TZNAME:EDT END:DAYLIGHT BEGIN:STANDARD TZOFFSETFROM:-0400 TZNAME:EST END:STANDARD END:VTIMEZONE BEGIN:VEVENT UID:1234@example.com DTSTAMP:20260801T120000Z DTSTART;TZID=America/Toronto:20260810T093000 DTEND;TZID=America/Toronto:20260810T100000 SUMMARY:Standup X-APPLE-TRAVEL-ADVISORY-BEHAVIOR:AUTOMATIC BEGIN:VALARM ACTION:DISPLAY TRIGGER:-PT15M END:VALARM END:VEVENT END:VCALENDAR """ @Test("Timezone blocks, alarms and X- properties are removed") func stripsProblemComponents() { let contents = ICalendarFileSanitizer .sanitize(text: sample, name: "Work", timeZone: zone) .files[0].contents #expect(!contents.contains("VTIMEZONE")) #expect(!contents.contains("DAYLIGHT")) #expect(!contents.contains("STANDARD")) #expect(!contents.contains("VALARM")) #expect(!contents.contains("TRIGGER")) #expect(!contents.contains("X-APPLE")) } @Test("TZID is dropped, leaving the same wall-clock reading as a floating time") func stripsTZID() { let contents = ICalendarFileSanitizer .sanitize(text: sample, name: "Work", timeZone: zone) .files[0].contents #expect(contents.contains("DTSTART:20260810T093000")) #expect(contents.contains("DTEND:20260810T100000")) #expect(!contents.contains("TZID")) } @Test("The surrounding calendar structure survives") func keepsStructure() { let contents = ICalendarFileSanitizer .sanitize(text: sample, name: "Work", timeZone: zone) .files[0].contents #expect(contents.hasPrefix("BEGIN:VCALENDAR\r\n")) #expect(contents.hasSuffix("END:VCALENDAR\r\n")) #expect(contents.contains("BEGIN:VEVENT")) #expect(contents.contains("SUMMARY:Standup")) #expect(contents.contains("UID:1234@example.com")) } @Test("UTC start times become local floating times") func convertsUTCToFloating() { let text = """ BEGIN:VCALENDAR BEGIN:VEVENT UID:1 DTSTART:20260810T133000Z DTEND:20260810T140000Z SUMMARY:Call END:VEVENT END:VCALENDAR """ let contents = ICalendarFileSanitizer .sanitize(text: text, name: "Work", timeZone: zone) .files[0].contents // 13:30 UTC is 09:30 in Toronto on that date. #expect(contents.contains("DTSTART:20260810T093000")) #expect(contents.contains("DTEND:20260810T100000")) } @Test("DTSTAMP stays in UTC, since it is bookkeeping the device never shows") func leavesDTSTAMP() { let contents = ICalendarFileSanitizer .sanitize(text: sample, name: "Work", timeZone: zone) .files[0].contents #expect(contents.contains("DTSTAMP:20260801T120000Z")) } @Test("Recurring events are kept but flagged") func flagsRecurrence() { let text = sample.replacingOccurrences( of: "SUMMARY:Standup", with: "SUMMARY:Standup\nRRULE:FREQ=WEEKLY;BYDAY=MO" ) let result = ICalendarFileSanitizer.sanitize(text: text, name: "Work", timeZone: zone) #expect(result.files[0].contents.contains("RRULE:FREQ=WEEKLY")) #expect(result.warnings.contains { $0.contains("RRULE") }) } @Test("A file with no events is flagged") func notACalendar() { let result = ICalendarFileSanitizer.sanitize(text: "hello", name: "x", timeZone: zone) #expect(result.warnings.contains { $0.contains("No events") }) } } @Suite("Quoted-printable decoding") struct QuotedPrintableTests { @Test("Decodes UTF-8 escapes") func utf8() { #expect(QuotedPrintable.decode("Jos=C3=A9") == "José") #expect(QuotedPrintable.decode("plain text") == "plain text") } @Test("Falls back for bytes that are not valid UTF-8") func latin1Fallback() { // 0xE9 is é in Latin-1 but an invalid UTF-8 sequence on its own. #expect(QuotedPrintable.decode("Jos=E9", charset: "ISO-8859-1") == "José") } @Test("Leaves malformed escapes alone") func malformed() { #expect(QuotedPrintable.decode("100=% sure") == "100=% sure") } } @Suite("Writing an export") struct ExportSessionTests { private func temporaryDirectory() throws -> URL { let url = URL(fileURLWithPath: NSTemporaryDirectory()) .appendingPathComponent("ipod-sync-tests-\(UUID().uuidString)") try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) return url } @Test("Contacts and calendars land in folders matching the device") func folderLayout() throws { let directory = try temporaryDirectory() defer { try? FileManager.default.removeItem(at: directory) } var session = ExportSession(destination: directory) try session.write(contacts: [IPodContact(givenName: "Ada", familyName: "Lovelace")]) try session.write( events: [IPodEvent(uid: "a", summary: "Standup", start: .now, end: .now.addingTimeInterval(1800))], calendarName: "Work" ) let contactFile = directory.appendingPathComponent("Contacts/Ada Lovelace.vcf") let calendarFile = directory.appendingPathComponent("Calendars/Work.ics") #expect(FileManager.default.fileExists(atPath: contactFile.path)) #expect(FileManager.default.fileExists(atPath: calendarFile.path)) #expect(session.writtenFiles.count == 2) #expect(session.summary == "Wrote 1 contact file and 1 calendar file.") } @Test("Contacts with nothing on them are skipped and reported") func skipsEmptyContacts() throws { let directory = try temporaryDirectory() defer { try? FileManager.default.removeItem(at: directory) } var session = ExportSession(destination: directory) try session.write(contacts: [IPodContact(), IPodContact(givenName: "Ada")]) #expect(session.writtenFiles.count == 1) #expect(session.warnings.contains { $0.contains("skipped") }) } @Test("Two contacts with the same name do not overwrite each other") func collidingNames() throws { let directory = try temporaryDirectory() defer { try? FileManager.default.removeItem(at: directory) } var session = ExportSession(destination: directory) try session.write(contacts: [ IPodContact(givenName: "John", familyName: "Smith", phoneNumbers: [LabeledValue(label: "CELL", value: "1")]), IPodContact(givenName: "John", familyName: "Smith", phoneNumbers: [LabeledValue(label: "CELL", value: "2")]), ]) #expect(session.writtenFiles.count == 2) let names = Set(session.writtenFiles.map(\.path.lastPathComponent)) #expect(names == ["John Smith.vcf", "John Smith_2.vcf"]) } }