From 7969c6a2256aba3872ec13957757d87f9c29ae12 Mon Sep 17 00:00:00 2001 From: Eric Dahl Date: Wed, 17 Jun 2026 01:23:15 -0400 Subject: [PATCH 1/2] Add tests for TunnelManager ngrok paths, Route decoding, and WebhookRouteFactory Covers gaps from issue #64: - TunnelManager: ngrok with keychain token, missing/empty token error, process exit error propagation, duplicate URL suppression (5 new tests) - RouteTests: Compare raw values, all three Route inits, JSON decoding of comparisons map, unknown Compare key throws (7 new tests) - WebhookRouteFactoryTests: nil/empty routes, name/comparison preservation, closure wiring (4 new tests) Co-Authored-By: Claude Sonnet 4.6 --- JaredTests/RouteTests.swift | 87 ++++++++++++++++++++++ JaredTests/TunnelManagerTests.swift | 91 +++++++++++++++++++++++ JaredTests/WebhookRouteFactoryTests.swift | 55 ++++++++++++++ 3 files changed, 233 insertions(+) create mode 100644 JaredTests/RouteTests.swift create mode 100644 JaredTests/WebhookRouteFactoryTests.swift diff --git a/JaredTests/RouteTests.swift b/JaredTests/RouteTests.swift new file mode 100644 index 0000000..014ff54 --- /dev/null +++ b/JaredTests/RouteTests.swift @@ -0,0 +1,87 @@ +import XCTest +import JaredFramework + +class RouteTests: XCTestCase { + + // MARK: - Compare raw values + + func testCompareRawValues() { + XCTAssertEqual(Compare.startsWith.rawValue, "startsWith") + XCTAssertEqual(Compare.contains.rawValue, "contains") + XCTAssertEqual(Compare.is.rawValue, "is") + XCTAssertEqual(Compare.containsURL.rawValue, "containsURL") + XCTAssertEqual(Compare.isReaction.rawValue, "isReaction") + } + + // MARK: - Route init + + func testRouteInitMinimalPreservesProperties() { + var called = false + let route = Route(name: "ping", comparisons: [.startsWith: ["/ping"]], call: { _ in called = true }) + XCTAssertEqual(route.name, "ping") + XCTAssertEqual(route.comparisons[.startsWith], ["/ping"]) + XCTAssertNil(route.description) + XCTAssertNil(route.parameterSyntax) + } + + func testRouteInitWithDescriptionPreservesIt() { + let route = Route(name: "r", comparisons: [.contains: ["foo"]], call: { _ in }, description: "does foo") + XCTAssertEqual(route.description, "does foo") + XCTAssertNil(route.parameterSyntax) + } + + func testRouteInitWithSyntaxPreservesIt() { + let route = Route(name: "r", comparisons: [:], call: { _ in }, description: "d", parameterSyntax: "/r,arg") + XCTAssertEqual(route.parameterSyntax, "/r,arg") + } + + // MARK: - JSON decoding + + func testRouteDecodesFromJSON() throws { + let json = """ + { + "name": "ping", + "description": "Replies pong", + "parameterSyntax": "/ping", + "comparisons": { "startsWith": ["/ping"] } + } + """.data(using: .utf8)! + + let route = try JSONDecoder().decode(Route.self, from: json) + + XCTAssertEqual(route.name, "ping") + XCTAssertEqual(route.description, "Replies pong") + XCTAssertEqual(route.parameterSyntax, "/ping") + XCTAssertEqual(route.comparisons[.startsWith], ["/ping"]) + } + + func testRouteDecodesMultipleComparisons() throws { + let json = """ + { + "name": "multi", + "description": "d", + "parameterSyntax": "/m", + "comparisons": { "startsWith": ["/m"], "contains": ["foo", "bar"] } + } + """.data(using: .utf8)! + + let route = try JSONDecoder().decode(Route.self, from: json) + + XCTAssertEqual(route.comparisons[.startsWith], ["/m"]) + XCTAssertEqual(route.comparisons[.contains], ["foo", "bar"]) + } + + func testCompareDecoderThrowsOnUnknownKey() { + let json = """ + { + "name": "test", + "description": "test", + "parameterSyntax": "/test", + "comparisons": { "unknownCompareType": ["/test"] } + } + """.data(using: .utf8)! + + XCTAssertThrowsError(try JSONDecoder().decode(Route.self, from: json), + "unknown Compare key must throw DecodingError") + } +} diff --git a/JaredTests/TunnelManagerTests.swift b/JaredTests/TunnelManagerTests.swift index e135b33..ba7ae7e 100644 --- a/JaredTests/TunnelManagerTests.swift +++ b/JaredTests/TunnelManagerTests.swift @@ -8,10 +8,13 @@ final class MockTunnelRunner: TunnelRunner { var stopCallCount = 0 var onOutput: ((String) -> Void)? + var onComplete: ((Error?) -> Void)? + func start(command: TunnelLaunchCommand, onOutput: @escaping (String) -> Void, onComplete: @escaping (Error?) -> Void) { startCallCount += 1 lastCommand = command self.onOutput = onOutput + self.onComplete = onComplete isRunning = true } @@ -162,6 +165,94 @@ class TunnelManagerTests: XCTestCase { XCTAssertGreaterThanOrEqual(runner.stopCallCount, 1) } + func testSyncStartsNgrokWithTokenFromKeychain() { + let runner = MockTunnelRunner() + let keychain = MockKeychain() + keychain.save(secret: "mytoken", for: TunnelManager.ngrokKeychainAccount) + let manager = TunnelManager( + configuration: TunnelConfiguration(enabled: true, provider: .ngrok), + runner: runner, + keychain: keychain, + localPortProvider: { 3005 } + ) + + manager.sync(restApiEnabled: true, localPort: 3005) + + XCTAssertEqual(runner.startCallCount, 1) + XCTAssertEqual(runner.lastCommand, .ngrok(localPort: 3005, authToken: "mytoken")) + } + + func testSyncSetsErrorWhenNgrokTokenMissing() { + let runner = MockTunnelRunner() + let manager = TunnelManager( + configuration: TunnelConfiguration(enabled: true, provider: .ngrok), + runner: runner, + keychain: MockKeychain(), + localPortProvider: { 3005 } + ) + + manager.sync(restApiEnabled: true, localPort: 3005) + + XCTAssertEqual(runner.startCallCount, 0) + XCTAssertNotNil(manager.lastError) + } + + func testSyncSetsErrorWhenNgrokTokenIsEmpty() { + let runner = MockTunnelRunner() + let keychain = MockKeychain() + keychain.save(secret: "", for: TunnelManager.ngrokKeychainAccount) + let manager = TunnelManager( + configuration: TunnelConfiguration(enabled: true, provider: .ngrok), + runner: runner, + keychain: keychain, + localPortProvider: { 3005 } + ) + + manager.sync(restApiEnabled: true, localPort: 3005) + + XCTAssertEqual(runner.startCallCount, 0) + XCTAssertNotNil(manager.lastError) + } + + func testProcessErrorSetsLastError() { + let runner = MockTunnelRunner() + let manager = TunnelManager( + configuration: TunnelConfiguration(enabled: true, provider: .cloudflared), + runner: runner, + keychain: MockKeychain(), + localPortProvider: { 3005 } + ) + + manager.sync(restApiEnabled: true, localPort: 3005) + runner.onComplete?(TunnelError.processExited(127)) + + XCTAssertNotNil(manager.lastError) + XCTAssertTrue(manager.lastError!.contains("127"), "error message should include exit code") + } + + func testDuplicateURLLineDoesNotPostSecondNotification() { + let runner = MockTunnelRunner() + let manager = TunnelManager( + configuration: TunnelConfiguration(enabled: true, provider: .cloudflared), + runner: runner, + keychain: MockKeychain(), + localPortProvider: { 3005 } + ) + var notificationCount = 0 + let token = NotificationCenter.default.addObserver( + forName: TunnelManager.publicURLDidChangeNotification, + object: manager, + queue: nil + ) { _ in notificationCount += 1 } + + manager.sync(restApiEnabled: true, localPort: 3005) + runner.onOutput?("| https://abc-def.trycloudflare.com |") + runner.onOutput?("| https://abc-def.trycloudflare.com |") + + NotificationCenter.default.removeObserver(token) + XCTAssertEqual(notificationCount, 1, "duplicate URL line must not post a second notification") + } + func testWebserverConfigurationDecodesTunnelBlock() throws { let json = """ { diff --git a/JaredTests/WebhookRouteFactoryTests.swift b/JaredTests/WebhookRouteFactoryTests.swift new file mode 100644 index 0000000..29ad5e3 --- /dev/null +++ b/JaredTests/WebhookRouteFactoryTests.swift @@ -0,0 +1,55 @@ +import XCTest +import JaredFramework +@testable import Jared + +class WebhookRouteFactoryTests: XCTestCase { + + private func makeFactory() -> WebhookRouteFactory { + let dir = URL(fileURLWithPath: NSTemporaryDirectory()) + .appendingPathComponent("WebhookRouteFactoryTests-\(UUID().uuidString)", isDirectory: true) + let client = WebhookDeliveryClient( + session: .ephemeral, + keychain: MockKeychain(), + sender: nil, + deliveryStore: WebhookDeliveryStore(fileURL: dir.appendingPathComponent("deliveries.json")) + ) + return WebhookRouteFactory(client: client) + } + + func testRoutesFromWebhookWithNilRoutesReturnsEmpty() { + let webhook = RichWebhook(url: "https://example.com", routes: nil) + XCTAssertTrue(makeFactory().routes(from: webhook).isEmpty) + } + + func testRoutesFromWebhookWithEmptyRoutesReturnsEmpty() { + let webhook = RichWebhook(url: "https://example.com", routes: []) + XCTAssertTrue(makeFactory().routes(from: webhook).isEmpty) + } + + func testRoutesPreserveNamesAndComparisons() { + let r1 = Route(name: "greet", comparisons: [.startsWith: ["/hello"]], call: { _ in }) + let r2 = Route(name: "bye", comparisons: [.is: ["/bye"]], call: { _ in }) + let webhook = RichWebhook(url: "https://example.com", routes: [r1, r2]) + + let produced = makeFactory().routes(from: webhook) + + XCTAssertEqual(produced.count, 2) + XCTAssertEqual(produced[0].name, "greet") + XCTAssertEqual(produced[0].comparisons[.startsWith], ["/hello"]) + XCTAssertEqual(produced[1].name, "bye") + XCTAssertEqual(produced[1].comparisons[.is], ["/bye"]) + } + + func testEachRouteGetsFreshDeliveryCallClosure() { + let r1 = Route(name: "a", comparisons: [.startsWith: ["/a"]], call: { _ in }) + let r2 = Route(name: "b", comparisons: [.startsWith: ["/b"]], call: { _ in }) + let webhook = RichWebhook(url: "https://example.com", routes: [r1, r2]) + + let produced = makeFactory().routes(from: webhook) + + // Each route's call is wired by the factory (not the original stub closure) + XCTAssertEqual(produced.count, 2) + XCTAssertNotNil(produced[0].call as Any) + XCTAssertNotNil(produced[1].call as Any) + } +} From 349ff6c8bb132f0251e04bd7dab663dd4bf5925b Mon Sep 17 00:00:00 2001 From: Eric Dahl Date: Wed, 17 Jun 2026 01:38:52 -0400 Subject: [PATCH 2/2] Add PluginManagerTests and register new test files in xcodeproj PluginManagerTests covers: enabled() true/false/case-insensitive, module count after init, /ping route present in getAllRoutes, reload preserves modules, apply updates disabledCommands and webhooks (10 new tests). Also registers RouteTests, WebhookRouteFactoryTests, and PluginManagerTests in the Xcode project so they compile into the test target. 172 tests total, 0 failures. Co-Authored-By: Claude Sonnet 4.6 --- Jared.xcodeproj/project.pbxproj | 14 ++++++ JaredTests/PluginManagerTests.swift | 76 +++++++++++++++++++++++++++++ 2 files changed, 90 insertions(+) create mode 100644 JaredTests/PluginManagerTests.swift diff --git a/Jared.xcodeproj/project.pbxproj b/Jared.xcodeproj/project.pbxproj index 62fe67e..367db59 100644 --- a/Jared.xcodeproj/project.pbxproj +++ b/Jared.xcodeproj/project.pbxproj @@ -20,6 +20,9 @@ B319236224DE76A2001904BA /* JaredUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B319236124DE76A2001904BA /* JaredUITests.swift */; }; B32DE51524D51F9C00AB3A71 /* RouterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B32DE51424D51F9C00AB3A71 /* RouterTests.swift */; }; EC71CM00000000000000A002 /* CoreModuleTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = EC71CM00000000000000A001 /* CoreModuleTests.swift */; }; + RT00TEST0000000000002BF0 /* RouteTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = RT00TEST0000000000001FRF /* RouteTests.swift */; }; + WRF0TEST0000000000002BF0 /* WebhookRouteFactoryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = WRF0TEST0000000000001FRF /* WebhookRouteFactoryTests.swift */; }; + PM00TEST0000000000002BF0 /* PluginManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = PM00TEST0000000000001FRF /* PluginManagerTests.swift */; }; B32DE51624D522DA00AB3A71 /* Router.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3BF17C1244EBE9A00CC44C5 /* Router.swift */; }; EC70RF00000000000000A004 /* RuntimeFlags.swift in Sources */ = {isa = PBXBuildFile; fileRef = EC70RF00000000000000A001 /* RuntimeFlags.swift */; }; EC70FP00000000000000A004 /* InboundFilterPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = EC70FP00000000000000A001 /* InboundFilterPolicy.swift */; }; @@ -80,6 +83,7 @@ B38BB6AD24EB99BE000700F7 /* Webhook.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3B7E52824EA579600A3FBD1 /* Webhook.swift */; }; B38BB6AF24EBB4D8000700F7 /* Configuration.swift in Sources */ = {isa = PBXBuildFile; fileRef = B36C8D7E24EA655900C00A99 /* Configuration.swift */; }; B39A65E01CC1FB1B003E26B0 /* PluginManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = B39A65DF1CC1FB1B003E26B0 /* PluginManager.swift */; }; + PM00000000000000000001BF /* PluginManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = B39A65DF1CC1FB1B003E26B0 /* PluginManager.swift */; }; B39F2B391CB3BCA700C0D35C /* SendText.scpt in Resources */ = {isa = PBXBuildFile; fileRef = B39F2B381CB3BCA700C0D35C /* SendText.scpt */; }; B3A4C61423B867E300B7F009 /* MessageRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3A4C61323B867E300B7F009 /* MessageRequest.swift */; }; B3AECB5F2207CE33003E32F8 /* JaredFramework.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 7F84B61F1CC506790059A82B /* JaredFramework.framework */; }; @@ -204,6 +208,9 @@ B319236324DE76A2001904BA /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; B32DE51424D51F9C00AB3A71 /* RouterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RouterTests.swift; sourceTree = ""; }; EC71CM00000000000000A001 /* CoreModuleTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoreModuleTests.swift; sourceTree = ""; }; + RT00TEST0000000000001FRF /* RouteTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RouteTests.swift; sourceTree = ""; }; + WRF0TEST0000000000001FRF /* WebhookRouteFactoryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebhookRouteFactoryTests.swift; sourceTree = ""; }; + PM00TEST0000000000001FRF /* PluginManagerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PluginManagerTests.swift; sourceTree = ""; }; B32DE51924D5524300AB3A71 /* Action.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Action.swift; sourceTree = ""; }; B32DE51B24D552D300AB3A71 /* MockPluginManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockPluginManager.swift; sourceTree = ""; }; B33278511CB2C94700BBDD3A /* AppKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = AppKit.framework; path = System/Library/Frameworks/AppKit.framework; sourceTree = SDKROOT; }; @@ -409,6 +416,9 @@ B3B844B924D77A4800DC162A /* SendStyleTest.swift */, B32DE51424D51F9C00AB3A71 /* RouterTests.swift */, EC71CM00000000000000A001 /* CoreModuleTests.swift */, + RT00TEST0000000000001FRF /* RouteTests.swift */, + WRF0TEST0000000000001FRF /* WebhookRouteFactoryTests.swift */, + PM00TEST0000000000001FRF /* PluginManagerTests.swift */, EC70PT00000000000000A001 /* InboundFilterPolicyTests.swift */, B3B844CF24DA6AC800DC162A /* JaredWebServerTest.swift */, TM0000010000000000000005 /* TunnelManagerTests.swift */, @@ -882,6 +892,7 @@ B3B844C724D9298000DC162A /* MockRouter.swift in Sources */, B347A4AD24DD170C00E93657 /* ActionTest.swift in Sources */, B36710E224DFB5EC00DA1C5D /* JaredMock.swift in Sources */, + PM00000000000000000001BF /* PluginManager.swift in Sources */, B3B844C224D8FA7D00DC162A /* DatabaseTestHelper.swift in Sources */, B38BB6AD24EB99BE000700F7 /* Webhook.swift in Sources */, AD456B8A60A74A9E90DC26CA /* KeychainAccessor.swift in Sources */, @@ -899,6 +910,9 @@ TM0000010000000000000002 /* TunnelManager.swift in Sources */, B32DE51524D51F9C00AB3A71 /* RouterTests.swift in Sources */, EC71CM00000000000000A002 /* CoreModuleTests.swift in Sources */, + RT00TEST0000000000002BF0 /* RouteTests.swift in Sources */, + WRF0TEST0000000000002BF0 /* WebhookRouteFactoryTests.swift in Sources */, + PM00TEST0000000000002BF0 /* PluginManagerTests.swift in Sources */, EC71C000000000000000A010 /* CoreModule.swift in Sources */, EC71CS00000000000000A004 /* ContactNameService.swift in Sources */, EC71CK00000000000000A004 /* Clock.swift in Sources */, diff --git a/JaredTests/PluginManagerTests.swift b/JaredTests/PluginManagerTests.swift new file mode 100644 index 0000000..04133ab --- /dev/null +++ b/JaredTests/PluginManagerTests.swift @@ -0,0 +1,76 @@ +import XCTest +import JaredFramework +@testable import Jared + +class PluginManagerTests: XCTestCase { + + private func makeManager(disabledCommands: [String: Bool] = [:]) -> PluginManager { + let config = ConfigurationFile(disabledCommands: disabledCommands) + return PluginManager(sender: JaredMock(), configuration: config) + } + + // MARK: - enabled() + + func testEnabledReturnsTrueWhenCommandNotInMap() { + XCTAssertTrue(makeManager().enabled(routeName: "/ping")) + } + + func testEnabledReturnsFalseWhenCommandDisabled() { + XCTAssertFalse(makeManager(disabledCommands: ["/ping": true]).enabled(routeName: "/ping")) + } + + func testEnabledReturnsTrueWhenCommandExplicitlyEnabled() { + XCTAssertTrue(makeManager(disabledCommands: ["/ping": false]).enabled(routeName: "/ping")) + } + + func testEnabledIsCaseInsensitive() { + XCTAssertFalse(makeManager(disabledCommands: ["/ping": true]).enabled(routeName: "/PING")) + } + + // MARK: - getAllModules() / getAllRoutes() + + func testGetAllModulesHasFourInternalModulesOnInit() { + // CoreModule, ScheduleModule, InternalModule, WebHookManager + XCTAssertEqual(makeManager().getAllModules().count, 4) + } + + func testGetAllRoutesIncludesPingRoute() { + XCTAssertTrue(makeManager().getAllRoutes().contains { $0.name == "/ping" }) + } + + // MARK: - reload() + + func testReloadPreservesModuleCount() { + let manager = makeManager() + manager.reload() + XCTAssertEqual(manager.getAllModules().count, 4) + } + + func testReloadRestoresPingRoute() { + let manager = makeManager() + manager.reload() + XCTAssertTrue(manager.getAllRoutes().contains { $0.name == "/ping" }) + } + + // MARK: - apply() + + func testApplyUpdatesDisabledCommands() { + let manager = makeManager() + XCTAssertTrue(manager.enabled(routeName: "/ping")) + + manager.apply(ConfigurationFile(disabledCommands: ["/ping": true])) + + XCTAssertFalse(manager.enabled(routeName: "/ping")) + } + + func testApplyUpdatesWebhooks() { + let manager = makeManager() + XCTAssertTrue(manager.config.webhooks.isEmpty) + + let hook = RichWebhook(url: "https://example.com") + manager.apply(ConfigurationFile(webhooks: [hook])) + + XCTAssertEqual(manager.config.webhooks.count, 1) + XCTAssertEqual(manager.config.webhooks.first?.url, "https://example.com") + } +}