import XCTest @testable import ReynaCLIHostCore import Foundation final class ContactsOperationsTests: XCTestCase { // MARK: - Fake providers struct FakeSearch: ContactsSearchProviding { var contacts: [ContactListItem] var shouldThrow: ContactsProviderError? = nil func searchContacts(query: String?, limit: Int) throws -> [ContactListItem] { if let e = shouldThrow { throw e } var filtered = contacts if let q = query, !q.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { let lower = q.lowercased() filtered = filtered.filter { ($0.name + "\n" + $0.organization).lowercased().contains(lower) } } filtered.sort { $0.name < $1.name } if filtered.count > limit { filtered = Array(filtered.prefix(limit)) } return filtered } } struct FakeRead: ContactsReadProviding { var contact: ContactDetailItem? var shouldThrow: ContactsProviderError? = nil func readContact(id: String) throws -> ContactDetailItem { if let e = shouldThrow { throw e } guard let c = contact, c.id == id else { throw ContactsProviderError.notFound("Contact not found: \(id)") } return c } } struct FakeCreate: ContactsCreateProviding { var result: ContactCreateResult var shouldThrow: ContactsProviderError? = nil func createContact(firstName: String?, lastName: String?, organization: String?, jobTitle: String?, note: String?, email: ContactEmailLabelValue?, phone: ContactPhoneLabelValue?) throws -> ContactCreateResult { if let e = shouldThrow { throw e } return result } } struct EmptyCalendarList: CalendarListProviding { func listCalendars() throws -> [CalendarListItem] { [] } } struct EmptyEvents: CalendarEventsListProviding { func listEvents(start: Date, end: Date, calendarId: String?, calendarTitle: String?, limit: Int) throws -> [CalendarEventItem] { [] } } struct EmptyCreate: CalendarEventCreateProviding { func createEvent(title: String, start: Date, end: Date, allDay: Bool, notes: String?, location: String?, calendarId: String?, calendarTitle: String?) throws -> CalendarEventItem { CalendarEventItem(id: "x", title: "t", start: "2026-01-01T10:00:00Z", end: "2026-01-01T11:00:00Z", all_day: false, calendar_id: "c", calendar_title: "Home", notes: nil, location: nil) } } struct EmptyContactsAuth: ContactsAuthorizationProviding { func authorizationStatus() -> ContactsAuthorizationStatus { .authorized } func requestAccess() throws -> Bool { false } } func mockCalAuth() -> MockCalendarAuth { MockCalendarAuth(status: .authorized) } struct MockCalendarAuth: CalendarAuthorizationProviding { var status: CalendarAuthorizationStatus func authorizationStatus() -> CalendarAuthorizationStatus { status } func requestFullAccess() throws -> Bool { false } } // Helpers to build dispatch for contacts only func dispatchContacts(request: Request, search: ContactsSearchProviding = FakeSearch(contacts: []), read: ContactsReadProviding = FakeRead(), create: ContactsCreateProviding = FakeCreate(result: ContactCreateResult(id: "id", name: "Name", organization: "")), auth: ContactsAuthorizationProviding = EmptyContactsAuth()) -> Response { return dispatch(request: request, calendarProvider: EmptyCalendarList(), eventsProvider: EmptyEvents(), createProvider: EmptyCreate(), authProvider: mockCalAuth(), contactsAuthProvider: auth, contactsSearchProvider: search, contactsReadProvider: read, contactsCreateProvider: create) } func sampleContactList() -> [ContactListItem] { [ ContactListItem(id: "3", name: "Charlie", organization: "OrgC", modifiedAt: "2026-01-01T00:00:00Z"), ContactListItem(id: "1", name: "Alice", organization: "OrgA", modifiedAt: "2026-01-01T00:00:00Z"), ContactListItem(id: "2", name: "Bob", organization: "OrgB", modifiedAt: "2026-01-01T00:00:00Z"), ] } func sampleContactDetail() -> ContactDetailItem { ContactDetailItem(id: "1", name: "Alice Smith", firstName: "Alice", lastName: "Smith", organization: "OrgA", jobTitle: "Engineer", emails: [ContactEmailLabelValue(label: "work", value: "alice@example.com")], phones: [ContactPhoneLabelValue(label: "mobile", value: "123")], modifiedAt: "2026-01-01T00:00:00Z") } // MARK: - Search tests func testSearchMissingArgsReturnsAllWithDefaultLimit() { let req = Request(id: "s1", operation: "contacts.search", arguments: .object([:])) let resp = dispatchContacts(request: req, search: FakeSearch(contacts: sampleContactList())) XCTAssertTrue(resp.ok) XCTAssertEqual(resp.result?.contacts?.count, 3) } func testSearchLimitBoundedLow() { let req = Request(id: "s2", operation: "contacts.search", arguments: .object(["limit": .number(0)])) let resp = dispatchContacts(request: req) XCTAssertFalse(resp.ok) XCTAssertEqual(resp.error?.code, "invalid_request") } func testSearchLimitBoundedHigh() { let req = Request(id: "s3", operation: "contacts.search", arguments: .object(["limit": .number(101)])) let resp = dispatchContacts(request: req) XCTAssertFalse(resp.ok) XCTAssertEqual(resp.error?.code, "invalid_request") } func testSearchQueryFiltersDeterministically() { let req = Request(id: "s4", operation: "contacts.search", arguments: .object(["query": .string("ali"), "limit": .number(10)])) let resp = dispatchContacts(request: req, search: FakeSearch(contacts: sampleContactList())) XCTAssertTrue(resp.ok) XCTAssertEqual(resp.result?.contacts?.count, 1) XCTAssertEqual(resp.result?.contacts?.first?.name, "Alice") } func testSearchSortedByNameOrgId() { let req = Request(id: "s5", operation: "contacts.search", arguments: .object(["limit": .number(10)])) let resp = dispatchContacts(request: req, search: FakeSearch(contacts: sampleContactList())) XCTAssertTrue(resp.ok) let ids = resp.result?.contacts?.map { $0.id } XCTAssertEqual(ids, ["1","2","3"]) } func testSearchPermissionRequired() { let req = Request(id: "s6", operation: "contacts.search", arguments: .object([:])) let resp = dispatchContacts(request: req, search: FakeSearch(contacts: [], shouldThrow: .permissionRequired)) XCTAssertFalse(resp.ok) XCTAssertEqual(resp.error?.code, "permission_required") XCTAssertNil(resp.result) } func testSearchContactsNeverPrompts() throws { // Ensure Contacts search providers never call request-access APIs – static source check let fm = FileManager.default var cur = URL(fileURLWithPath: fm.currentDirectoryPath) var providerURL: URL? = nil for _ in 0..<8 { let cand = cur.appendingPathComponent("Sources/ReynaCLIHostCore/ContactsProvider.swift") if fm.fileExists(atPath: cand.path) { providerURL = cand; break } let cand2 = cur.appendingPathComponent("native/ReynaCLIHost/Sources/ReynaCLIHostCore/ContactsProvider.swift") if fm.fileExists(atPath: cand2.path) { providerURL = cand2; break } cur = cur.deletingLastPathComponent() } guard let url = providerURL, let content = try? String(contentsOf: url) else { XCTFail("ContactsProvider.swift not found"); return } // Protocol declarations like `func requestAccess()` are allowed; actual prompt calls use `requestAccess(for:` // or call on CNContactStore. We forbid `requestAccess(for:` in this file. XCTAssertFalse(content.contains("requestAccess(for:"), "Contacts search/read/create must not call requestAccess(for:) – only auth provider should") XCTAssertFalse(content.contains("requestFullAccess"), "Contacts provider must not call calendar request") } // MARK: - Read tests func testReadMissingIdInvalidRequest() { let req = Request(id: "r1", operation: "contacts.read", arguments: .object([:])) let resp = dispatchContacts(request: req) XCTAssertFalse(resp.ok) XCTAssertEqual(resp.error?.code, "invalid_request") } func testReadSuccessMinimalFields() { let detail = sampleContactDetail() let req = Request(id: "r2", operation: "contacts.read", arguments: .object(["id": .string("1")])) let resp = dispatchContacts(request: req, read: FakeRead(contact: detail)) XCTAssertTrue(resp.ok) XCTAssertEqual(resp.result?.contact?.id, "1") XCTAssertEqual(resp.result?.contact?.firstName, "Alice") XCTAssertEqual(resp.result?.contact?.emails.first?.value, "alice@example.com") } func testReadPermissionRequired() { let req = Request(id: "r3", operation: "contacts.read", arguments: .object(["id": .string("1")])) let resp = dispatchContacts(request: req, read: FakeRead(shouldThrow: .permissionRequired)) XCTAssertFalse(resp.ok) XCTAssertEqual(resp.error?.code, "permission_required") } func testReadNotFoundMapsToInvalid() { let req = Request(id: "r4", operation: "contacts.read", arguments: .object(["id": .string("nope")])) let resp = dispatchContacts(request: req, read: FakeRead(contact: sampleContactDetail())) XCTAssertFalse(resp.ok) XCTAssertEqual(resp.error?.code, "invalid_request") } // MARK: - Create tests func testCreateMissingNameFieldsInvalid() { let req = Request(id: "c1", operation: "contacts.create", arguments: .object([:])) let resp = dispatchContacts(request: req) XCTAssertFalse(resp.ok) XCTAssertEqual(resp.error?.code, "invalid_request") } func testCreateSuccessReturnsMetadataOnly() { let result = ContactCreateResult(id: "new-id", name: "Alice Smith", organization: "OrgA") let req = Request(id: "c2", operation: "contacts.create", arguments: .object(["firstName": .string("Alice"), "lastName": .string("Smith")])) let resp = dispatchContacts(request: req, create: FakeCreate(result: result)) XCTAssertTrue(resp.ok) XCTAssertEqual(resp.result?.created_contact?.id, "new-id") XCTAssertEqual(resp.result?.created_contact?.name, "Alice Smith") // Ensure no excessive fields leaked let encoded = try! JSONEncoder().encode(resp) let obj = try! JSONSerialization.jsonObject(with: encoded) as! [String: Any] let resultObj = obj["result"] as! [String: Any] XCTAssertNotNil(resultObj["created_contact"]) XCTAssertNil(resultObj["contact"], "create must not output full contact detail") XCTAssertNil(resultObj["contacts"]) } func testCreateWithEmailPhoneObjects() { let result = ContactCreateResult(id: "nid", name: "Bob", organization: "") let req = Request(id: "c3", operation: "contacts.create", arguments: .object([ "firstName": .string("Bob"), "email": .object(["label": .string("work"), "value": .string("bob@example.com")]), "phone": .object(["label": .string("mobile"), "value": .string("+1555")]) ])) let resp = dispatchContacts(request: req, create: FakeCreate(result: result)) XCTAssertTrue(resp.ok) XCTAssertEqual(resp.result?.created_contact?.name, "Bob") } func testCreatePermissionRequired() { let req = Request(id: "c4", operation: "contacts.create", arguments: .object(["firstName": .string("Bob")])) let resp = dispatchContacts(request: req, create: FakeCreate(result: ContactCreateResult(id: "x", name: "x", organization: ""), shouldThrow: .permissionRequired)) XCTAssertFalse(resp.ok) XCTAssertEqual(resp.error?.code, "permission_required") } func testSearchDoesNotTriggerCreate() { final class CountingCreate: ContactsCreateProviding, @unchecked Sendable { var count = 0 func createContact(firstName: String?, lastName: String?, organization: String?, jobTitle: String?, note: String?, email: ContactEmailLabelValue?, phone: ContactPhoneLabelValue?) throws -> ContactCreateResult { count += 1 return ContactCreateResult(id: "x", name: "x", organization: "") } } let counter = CountingCreate() let req = Request(id: "iso", operation: "contacts.search", arguments: .object(["limit": .number(5)])) let resp = dispatch(request: req, calendarProvider: EmptyCalendarList(), eventsProvider: EmptyEvents(), createProvider: EmptyCreate(), authProvider: mockCalAuth(), contactsAuthProvider: EmptyContactsAuth(), contactsSearchProvider: FakeSearch(contacts: []), contactsReadProvider: FakeRead(), contactsCreateProvider: counter) XCTAssertTrue(resp.ok) XCTAssertEqual(counter.count, 0, "search must not trigger create") } // MARK: - Regression: contacts.search production crash (CNPropertyNotFetchedException) func testSearchProductionDoesNotUseCNContactFormatter() throws { let fm = FileManager.default var cur = URL(fileURLWithPath: fm.currentDirectoryPath) var providerURL: URL? = nil for _ in 0..<8 { let cand = cur.appendingPathComponent("Sources/ReynaCLIHostCore/ContactsProvider.swift") if fm.fileExists(atPath: cand.path) { providerURL = cand; break } let cand2 = cur.appendingPathComponent("native/ReynaCLIHost/Sources/ReynaCLIHostCore/ContactsProvider.swift") if fm.fileExists(atPath: cand2.path) { providerURL = cand2; break } cur = cur.deletingLastPathComponent() } guard let url = providerURL, let content = try? String(contentsOf: url) else { XCTFail("ContactsProvider.swift not found"); return } XCTAssertFalse(content.contains("CNContactFormatter.string("), "Production ContactsProvider must not call CNContactFormatter.string - use only fetched keys to avoid ObjC exception on middleName etc.") XCTAssertFalse(content.contains("CNContactMiddleNameKey"), "Do not add middleName to keysToFetch - fix is to avoid formatter, not fetch more") XCTAssertFalse(content.contains("CNContactNamePrefixKey"), "Avoid extra keys to satisfy formatter") XCTAssertFalse(content.contains("CNContactNameSuffixKey"), "Avoid extra keys to satisfy formatter") XCTAssertFalse(content.contains("CNContactNicknameKey"), "Avoid extra keys to satisfy formatter") } func testSearchProductionKeysToFetchWhitelist() throws { let fm = FileManager.default var cur = URL(fileURLWithPath: fm.currentDirectoryPath) var providerURL: URL? = nil for _ in 0..<8 { let cand = cur.appendingPathComponent("Sources/ReynaCLIHostCore/ContactsProvider.swift") if fm.fileExists(atPath: cand.path) { providerURL = cand; break } let cand2 = cur.appendingPathComponent("native/ReynaCLIHost/Sources/ReynaCLIHostCore/ContactsProvider.swift") if fm.fileExists(atPath: cand2.path) { providerURL = cand2; break } cur = cur.deletingLastPathComponent() } guard let url = providerURL, let content = try? String(contentsOf: url) else { XCTFail("ContactsProvider.swift not found"); return } let lines = content.components(separatedBy: "\n") guard let searchIdx = lines.firstIndex(where: { $0.contains("struct ContactsSearchProvider") }) else { XCTFail("ContactsSearchProvider not found"); return } let searchSlice = lines[searchIdx.. String { let combined = "\(givenName) \(familyName)".trimmingCharacters(in: .whitespacesAndNewlines) return combined.components(separatedBy: .whitespaces).filter { !$0.isEmpty }.joined(separator: " ") } let contacts = [ ContactListItem(id: "1", name: displayNameFromFetchedParts(givenName: "Alice", familyName: "Smith"), organization: "OrgA", modifiedAt: ""), ContactListItem(id: "2", name: displayNameFromFetchedParts(givenName: "Bob", familyName: "Jones"), organization: "OrgB", modifiedAt: ""), ] let fake = FakeSearch(contacts: contacts) let syntheticQuery = "zzzz_synthetic_nonmatch_9f3a7c2e" let filtered = try! fake.searchContacts(query: syntheticQuery, limit: 20) XCTAssertEqual(filtered.count, 0, "Synthetic nonmatching query should yield empty result, not crash") let req = Request(id: "s-nm", operation: "contacts.search", arguments: .object(["query": .string(syntheticQuery), "limit": .number(20)])) let resp = dispatchContacts(request: req, search: fake) XCTAssertTrue(resp.ok, "Nonmatching search must succeed with ok:true") XCTAssertEqual(resp.result?.contacts?.count, 0, "Nonmatching search must return empty list") } func testReadProductionDoesNotUseCNContactFormatter() throws { let fm = FileManager.default var cur = URL(fileURLWithPath: fm.currentDirectoryPath) var providerURL: URL? = nil for _ in 0..<8 { let cand = cur.appendingPathComponent("Sources/ReynaCLIHostCore/ContactsProvider.swift") if fm.fileExists(atPath: cand.path) { providerURL = cand; break } let cand2 = cur.appendingPathComponent("native/ReynaCLIHost/Sources/ReynaCLIHostCore/ContactsProvider.swift") if fm.fileExists(atPath: cand2.path) { providerURL = cand2; break } cur = cur.deletingLastPathComponent() } guard let url = providerURL, let content = try? String(contentsOf: url) else { XCTFail("ContactsProvider.swift not found"); return } XCTAssertFalse(content.contains("CNContactFormatter.string("), "Read must not use CNContactFormatter either") } }