import XCTest @testable import ReynaCLIHostCore import Foundation // Tests for reminders.request_full_access – TDD, fake providers only // Modeled after CalendarAuthorizationTests.swift final class RemindersAuthorizationTests: XCTestCase { // MARK: - Fake auth providers struct AlreadyAuthorizedProvider: RemindersAuthorizationProviding { func authorizationStatus() -> RemindersAuthorizationStatus { .authorized } func requestFullAccess() throws -> Bool { XCTFail("requestFullAccess must not be called when already authorized") return false } } struct NotDeterminedGrantedProvider: RemindersAuthorizationProviding { func authorizationStatus() -> RemindersAuthorizationStatus { .notDetermined } func requestFullAccess() throws -> Bool { true } } struct NotDeterminedDeniedProvider: RemindersAuthorizationProviding { func authorizationStatus() -> RemindersAuthorizationStatus { .notDetermined } func requestFullAccess() throws -> Bool { false } } struct DeniedProvider: RemindersAuthorizationProviding { func authorizationStatus() -> RemindersAuthorizationStatus { .denied } func requestFullAccess() throws -> Bool { false } } struct TimeoutProvider: RemindersAuthorizationProviding { func authorizationStatus() -> RemindersAuthorizationStatus { .notDetermined } func requestFullAccess() throws -> Bool { throw RemindersProviderError.unavailable("reminders authorization timed out") } } struct ErrorProvider: RemindersAuthorizationProviding { func authorizationStatus() -> RemindersAuthorizationStatus { .notDetermined } func requestFullAccess() throws -> Bool { throw RemindersProviderError.unavailable("disk error") } } // MARK: - Empty reminders providers for dispatch struct EmptyLists: RemindersListsProviding { func listReminderLists() throws -> [ReminderListItem] { [] } } struct EmptyList: RemindersListProviding { func listReminders(listId: String?, listTitle: String?, completed: Bool?, limit: Int) throws -> [ReminderItem] { [] } } struct EmptyCreate: RemindersCreateProviding { func createReminder(title: String, listId: String?, listTitle: String?, notes: String?, due: Date?, priority: Int?) throws -> ReminderCreateResult { ReminderCreateResult(id: "r1", list_id: "l1", list_title: "t", title: title) } } struct EmptyCalList: CalendarListProviding { func listCalendars() throws -> [CalendarListItem] { [] } } struct EmptyCalEvents: CalendarEventsListProviding { func listEvents(start: Date, end: Date, calendarId: String?, calendarTitle: String?, limit: Int) throws -> [CalendarEventItem] { [] } } struct EmptyCalCreate: CalendarEventCreateProviding { func createEvent(title: String, start: Date, end: Date, allDay: Bool, notes: String?, location: String?, calendarId: String?, calendarTitle: String?) throws -> CalendarEventItem { CalendarEventItem(id: "e", title: title, start: "2026-01-01T00:00:00Z", end: "2026-01-01T01:00:00Z", all_day: false, calendar_id: "c", calendar_title: "t", notes: nil, location: nil) } } struct MockCalAuth: CalendarAuthorizationProviding { var status: CalendarAuthorizationStatus = .authorized func authorizationStatus() -> CalendarAuthorizationStatus { status } func requestFullAccess() throws -> Bool { false } } struct EmptyContactsAuth: ContactsAuthorizationProviding { func authorizationStatus() -> ContactsAuthorizationStatus { .authorized } func requestAccess() throws -> Bool { false } } struct EmptyContactsSearch: ContactsSearchProviding { func searchContacts(query: String?, limit: Int) throws -> [ContactListItem] { [] } } struct EmptyContactsRead: ContactsReadProviding { func readContact(id: String) throws -> ContactDetailItem { throw ContactsProviderError.notFound("nf") } } struct EmptyContactsCreate: ContactsCreateProviding { func createContact(firstName: String?, lastName: String?, organization: String?, jobTitle: String?, note: String?, email: ContactEmailLabelValue?, phone: ContactPhoneLabelValue?) throws -> ContactCreateResult { throw ContactsProviderError.unavailable("na") } } private func dispatchRemindersAuth(op: String, id: String, auth: RemindersAuthorizationProviding) -> Response { let req = Request(id: id, operation: op, arguments: .object([:])) return dispatch( request: req, calendarProvider: EmptyCalList(), eventsProvider: EmptyCalEvents(), createProvider: EmptyCalCreate(), authProvider: MockCalAuth(), contactsAuthProvider: EmptyContactsAuth(), contactsSearchProvider: EmptyContactsSearch(), contactsReadProvider: EmptyContactsRead(), contactsCreateProvider: EmptyContactsCreate(), remindersAuthProvider: auth, remindersListsProvider: EmptyLists(), remindersListProvider: EmptyList(), remindersCreateProvider: EmptyCreate() ) } // MARK: - Core auth behavior func testAlreadyAuthorizedReturnsAuthorizedWithoutRequesting() { let auth = AlreadyAuthorizedProvider() let resp = dispatchRemindersAuth(op: "reminders.request_full_access", id: "ra1", auth: auth) XCTAssertTrue(resp.ok) XCTAssertEqual(resp.result?.status, "authorized") XCTAssertEqual(resp.result?.operation, "reminders.request_full_access") XCTAssertEqual(resp.id, "ra1") } func testNotDeterminedReachesRequestPath() { final class TrackingProvider: RemindersAuthorizationProviding, @unchecked Sendable { var didRequest = false var status: RemindersAuthorizationStatus = .notDetermined func authorizationStatus() -> RemindersAuthorizationStatus { status } func requestFullAccess() throws -> Bool { didRequest = true return true } } let tracking = TrackingProvider() let resp = dispatchRemindersAuth(op: "reminders.request_full_access", id: "ra2", auth: tracking) XCTAssertTrue(tracking.didRequest, "requestFullAccess must be called when notDetermined") XCTAssertTrue(resp.ok) } func testGrantedReturnsAuthorizedResult() { let auth = NotDeterminedGrantedProvider() let resp = dispatchRemindersAuth(op: "reminders.request_full_access", id: "ra3", auth: auth) XCTAssertTrue(resp.ok) XCTAssertEqual(resp.result?.status, "authorized") XCTAssertEqual(resp.result?.protocol_version, PROTOCOL_VERSION) XCTAssertNil(resp.error) XCTAssertNil(resp.result?.reminders, "must not output reminder content") XCTAssertNil(resp.result?.reminder_lists) XCTAssertNil(resp.result?.reminder) } func testDeniedReturnsPermissionDenied() { let auth = NotDeterminedDeniedProvider() let resp = dispatchRemindersAuth(op: "reminders.request_full_access", id: "ra4", auth: auth) XCTAssertFalse(resp.ok) XCTAssertEqual(resp.error?.code, "permission_denied") XCTAssertNotNil(resp.error?.message) XCTAssertNil(resp.result) } func testAlreadyDeniedPathAlsoDenies() { let auth = DeniedProvider() let resp = dispatchRemindersAuth(op: "reminders.request_full_access", id: "ra5", auth: auth) XCTAssertFalse(resp.ok) XCTAssertEqual(resp.error?.code, "permission_denied") } func testTimeoutReturnsRemindersUnavailable() { let auth = TimeoutProvider() let resp = dispatchRemindersAuth(op: "reminders.request_full_access", id: "ra6", auth: auth) XCTAssertFalse(resp.ok) XCTAssertEqual(resp.error?.code, "reminders_unavailable") XCTAssertTrue(resp.error?.message.lowercased().contains("timed out") ?? false) } func testErrorReturnsRemindersUnavailable() { let auth = ErrorProvider() let resp = dispatchRemindersAuth(op: "reminders.request_full_access", id: "ra7", auth: auth) XCTAssertFalse(resp.ok) XCTAssertEqual(resp.error?.code, "reminders_unavailable") } func testNoReminderDataInAuthResponses() { let authOk = NotDeterminedGrantedProvider() let respOk = dispatchRemindersAuth(op: "reminders.request_full_access", id: "ok", auth: authOk) XCTAssertNil(respOk.result?.reminders) XCTAssertNil(respOk.result?.reminder_lists) XCTAssertNil(respOk.result?.reminder) XCTAssertNil(respOk.result?.created_reminder) XCTAssertNil(respOk.result?.calendars) XCTAssertNil(respOk.result?.events) let authDen = NotDeterminedDeniedProvider() let respDen = dispatchRemindersAuth(op: "reminders.request_full_access", id: "den", auth: authDen) XCTAssertNil(respDen.result) } // MARK: - list / create must never prompt func testRemindersListsDoesNotCallAuthRequest() { final class SpyLists: RemindersListsProviding, @unchecked Sendable { var called = false func listReminderLists() throws -> [ReminderListItem] { called = true; return [] } } final class SpyAuth: RemindersAuthorizationProviding, @unchecked Sendable { var didCallStatus = false var didCallRequest = false func authorizationStatus() -> RemindersAuthorizationStatus { didCallStatus = true; return .authorized } func requestFullAccess() throws -> Bool { didCallRequest = true; return false } } let lists = SpyLists() let auth = SpyAuth() let req = Request(id: "rl-1", operation: "reminders.lists", arguments: .object([:])) let resp = dispatch( request: req, calendarProvider: EmptyCalList(), eventsProvider: EmptyCalEvents(), createProvider: EmptyCalCreate(), authProvider: MockCalAuth(), contactsAuthProvider: EmptyContactsAuth(), contactsSearchProvider: EmptyContactsSearch(), contactsReadProvider: EmptyContactsRead(), contactsCreateProvider: EmptyContactsCreate(), remindersAuthProvider: auth, remindersListsProvider: lists, remindersListProvider: EmptyList(), remindersCreateProvider: EmptyCreate() ) XCTAssertTrue(resp.ok) XCTAssertFalse(auth.didCallRequest, "reminders.lists must never call requestFullAccess") XCTAssertTrue(lists.called) } func testRemindersListDoesNotCallAuthRequest() { final class SpyList: RemindersListProviding, @unchecked Sendable { var called = false func listReminders(listId: String?, listTitle: String?, completed: Bool?, limit: Int) throws -> [ReminderItem] { called = true; return [] } } final class SpyAuth: RemindersAuthorizationProviding, @unchecked Sendable { var didCallRequest = false func authorizationStatus() -> RemindersAuthorizationStatus { .authorized } func requestFullAccess() throws -> Bool { didCallRequest = true; return false } } let rl = SpyList() let auth = SpyAuth() let req = Request(id: "r-1", operation: "reminders.list", arguments: .object(["limit": .number(10)])) let resp = dispatch( request: req, calendarProvider: EmptyCalList(), eventsProvider: EmptyCalEvents(), createProvider: EmptyCalCreate(), authProvider: MockCalAuth(), contactsAuthProvider: EmptyContactsAuth(), contactsSearchProvider: EmptyContactsSearch(), contactsReadProvider: EmptyContactsRead(), contactsCreateProvider: EmptyContactsCreate(), remindersAuthProvider: auth, remindersListsProvider: EmptyLists(), remindersListProvider: rl, remindersCreateProvider: EmptyCreate() ) XCTAssertTrue(resp.ok) XCTAssertFalse(auth.didCallRequest, "reminders.list must never trigger authorization request") XCTAssertTrue(rl.called) } func testRemindersCreateDoesNotCallAuthRequest() { final class SpyCreate: RemindersCreateProviding, @unchecked Sendable { var called = false func createReminder(title: String, listId: String?, listTitle: String?, notes: String?, due: Date?, priority: Int?) throws -> ReminderCreateResult { called = true return ReminderCreateResult(id: "x", list_id: "l", list_title: "t", title: title) } } final class SpyAuth: RemindersAuthorizationProviding, @unchecked Sendable { var didCallRequest = false func authorizationStatus() -> RemindersAuthorizationStatus { .authorized } func requestFullAccess() throws -> Bool { didCallRequest = true; return false } } let create = SpyCreate() let auth = SpyAuth() let req = Request(id: "rc-1", operation: "reminders.create", arguments: .object(["title": .string("Buy milk"), "list": .string("Groceries")])) let resp = dispatch( request: req, calendarProvider: EmptyCalList(), eventsProvider: EmptyCalEvents(), createProvider: EmptyCalCreate(), authProvider: MockCalAuth(), contactsAuthProvider: EmptyContactsAuth(), contactsSearchProvider: EmptyContactsSearch(), contactsReadProvider: EmptyContactsRead(), contactsCreateProvider: EmptyContactsCreate(), remindersAuthProvider: auth, remindersListsProvider: EmptyLists(), remindersListProvider: EmptyList(), remindersCreateProvider: create ) XCTAssertTrue(resp.ok) XCTAssertFalse(auth.didCallRequest, "reminders.create must never trigger authorization request (permission check is via status only in real provider, but here we assert no prompt)") XCTAssertTrue(create.called) } // MARK: - Bridge tests – deterministic pump without real EventKit final class TestBox: @unchecked Sendable { var value: T init(_ v: T) { value = v } } func testBridgePumpsMainRunLoopAndDeliversMainQueueCallback() throws { let exp = expectation(description: "bridge completes") let grantedBox = TestBox(false) let errorBox = TestBox(nil) DispatchQueue.main.async { let bridge = RemindersMainRunLoopBridge() do { grantedBox.value = try bridge.requestAccess(timeout: 2) { completion in Timer.scheduledTimer(withTimeInterval: 0.02, repeats: false) { _ in completion(true, nil) } } } catch { errorBox.value = error } exp.fulfill() } wait(for: [exp], timeout: 5) XCTAssertNil(errorBox.value, "bridge must not timeout when it pumps main run loop; got \(String(describing: errorBox.value))") XCTAssertTrue(grantedBox.value, "granted should be true after main-queue callback is pumped") } func testBridgeHandlesCompletionExactlyOnce() throws { let exp = expectation(description: "exactly once") let resultBox = TestBox(false) DispatchQueue.main.async { let bridge = RemindersMainRunLoopBridge() do { resultBox.value = try bridge.requestAccess(timeout: 1) { completion in completion(true, nil) completion(false, NSError(domain: "should-be-ignored", code: 1)) } } catch {} exp.fulfill() } wait(for: [exp], timeout: 2) XCTAssertTrue(resultBox.value, "First completion should win") } func testBridgeThreadSafetyForConcurrentCompletion() throws { let exp = expectation(description: "thread-safe") let grantedBox = TestBox(false) let doneBox = TestBox(false) DispatchQueue.main.async { let bridge = RemindersMainRunLoopBridge() do { grantedBox.value = try bridge.requestAccess(timeout: 1) { completion in DispatchQueue.global().async { completion(true, nil) } DispatchQueue.global().async { completion(false, nil) } } doneBox.value = true } catch {} exp.fulfill() } wait(for: [exp], timeout: 2) XCTAssertTrue(doneBox.value, "bridge must complete even with concurrent completions") } func testBridgePropagatesError() throws { let exp = expectation(description: "error propagation") let caughtBox = TestBox(false) DispatchQueue.main.async { let bridge = RemindersMainRunLoopBridge() do { _ = try bridge.requestAccess(timeout: 1) { completion in completion(false, NSError(domain: "test", code: 2, userInfo: [NSLocalizedDescriptionKey: "fake EK error"])) } } catch let err as RemindersProviderError { if case .unavailable(let msg) = err { caughtBox.value = msg.contains("fake EK error") } } catch {} exp.fulfill() } wait(for: [exp], timeout: 2) XCTAssertTrue(caughtBox.value, "Error from EK completion must be wrapped as reminders_unavailable") } func testBridgeTimeoutReturnsCorrectError() throws { let exp = expectation(description: "timeout") let codeBox = TestBox("") DispatchQueue.main.async { let bridge = RemindersMainRunLoopBridge() do { _ = try bridge.requestAccess(timeout: 0.15) { _ in } XCTFail("Should have thrown") } catch let err as RemindersProviderError { if case .unavailable(let msg) = err { codeBox.value = msg } } catch {} exp.fulfill() } wait(for: [exp], timeout: 2) XCTAssertTrue(codeBox.value.lowercased().contains("timed out"), "Timeout must produce 'reminders authorization timed out' message, got \(codeBox.value)") } // MARK: - Production code location check func testOnlyOneFileCallsRequestFullAccessToReminders() throws { let fm = FileManager.default var cur = URL(fileURLWithPath: fm.currentDirectoryPath) var dirs: [URL] = [] for _ in 0..<10 { let cand = cur.appendingPathComponent("native/ReynaCLIHost/Sources/ReynaCLIHostCore") if fm.fileExists(atPath: cand.path) { dirs.append(cand); break } let cand2 = cur.appendingPathComponent("Sources/ReynaCLIHostCore") if fm.fileExists(atPath: cand2.path) { dirs.append(cand2); break } let cand3 = cur.appendingPathComponent("native/ReynaCLIHost/Sources/ReynaCLIHost") if fm.fileExists(atPath: cand3.path) { dirs.append(cand3) } let cand4 = cur.appendingPathComponent("Sources/ReynaCLIHost") if fm.fileExists(atPath: cand4.path) { dirs.append(cand4) } if !dirs.isEmpty { break } cur = cur.deletingLastPathComponent() } guard !dirs.isEmpty else { XCTFail("Could not locate Sources/ReynaCLIHost dir") return } var hits: [String] = [] for srcDir in dirs { let files = (try? fm.contentsOfDirectory(at: srcDir, includingPropertiesForKeys: nil)) ?? [] for file in files where file.pathExtension == "swift" { guard let content = try? String(contentsOf: file) else { continue } if content.contains("requestFullAccessToReminders") { hits.append(file.lastPathComponent) } } } let uniqueSorted = Array(Set(hits)).sorted() XCTAssertEqual(uniqueSorted, ["RemindersAuthorizationProvider.swift"], "requestFullAccessToReminders must only appear in RemindersAuthorizationProvider.swift, found in \(uniqueSorted)") } }