import XCTest @testable import ReynaCLIHostCore import Foundation final class ContactsAuthorizationTests: XCTestCase { struct AlreadyAuthorizedProvider: ContactsAuthorizationProviding { func authorizationStatus() -> ContactsAuthorizationStatus { .authorized } func requestAccess() throws -> Bool { XCTFail("must not call requestAccess when already authorized") return false } } struct NotDeterminedGranted: ContactsAuthorizationProviding { func authorizationStatus() -> ContactsAuthorizationStatus { .notDetermined } func requestAccess() throws -> Bool { true } } struct NotDeterminedDenied: ContactsAuthorizationProviding { func authorizationStatus() -> ContactsAuthorizationStatus { .notDetermined } func requestAccess() throws -> Bool { false } } struct DeniedProvider: ContactsAuthorizationProviding { func authorizationStatus() -> ContactsAuthorizationStatus { .denied } func requestAccess() throws -> Bool { false } } struct ErrorProvider: ContactsAuthorizationProviding { func authorizationStatus() -> ContactsAuthorizationStatus { .notDetermined } func requestAccess() throws -> Bool { throw ContactsProviderError.unavailable("disk error") } } struct EmptyContactsSearch: ContactsSearchProviding { func searchContacts(query: String?, limit: Int) throws -> [ContactListItem] { [] } } struct EmptyContactsRead: ContactsReadProviding { func readContact(id: String) throws -> ContactDetailItem { throw ContactsProviderError.notFound("not found") } } struct EmptyContactsCreate: ContactsCreateProviding { func createContact(firstName: String?, lastName: String?, organization: String?, jobTitle: String?, note: String?, email: ContactEmailLabelValue?, phone: ContactPhoneLabelValue?) throws -> ContactCreateResult { throw ContactsProviderError.unavailable("no create") } } struct EmptyCalendarList: CalendarListProviding { func listCalendars() throws -> [CalendarListItem] { [] } } func testAlreadyAuthorizedNoRequest() { let auth = AlreadyAuthorizedProvider() let req = Request(id: "c-a1", operation: "contacts.request_access", arguments: .object([:])) let resp = dispatch(request: req, calendarProvider: EmptyCalendarList(), eventsProvider: FakeEventsProvider(events: []), createProvider: FakeCreateProvider(willReturn: sampleEvent()), authProvider: MockCalAuth(status: .authorized), contactsAuthProvider: auth, contactsSearchProvider: EmptyContactsSearch(), contactsReadProvider: EmptyContactsRead(), contactsCreateProvider: EmptyContactsCreate()) XCTAssertTrue(resp.ok) XCTAssertEqual(resp.result?.status, "authorized") XCTAssertNil(resp.result?.contacts, "auth response must not leak contacts") XCTAssertNil(resp.result?.contact) } func testNotDeterminedGrantedReturnsAuthorized() { let auth = NotDeterminedGranted() let req = Request(id: "c-a2", operation: "contacts.request_access", arguments: .object([:])) let resp = dispatch(request: req, calendarProvider: EmptyCalendarList(), eventsProvider: FakeEventsProvider(events: []), createProvider: FakeCreateProvider(willReturn: sampleEvent()), authProvider: MockCalAuth(status: .authorized), contactsAuthProvider: auth, contactsSearchProvider: EmptyContactsSearch(), contactsReadProvider: EmptyContactsRead(), contactsCreateProvider: EmptyContactsCreate()) XCTAssertTrue(resp.ok) XCTAssertEqual(resp.result?.status, "authorized") XCTAssertEqual(resp.result?.operation, "contacts.request_access") } func testDeniedReturnsPermissionDenied() { let auth = NotDeterminedDenied() let req = Request(id: "c-a3", operation: "contacts.request_access", arguments: .object([:])) let resp = dispatch(request: req, calendarProvider: EmptyCalendarList(), eventsProvider: FakeEventsProvider(events: []), createProvider: FakeCreateProvider(willReturn: sampleEvent()), authProvider: MockCalAuth(status: .authorized), contactsAuthProvider: auth, contactsSearchProvider: EmptyContactsSearch(), contactsReadProvider: EmptyContactsRead(), contactsCreateProvider: EmptyContactsCreate()) XCTAssertFalse(resp.ok) XCTAssertEqual(resp.error?.code, "permission_denied") } func testErrorReturnsContactsUnavailable() { let auth = ErrorProvider() let req = Request(id: "c-a4", operation: "contacts.request_access", arguments: .object([:])) let resp = dispatch(request: req, calendarProvider: EmptyCalendarList(), eventsProvider: FakeEventsProvider(events: []), createProvider: FakeCreateProvider(willReturn: sampleEvent()), authProvider: MockCalAuth(status: .authorized), contactsAuthProvider: auth, contactsSearchProvider: EmptyContactsSearch(), contactsReadProvider: EmptyContactsRead(), contactsCreateProvider: EmptyContactsCreate()) XCTAssertFalse(resp.ok) XCTAssertEqual(resp.error?.code, "contacts_unavailable") } func testContactsSearchDoesNotCallAuthRequest() { final class SpySearch: ContactsSearchProviding, @unchecked Sendable { var called = false func searchContacts(query: String?, limit: Int) throws -> [ContactListItem] { called = true return [] } } final class SpyAuth: ContactsAuthorizationProviding, @unchecked Sendable { var didRequest = false func authorizationStatus() -> ContactsAuthorizationStatus { .authorized } func requestAccess() throws -> Bool { didRequest = true return false } } let search = SpySearch() let cAuth = SpyAuth() let req = Request(id: "cs-1", operation: "contacts.search", arguments: .object(["query": .string("john"), "limit": .number(10)])) let resp = dispatch(request: req, calendarProvider: EmptyCalendarList(), eventsProvider: FakeEventsProvider(events: []), createProvider: FakeCreateProvider(willReturn: sampleEvent()), authProvider: MockCalAuth(status: .authorized), contactsAuthProvider: cAuth, contactsSearchProvider: search, contactsReadProvider: EmptyContactsRead(), contactsCreateProvider: EmptyContactsCreate()) XCTAssertTrue(resp.ok) XCTAssertFalse(cAuth.didRequest, "contacts.search must never trigger authorization request") XCTAssertTrue(search.called) } // Bridging tests reuse same bridge pattern – verify contacts bridge pumps run loop func testContactsBridgePumpsMainRunLoop() { let exp = expectation(description: "contacts bridge") final class Box: @unchecked Sendable { var granted = false; var error: Error? = nil } let box = Box() DispatchQueue.main.async { let bridge = ContactsMainRunLoopBridge() do { box.granted = try bridge.requestAccess(timeout: 2) { completion in Timer.scheduledTimer(withTimeInterval: 0.02, repeats: false) { _ in completion(true, nil) } } } catch { box.error = error } exp.fulfill() } wait(for: [exp], timeout: 5) XCTAssertNil(box.error) XCTAssertTrue(box.granted) } // MARK: - Helpers shared struct FakeEventsProvider: CalendarEventsListProviding { var events: [CalendarEventItem] func listEvents(start: Date, end: Date, calendarId: String?, calendarTitle: String?, limit: Int) throws -> [CalendarEventItem] { events } } struct FakeCreateProvider: CalendarEventCreateProviding { var willReturn: CalendarEventItem func createEvent(title: String, start: Date, end: Date, allDay: Bool, notes: String?, location: String?, calendarId: String?, calendarTitle: String?) throws -> CalendarEventItem { willReturn } } func sampleEvent() -> CalendarEventItem { CalendarEventItem(id: "sample", title: "Sample", start: "2026-01-01T10:00:00Z", end: "2026-01-01T11:00:00Z", all_day: false, calendar_id: "cal1", calendar_title: "Home", notes: nil, location: nil) } struct MockCalAuth: CalendarAuthorizationProviding { var status: CalendarAuthorizationStatus func authorizationStatus() -> CalendarAuthorizationStatus { status } func requestFullAccess() throws -> Bool { false } } // Isolation: only ContactsAuthorizationProvider.swift should call requestAccess(for: .contacts) func testOnlyContactsAuthorizationProviderCallsRequestAccessForContacts() throws { let fm = FileManager.default var cur = URL(fileURLWithPath: fm.currentDirectoryPath) var dirs: [URL] = [] for _ in 0..<10 { let candCore = cur.appendingPathComponent("Sources/ReynaCLIHostCore") if fm.fileExists(atPath: candCore.path) { dirs.append(candCore); break } let cand = cur.appendingPathComponent("native/ReynaCLIHost/Sources/ReynaCLIHostCore") if fm.fileExists(atPath: cand.path) { dirs.append(cand); break } cur = cur.deletingLastPathComponent() } guard let srcDir = dirs.first else { XCTFail("Could not find ReynaCLIHostCore sources"); return } let files = (try? fm.contentsOfDirectory(at: srcDir, includingPropertiesForKeys: nil)) ?? [] var hits: [String] = [] for file in files where file.pathExtension == "swift" { guard let content = try? String(contentsOf: file) else { continue } if content.contains("requestAccess(for:") && file.lastPathComponent != "ContactsAuthorizationProvider.swift" { // Calendar provider calls requestFullAccessToEvents – not contacts if content.contains(".contacts") || content.contains("CNContact") { hits.append(file.lastPathComponent) } } } XCTAssertTrue(hits.isEmpty, "Only ContactsAuthorizationProvider.swift should call requestAccess(for: .contacts), found extras: \(hits)") } }