diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index fb4298ac0..d35b000e1 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -26,6 +26,9 @@ jobs: env: MAPBOX_DOWNLOADS_TOKEN: ${{ secrets.MAPBOX_DOWNLOADS_TOKEN }} + - name: Start SDK test proxy server + run: cd external/sdk-test-proxy && ./start-service + - name: Run All Tests run: ./Scripts/test.sh env: diff --git a/.gitmodules b/.gitmodules index 35dfc62b2..eeeb6af7f 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,3 +1,6 @@ [submodule "external/common"] path = external/common url = git@github.com:ably/ably-asset-tracking-common.git +[submodule "external/sdk-test-proxy"] + path = external/sdk-test-proxy + url = git@github.com:ably/sdk-test-proxy.git diff --git a/Tests/SystemTests/NetworkConnectivityTests.swift b/Tests/SystemTests/NetworkConnectivityTests.swift new file mode 100644 index 000000000..a992690b7 --- /dev/null +++ b/Tests/SystemTests/NetworkConnectivityTests.swift @@ -0,0 +1,94 @@ +import XCTest + +final class NetworkConnectivityTests: XCTestCase { + private let faultProxyExpectationTimeout: TimeInterval = 2 + + // This test is just a temporary one to demonstrate that the fault proxy client is working. + func testFaultProxyClient() { + let client = FaultProxyClient() + + // Get names of all faults + + let getAllFaultsExpectation = expectation(description: "get all faults") + var faultNames: [String]! + + client.getAllFaults { result in + do { + faultNames = try result.get() + } catch { + XCTFail("Failed to getAllFaults (\(error))") + } + + getAllFaultsExpectation.fulfill() + } + + wait(for: [getAllFaultsExpectation], timeout: faultProxyExpectationTimeout) + + // Create a fault simulation + + let faultName = faultNames[0] + + let createFaultSimulationExpectation = expectation(description: "create fault simulation") + var faultSimulationDto: FaultSimulationDTO! + + client.createFaultSimulation(withName: faultName) { result in + do { + faultSimulationDto = try result.get() + } catch { + XCTFail("Failed to create fault simulation (\(error))") + } + + createFaultSimulationExpectation.fulfill() + } + + wait(for: [createFaultSimulationExpectation], timeout: faultProxyExpectationTimeout) + + // Enable the fault simulation + + let enableFaultSimulationExpectation = expectation(description: "enable fault simulation") + + client.enableFaultSimulation(withID: faultSimulationDto.id) { result in + do { + try result.get() + } catch { + XCTFail("Failed to enable fault simulation (\(error))") + } + + enableFaultSimulationExpectation.fulfill() + } + + wait(for: [enableFaultSimulationExpectation], timeout: faultProxyExpectationTimeout) + + // Resolve the fault simulation + + let resolveFaultSimulationExpectation = expectation(description: "resolve fault simulation") + + client.resolveFaultSimulation(withID: faultSimulationDto.id) { result in + do { + try result.get() + } catch { + XCTFail("Failed to resolve fault simulation (\(error))") + } + + resolveFaultSimulationExpectation.fulfill() + } + + wait(for: [resolveFaultSimulationExpectation], timeout: faultProxyExpectationTimeout) + + // Clean up the fault simulation + + let cleanUpFaultSimulationExpectation = expectation(description: "clean up fault simulation") + + client.cleanUpFaultSimulation(withID: faultSimulationDto.id) { result in + do { + try result.get() + } catch { + XCTFail("Failed to clean up fault simulation (\(error))") + } + + cleanUpFaultSimulationExpectation.fulfill() + } + + wait(for: [cleanUpFaultSimulationExpectation], timeout: faultProxyExpectationTimeout) + } +} diff --git a/Tests/SystemTests/Proxy/FaultProxyClient.swift b/Tests/SystemTests/Proxy/FaultProxyClient.swift new file mode 100644 index 000000000..a46b7df4f --- /dev/null +++ b/Tests/SystemTests/Proxy/FaultProxyClient.swift @@ -0,0 +1,111 @@ +import Foundation + +/// A client for communicating with an instance of the SDK test proxy server. Provides methods for creating and managing proxies which are able to simulate connectivity faults that might occur during use of the Ably Asset Tracking SDKs. +class FaultProxyClient { + private let baseURL: URL + private let urlSession = URLSession(configuration: .default) + + // TODO callback queue + + init(baseURL: URL = URL(string: "http://localhost:8080")!) { + self.baseURL = baseURL + } + + private func url(forPathComponents pathComponents: String...) -> URL { + return pathComponents.reduce(baseURL) { (url, pathComponent) in + url.appendingPathComponent(pathComponent) + } + } + + private enum HTTPMethod: String { + case get = "GET" + case post = "POST" + } + + enum RequestError: Swift.Error { + case unexpectedStatus(Int) + } + + private func makeRequest(for url: URL, method: HTTPMethod, _ completionHandler: @escaping (Result) -> Void) { + var request = URLRequest(url: url) + request.httpMethod = method.rawValue + + let task = urlSession.dataTask(with: request) { data, response, error in + if let error = error { + completionHandler(.failure(error)) + return + } + + let httpResponse = response as! HTTPURLResponse + + guard httpResponse.statusCode >= 200 && httpResponse.statusCode < 300 else { + completionHandler(.failure(RequestError.unexpectedStatus(httpResponse.statusCode))) + return + } + + completionHandler(.success(data!)) + } + + task.resume() + } + + private func makeVoidPostRequest(for url: URL, _ completionHandler: @escaping (Result) -> Void) { + makeRequest(for: url, method: .post) { result in + completionHandler(result.map() { success in }) + } + } + + // TODO some logging here + + /// Lists all of the faults that the server is capable of simulating. + func getAllFaults(_ completionHandler: @escaping (Result<[String], Error>) -> Void) { + let url = url(forPathComponents: "faults") + + makeRequest(for: url, method: .get) { result in + do { + let decoder = JSONDecoder() + let data = try result.get() + let faultNames = try decoder.decode([String].self, from: data) + + completionHandler(.success(faultNames)) + } catch { + completionHandler(.failure(error)) + } + } + } + + /// Creates a fault simulation and starts its proxy. + func createFaultSimulation(withName name: String, _ completionHandler: @escaping (Result) -> Void) { + let url = url(forPathComponents: "faults", name, "simulation") + + makeRequest(for: url, method: .post) { result in + do { + let decoder = JSONDecoder() + let data = try result.get() + let dto = try decoder.decode(FaultSimulationDTO.self, from: data) + + completionHandler(.success(dto)) + } catch { + completionHandler(.failure(error)) + } + } + } + + /// Breaks the proxy using the fault-specific failure conditions. + func enableFaultSimulation(withID id: String, _ completionHandler: @escaping (Result) -> Void) { + let url = url(forPathComponents: "fault-simulations", id, "enable") + makeVoidPostRequest(for: url, completionHandler) + } + + /// Restores the proxy to normal functionality. + func resolveFaultSimulation(withID id: String, _ completionHandler: @escaping (Result) -> Void) { + let url = url(forPathComponents: "fault-simulations", id, "resolve") + makeVoidPostRequest(for: url, completionHandler) + } + + /// Stops the proxy. This should be called at the end of each test case that creates a fault simulation. + func cleanUpFaultSimulation(withID id: String, _ completionHandler: @escaping (Result) -> Void) { + let url = url(forPathComponents: "fault-simulations", id, "clean-up") + makeVoidPostRequest(for: url, completionHandler) + } +} diff --git a/Tests/SystemTests/Proxy/FaultProxyDTOs.swift b/Tests/SystemTests/Proxy/FaultProxyDTOs.swift new file mode 100644 index 000000000..1a35b0f18 --- /dev/null +++ b/Tests/SystemTests/Proxy/FaultProxyDTOs.swift @@ -0,0 +1,73 @@ +struct ProxyDTO: Decodable { + var listenPort: Int +} + +struct FaultSimulationDTO: Decodable { + var id: String + var name: String + var type: FaultTypeDTO + var proxy: ProxyDTO +} + +/** + * Describes the nature of a given fault simulation, and specifically the impact that it + * should have on any Trackables or channel activity during and after resolution. + */ +enum FaultTypeDTO: Decodable { + /** + * AAT and/or ably-cocoa should handle this fault seamlessly. Trackable state should be + * online and publisher should be present within `resolvedWithinMillis`. It's possible + * the fault will cause a brief Offline blip, but tests should expect to see Trackables + * Online again before `resolvedWithinMillis` expires regardless. + */ + case nonfatal(resolvedWithinMillis: Int) + + // TODO update link + /** + * This is a non-fatal error, but will persist until the [FaultSimulation.resolve] + * method has been called. Trackable states should be offline during the fault within + * `offlineWithinMillis` maximum. When the fault is resolved, Trackables should return + * online within `onlineWithinMillis` maximum. + */ + case nonfatalWhenResolved(offlineWithinMillis: Int, onlineWithinMillis: Int) + + /** + * This is a fatal error and should permanently move Trackables to the Failed state. + * The publisher should not be present in the corresponding channel any more and no + * further location updates will be published. Tests should check that Trackables reach + * the Failed state within `failedWithinMillis`. + */ + case fatal(failedWithinMillis: Int) + + private enum CodingKeys: CodingKey { + case type + case resolvedWithinMillis + case offlineWithinMillis + case onlineWithinMillis + case failedWithinMillis + } + + private enum FaultTypeDiscriminatorDTO: String, Decodable { + case nonfatal + case nonfatalWhenResolved + case fatal + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let discriminator = try container.decode(FaultTypeDiscriminatorDTO.self, forKey: .type) + + switch discriminator { + case .nonfatal: + let resolvedWithinMillis = try container.decode(Int.self, forKey: .resolvedWithinMillis) + self = .nonfatal(resolvedWithinMillis: resolvedWithinMillis) + case .nonfatalWhenResolved: + let offlineWithinMillis = try container.decode(Int.self, forKey: .offlineWithinMillis) + let onlineWithinMillis = try container.decode(Int.self, forKey: .onlineWithinMillis) + self = .nonfatalWhenResolved(offlineWithinMillis: offlineWithinMillis, onlineWithinMillis: onlineWithinMillis) + case .fatal: + let failedWithinMillis = try container.decode(Int.self, forKey: .failedWithinMillis) + self = .fatal(failedWithinMillis: failedWithinMillis) + } + } +} diff --git a/external/sdk-test-proxy b/external/sdk-test-proxy new file mode 160000 index 000000000..6781c8fe3 --- /dev/null +++ b/external/sdk-test-proxy @@ -0,0 +1 @@ +Subproject commit 6781c8fe36fc44310fdd8e8ac7f989ea56771b55