import XCTest @testable import ReynaCLIHostCore import Foundation // RED tests first: TDD for calendar.list migration final class CalendarListTests: XCTestCase { // MARK: - JSONValue safe representation func testJSONValueRoundTripsObjectArrayPrimitive() throws { // Expect JSONValue type to support object/array/string/bool/number/null let json = """ {"id":"1","operation":"calendar.list","arguments":{"filter":"home","limit":2,"nested":{"a":1},"arr":[1,2,null,true],"flag":false}} """ let data = json.data(using: .utf8)! let req = try JSONDecoder().decode(Request.self, from: data) // arguments should not be empty struct; should retain values // We test via encoding back and check presence let encoded = try JSONEncoder().encode(req) let obj = try JSONSerialization.jsonObject(with: encoded) as! [String: Any] let args = obj["arguments"] as! [String: Any] XCTAssertEqual(args["filter"] as? String, "home") XCTAssertNotNil(args["nested"]) XCTAssertNotNil(args["arr"]) } func testJSONValueCodableEquatableSendable() throws { // Verify JSONValue conforms to Codable, Equatable, Sendable (compile-time) let v1: JSONValue = .object(["a": .number(1)]) let v2: JSONValue = .object(["a": .number(1)]) XCTAssertEqual(v1, v2) // Codable roundtrip let data = try JSONEncoder().encode(v1) let decoded = try JSONDecoder().decode(JSONValue.self, from: data) XCTAssertEqual(decoded, v1) } // MARK: - CalendarListProvider injection & sorting // Fake provider for tests struct FakeSuccessProvider: CalendarListProviding { let calendars: [CalendarListItem] func listCalendars() throws -> [CalendarListItem] { calendars } } func testCalendarListSuccessAndDeterministicSort() throws { // Unsorted input should be returned sorted by source/title/id let unsorted = [ CalendarListItem(id: "c", title: "B", source: "iCloud", type: "caldav"), CalendarListItem(id: "a", title: "A", source: "Local", type: "local"), CalendarListItem(id: "b", title: "A", source: "iCloud", type: "caldav"), CalendarListItem(id: "aa", title: "A", source: "iCloud", type: "caldav"), ] let provider = FakeSuccessProvider(calendars: unsorted) let req = Request(id: "id-1", operation: "calendar.list", arguments: .object([:])) let resp = dispatch(request: req, calendarProvider: provider) XCTAssertTrue(resp.ok) XCTAssertEqual(resp.id, "id-1") // The new response/result model should still carry protocol_version/operation/status and calendars // We decode result payload to check calendars order // ResultPayload should support generic calendars? We'll check via JSON let encoded = try JSONEncoder().encode(resp) let obj = try JSONSerialization.jsonObject(with: encoded) as! [String: Any] let result = obj["result"] as! [String: Any] XCTAssertEqual(result["protocol_version"] as? String, PROTOCOL_VERSION) XCTAssertEqual(result["operation"] as? String, "calendar.list") XCTAssertEqual(result["status"] as? String, "ok") let cals = result["calendars"] as! [[String: Any]] // Deterministic sort: by source, then title, then id (lexicographic, case-sensitive) // 'L' (76) < 'i' (105) so Local < iCloud // Expected order: (Local,A,a), (iCloud,A,aa), (iCloud,A,b), (iCloud,B,c) XCTAssertEqual(cals[0]["id"] as? String, "a") XCTAssertEqual(cals[1]["id"] as? String, "aa") XCTAssertEqual(cals[2]["id"] as? String, "b") XCTAssertEqual(cals[3]["id"] as? String, "c") // Ensure only allowed fields for cal in cals { XCTAssertEqual(Set(cal.keys), Set(["id","title","source","type"])) } } func testCalendarListPermissionRequired() throws { struct DeniedProvider: CalendarListProviding { func listCalendars() throws -> [CalendarListItem] { throw CalendarProviderError.permissionRequired } } let req = Request(id: "perm-1", operation: "calendar.list", arguments: .object([:])) let resp = dispatch(request: req, calendarProvider: DeniedProvider()) XCTAssertFalse(resp.ok) XCTAssertEqual(resp.error?.code, "permission_required") XCTAssertNil(resp.result, "permission_required must not leak calendar content") } func testCalendarListProviderFailure() throws { struct FailProvider: CalendarListProviding { func listCalendars() throws -> [CalendarListItem] { throw CalendarProviderError.unavailable("disk error") } } let req = Request(id: "fail-1", operation: "calendar.list", arguments: .object([:])) let resp = dispatch(request: req, calendarProvider: FailProvider()) XCTAssertFalse(resp.ok) XCTAssertEqual(resp.error?.code, "calendar_unavailable") } func testCalendarListRequestJSONDecodeWithArgumentsObject() throws { let json = """ {"id":"json-1","operation":"calendar.list","arguments":{"foo":"bar","num":42}} """ let data = json.data(using: .utf8)! let req = try JSONDecoder().decode(Request.self, from: data) XCTAssertEqual(req.id, "json-1") // arguments should be parsed and not throw let provider = FakeSuccessProvider(calendars: []) let resp = dispatch(request: req, calendarProvider: provider) XCTAssertTrue(resp.ok, "calendar.list with args object should succeed") } func testServiceHealthStillWorksAfterMigration() throws { let req = Request(id: "health-1", operation: "service.health", arguments: .object([:])) let resp = dispatch(request: req, calendarProvider: FakeSuccessProvider(calendars: [])) XCTAssertTrue(resp.ok) XCTAssertEqual(resp.result?.protocol_version, PROTOCOL_VERSION) XCTAssertEqual(resp.result?.operation, "service.health") XCTAssertEqual(resp.result?.status, "ok") } // MARK: - EventKit provider must be read-only (static check) func testProductionEventKitProviderNeverRequestsAccess() throws { // Read the EventKit provider source and ensure it never calls prompt-triggering APIs // Note: createEvent legitimately mutates (save) – allowed only inside createEvent method. let fm = FileManager.default var cur = URL(fileURLWithPath: fm.currentDirectoryPath) var providerURL: URL? = nil for _ in 0..<8 { let cand = cur.appendingPathComponent("Sources/ReynaCLIHost/CalendarProvider.swift") if fm.fileExists(atPath: cand.path) { providerURL = cand; break } let candCore = cur.appendingPathComponent("Sources/ReynaCLIHostCore/CalendarProvider.swift") if fm.fileExists(atPath: candCore.path) { providerURL = candCore; break } let cand2 = cur.appendingPathComponent("native/ReynaCLIHost/Sources/ReynaCLIHost/CalendarProvider.swift") if fm.fileExists(atPath: cand2.path) { providerURL = cand2; break } let cand2Core = cur.appendingPathComponent("native/ReynaCLIHost/Sources/ReynaCLIHostCore/CalendarProvider.swift") if fm.fileExists(atPath: cand2Core.path) { providerURL = cand2Core; break } cur = cur.deletingLastPathComponent() } guard let url = providerURL, let content = try? String(contentsOf: url) else { XCTFail("CalendarProvider.swift not found for read-only safety check") return } let lines = content.components(separatedBy: .newlines) for line in lines { let l = line.lowercased() if l.contains("ekeventstore") && l.contains(".request") { XCTFail("Production provider must not call request methods on EKEventStore: \(line)") } } XCTAssertTrue(content.contains("authorizationStatus"), "Should check authorizationStatus") // Ensure no remove mutation anywhere let lower = content.lowercased() XCTAssertFalse(lower.contains("remove(") && lower.contains("ekevent"), "Should not remove EKEvent") // No AppleScript XCTAssertFalse(lower.contains("nsapplescript") || lower.contains("appleevent"), "Should not use AppleScript") // If save exists, it must be inside createEvent func (mutation allowed only there) if lower.contains("save(") { // crude check: ensure save appears after func createEvent let parts = content.components(separatedBy: "func createEvent") XCTAssertEqual(parts.count, 2, "save should only appear in createEvent, found multiple or none") let beforeCreate = parts[0].lowercased() XCTAssertFalse(beforeCreate.contains("save(") && beforeCreate.contains("ekevent"), "save(EKEvent) must not appear outside createEvent") } } }