import XCTest @testable import RouterOSAssistant private final class MockTransport: RouterOSTransport { let kind: RouterOSTransportKind var connectError: Error? var deviceInfo = RouterDeviceInfo(boardName: "Mock", routerOSVersion: "7.0", architecture: "arm64", uptime: "1h") /// Canned `fetchMenuItems` responses, keyed by menu path — lets a test simulate whatever the /// router currently reports for `/interface bridge port`, `/ip address`, etc. without a real /// connection. Menu paths not present here return an empty list, same as a genuinely empty /// menu on the router. var menuItemsByPath: [String: [RouterOSMenuItem]] = [:] init(kind: RouterOSTransportKind, connectError: Error? = nil) { self.kind = kind self.connectError = connectError } func connect() async throws { if let connectError { throw connectError } } func fetchDeviceInfo() async throws -> RouterDeviceInfo { deviceInfo } func fetchInterfaces() async throws -> [NetworkInterface] { [] } func fetchFirewallRuleCounts() async throws -> FirewallRuleCounts { FirewallRuleCounts(filterRuleCount: 0, natRuleCount: 0) } func fetchMenuItems(menuPath: String, restPath: String) async throws -> [RouterOSMenuItem] { menuItemsByPath[menuPath] ?? [] } func fetchFieldValues(menuPath: String, restPath: String, whereField: String, whereValue: String, returnField: String) async throws -> Set { [] } func apply(_ command: RouterOSCommand) async throws {} func disconnect() async {} } @MainActor final class ConnectionServiceTests: XCTestCase { private let credentials = RouterOSCredentials(host: "192.168.88.1", username: "admin", password: "") func testFallsBackToSSHWhenRestFails() async { let service = ConnectionService() let sshTransport = MockTransport(kind: .ssh) await service.connect( with: credentials, makeRestTransport: { MockTransport(kind: .rest, connectError: RouterOSError.transportUnavailable("kein REST")) }, makeSSHTransport: { sshTransport } ) XCTAssertEqual(service.state, .connected(kind: .ssh)) } func testUsesRestWhenAvailable() async { let service = ConnectionService() await service.connect( with: credentials, makeRestTransport: { MockTransport(kind: .rest) }, makeSSHTransport: { MockTransport(kind: .ssh) } ) XCTAssertEqual(service.state, .connected(kind: .rest)) } func testUntrustedCertificateAsksForConfirmationInsteadOfFallingBackToSSH() async { let service = ConnectionService() await service.connect( with: credentials, makeRestTransport: { MockTransport(kind: .rest, connectError: RouterOSError.untrustedCertificate(fingerprint: "AA:BB")) }, makeSSHTransport: { MockTransport(kind: .ssh) } ) XCTAssertEqual(service.state, .needsCertificateConfirmation(fingerprint: "AA:BB")) } func testFailsWhenBothTransportsFail() async { let service = ConnectionService() await service.connect( with: credentials, makeRestTransport: { MockTransport(kind: .rest, connectError: RouterOSError.transportUnavailable("kein REST")) }, makeSSHTransport: { MockTransport(kind: .ssh, connectError: RouterOSError.transportUnavailable("kein SSH")) } ) guard case .failed = service.state else { XCTFail("Erwarteter Zustand .failed, war \(service.state)") return } } // MARK: - checkPortConflict (Setup wizard LAN step pre-check) private func connectedService(menuItemsByPath: [String: [RouterOSMenuItem]]) async -> ConnectionService { let service = ConnectionService() let transport = MockTransport(kind: .rest) transport.menuItemsByPath = menuItemsByPath await service.connect(with: credentials, makeRestTransport: { transport }, makeSSHTransport: { transport }) return service } func testCheckPortConflictReturnsNilWhenPortIsFree() async throws { let service = await connectedService(menuItemsByPath: [:]) let conflict = try await service.checkPortConflict(interfaceName: "ether4") XCTAssertNil(conflict) } func testCheckPortConflictNeverFlagsTheSharedBridgeItself() async throws { // "bridge" is this app's own shared-LAN interface, not "someone else's" configuration — // even if it happens to already carry addresses (the normal case), it must never be // reported as a conflict. let service = await connectedService(menuItemsByPath: [ "/ip address": [RouterOSMenuItem(id: "*1", fields: ["interface": "bridge", "address": "192.168.88.1/24"])] ]) let conflict = try await service.checkPortConflict(interfaceName: "bridge") XCTAssertNil(conflict) } func testCheckPortConflictDetectsBridgeMembership() async throws { let service = await connectedService(menuItemsByPath: [ "/interface bridge port": [RouterOSMenuItem(id: "*1", fields: ["interface": "ether4", "bridge": "bridge"])] ]) let conflict = try await service.checkPortConflict(interfaceName: "ether4") XCTAssertEqual(conflict, PortConflict(interfaceName: "ether4", reasons: [.bridgeMember(bridgeName: "bridge")])) } func testCheckPortConflictDetectsExistingAddressDhcpClientAndPppoeClientTogether() async throws { let service = await connectedService(menuItemsByPath: [ "/ip address": [RouterOSMenuItem(id: "*1", fields: ["interface": "ether1", "address": "10.0.0.1/24"])], "/ip dhcp-client": [RouterOSMenuItem(id: "*2", fields: ["interface": "ether1"])], "/interface pppoe-client": [RouterOSMenuItem(id: "*3", fields: ["interface": "ether1"])] ]) let conflict = try await service.checkPortConflict(interfaceName: "ether1") XCTAssertEqual(conflict, PortConflict( interfaceName: "ether1", reasons: [.hasAddresses(["10.0.0.1/24"]), .dhcpClient, .pppoeClient] )) } func testCheckPortConflictIgnoresEntriesForOtherInterfaces() async throws { let service = await connectedService(menuItemsByPath: [ "/interface bridge port": [RouterOSMenuItem(id: "*1", fields: ["interface": "ether2", "bridge": "bridge"])], "/ip address": [RouterOSMenuItem(id: "*2", fields: ["interface": "ether2", "address": "192.168.88.1/24"])] ]) let conflict = try await service.checkPortConflict(interfaceName: "ether5") XCTAssertNil(conflict) } }