diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b25867f..c6dc5b5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,15 +11,13 @@ jobs: strategy: matrix: xcode: - - '14.0.1' + - '14.2' env: DEVELOPER_DIR: /Applications/Xcode_${{ matrix.xcode }}.app/Contents/Developer steps: - uses: actions/checkout@v2 - name: Run tests run: make test-library-xcode - - name: Compile documentation - run: make test-docs library-linux: runs-on: ubuntu-latest @@ -33,8 +31,8 @@ jobs: examples: runs-on: macos-12 env: - DEVELOPER_DIR: /Applications/Xcode_14.0.app/Contents/Developer + DEVELOPER_DIR: /Applications/Xcode_14.2.app/Contents/Developer steps: - uses: actions/checkout@v2 - name: Run tests - run: make test-examples + run: make build-examples diff --git a/Examples/Package.swift b/Examples/Package.swift index 84580b7..5cfab4a 100644 --- a/Examples/Package.swift +++ b/Examples/Package.swift @@ -1,4 +1,5 @@ // swift-tools-version:5.6 + import PackageDescription let package = Package( diff --git a/Examples/webhooks/BuildkiteClient+Middleware.swift b/Examples/webhooks/BuildkiteClient+Middleware.swift index e24c03e..22004fc 100644 --- a/Examples/webhooks/BuildkiteClient+Middleware.swift +++ b/Examples/webhooks/BuildkiteClient+Middleware.swift @@ -32,7 +32,8 @@ extension BuildkiteClient: AsyncMiddleware { } guard - let secretKey = Environment + let secretKey = + Environment .get("BUILDKITE_WEBHOOK_SECRET")? .data(using: .utf8) else { @@ -42,12 +43,14 @@ extension BuildkiteClient: AsyncMiddleware { ) } - let replayLimit = Environment + let replayLimit = + Environment .get("BUILDKITE_WEBHOOK_REPLAY_LIMIT") .flatMap(TimeInterval.init) if let signature = request.headers.buildkiteSignature, - let payload = request.body.data { + let payload = request.body.data + { try validateWebhookPayload( signatureHeader: signature, body: Data(buffer: payload), diff --git a/Examples/webhooks/Example.swift b/Examples/webhooks/Example.swift index 30c483d..c610813 100644 --- a/Examples/webhooks/Example.swift +++ b/Examples/webhooks/Example.swift @@ -15,7 +15,7 @@ import Vapor let app = try Application(.detect()) defer { app.shutdown() } - let buildkite = BuildkiteClient(/* transport: app.client */) + let buildkite = BuildkiteClient( /* transport: app.client */) app.group(buildkite) { $0.post("buildkite_webhook") { req in diff --git a/Makefile b/Makefile index 29388ad..7c9fb40 100644 --- a/Makefile +++ b/Makefile @@ -14,66 +14,75 @@ FORMAT_PATHS := $(GIT_REPO_TOPLEVEL)/Examples $(GIT_REPO_TOPLEVEL)/Package.swift # Tasks +.PHONY: default default: test-all -test-all: test-library test-library-xcode test-examples +.PHONY: test-all +test-all: test-library test-library-xcode build-examples +.PHONY: test-library test-library: swift test --parallel -test-library-xcode: +.PHONY: test-library-xcode +test-library-xcode: test-library-xcode-ios test-library-xcode-macos test-library-xcode-tvos test-library-xcode-watchos + +.PHONY: test-library-xcode-ios +test-library-xcode-ios: xcodebuild test \ -scheme Buildkite \ -destination "$(DESTINATION_PLATFORM_IOS_SIMULATOR)" \ -quiet + +.PHONY: test-library-xcode-macos +test-library-xcode-macos: xcodebuild test \ -scheme Buildkite \ -destination "$(DESTINATION_PLATFORM_MACOS)" \ -quiet + +.PHONY: test-library-xcode-tvos +test-library-xcode-tvos: xcodebuild test \ -scheme Buildkite \ -destination "$(DESTINATION_PLATFORM_TVOS_SIMULATOR)" \ -quiet + +.PHONY: test-library-xcode-watchos +test-library-xcode-watchos: xcodebuild \ -scheme Buildkite \ -destination "$(DESTINATION_PLATFORM_WATCHOS_SIMULATOR)" \ -quiet -test-examples: - xcodebuild build \ - -scheme simple \ - -destination "$(DESTINATION_PLATFORM_MACOS)" \ - -quiet - xcodebuild build \ - -scheme graphql \ - -destination "$(DESTINATION_PLATFORM_MACOS)" \ - -quiet - xcodebuild build \ - -scheme advanced-authorization \ - -destination "$(DESTINATION_PLATFORM_MACOS)" \ - -quiet - xcodebuild build \ - -scheme test-analytics \ - -destination "$(DESTINATION_PLATFORM_MACOS)" \ - -quiet - xcodebuild build \ - -scheme webhooks \ - -destination "$(DESTINATION_PLATFORM_MACOS)" \ - -quiet +.PHONY: build-examples +build-examples: build-examples-all -DOC_WARNINGS := $(shell xcodebuild clean docbuild \ - -scheme Buildkite \ - -destination "$(DESTINATION_PLATFORM_MACOS)" \ - -quiet \ - 2>&1 \ - | grep "couldn't be resolved to known documentation" \ - | sed 's|$(PWD)|.|g' \ - | tr '\n' '\1') -test-docs: - @test "$(DOC_WARNINGS)" = "" \ - || (echo "xcodebuild docbuild failed:\n\n$(DOC_WARNINGS)" | tr '\1' '\n' \ - && exit 1) +.PHONY: build-examples-all +build-examples-all: + swift build --package-path Examples +.PHONY: build-examples-simple +build-examples-simple: + swift build --package-path Examples --product simple + +.PHONY: build-examples-graphql +build-examples-graphql: + swift build --package-path Examples --product graphql + +.PHONY: build-examples-advanced-authorization +build-examples-advanced-authorization: + swift build --package-path Examples --product advanced-authorization + +.PHONY: build-examples-test-analytics +build-examples-test-analytics: + swift build --package-path Examples --product test-analytics + +.PHONY: build-examples-webhooks +build-examples-webhooks: + swift build --package-path Examples --product webhooks + +.PHONY: format format: $(SWIFT_FORMAT_BIN) \ --configuration $(SWIFT_FORMAT_CONFIG_FILE) \ @@ -82,11 +91,10 @@ format: --recursive \ $(FORMAT_PATHS) +.PHONY: lint lint: $(SWIFT_FORMAT_BIN) lint \ --configuration $(SWIFT_FORMAT_CONFIG_FILE) \ --ignore-unparsable-files \ --recursive \ $(FORMAT_PATHS) - -.PHONY: format lint test-all test-library test-library-xcode test-examples test-docs diff --git a/Package.swift b/Package.swift index 1d9e105..36371ed 100644 --- a/Package.swift +++ b/Package.swift @@ -17,8 +17,7 @@ let package = Package( ) ], dependencies: [ - .package(url: "https://github.com/apple/swift-crypto.git", .upToNextMajor(from: "2.0.0")), - .package(url: "https://github.com/apple/swift-docc-plugin", from: "1.0.0"), + .package(url: "https://github.com/apple/swift-crypto.git", .upToNextMajor(from: "2.0.0")) ], targets: [ .target( diff --git a/Sources/Buildkite/Models/Artifact.swift b/Sources/Buildkite/Models/Artifact.swift index 2b84e84..3b89051 100644 --- a/Sources/Buildkite/Models/Artifact.swift +++ b/Sources/Buildkite/Models/Artifact.swift @@ -73,7 +73,7 @@ public struct Artifact: Codable, Equatable, Hashable, Identifiable, Sendable { case deleted } - public struct URLs: Codable, Equatable { + public struct URLs: Codable, Equatable, Sendable { public var url: URL public init( diff --git a/Sources/Buildkite/Models/Cluster.swift b/Sources/Buildkite/Models/Cluster.swift new file mode 100644 index 0000000..4db2e06 --- /dev/null +++ b/Sources/Buildkite/Models/Cluster.swift @@ -0,0 +1,90 @@ +// +// Cluster.swift +// Buildkite +// +// Created by Aaron Sky on 6/18/23. +// Copyright © 2023 Aaron Sky. All rights reserved. +// + +import Foundation + +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + +/// A cluster is an isolated set of agents and pipelines within an organization. + +public struct Cluster: Codable, Equatable, Hashable, Identifiable, Sendable { + /// ID of the cluster. + public var id: UUID + /// ID of the cluster to be used with the GraphQL API. + public var graphqlId: String + /// ID of the cluster's default queue. Agents that connect to the cluster without specifying a queue will accept jobs from this queue. + public var defaultQueueId: UUID + /// Name of the cluster. + public var name: String + /// Description of the cluster. + public var description: String + /// Emoji for the cluster using the emoji syntax. + public var emoji: String + /// Color hex code for the cluster. + public var color: String + /// Followable URL to fetch this specific cluster. + public var url: Followable + /// Human-readable URL of this cluster in the Buildkite dashboard. + public var webURL: URL + /// Followable URL to fetch this cluster's queues. + public var queuesURL: Followable + /// Followable URL to fetch this cluster's default queue. + public var defaultQueueURL: Followable + /// When the cluster was created. + public var createdAt: Date + /// User who created the cluster. + public var createdBy: User? + + public init( + id: UUID, + graphqlId: String, + defaultQueueId: UUID, + name: String, + description: String, + emoji: String, + color: String, + url: Followable, + webURL: URL, + queuesURL: Followable, + defaultQueueURL: Followable, + createdAt: Date, + createdBy: User? = nil + ) { + self.id = id + self.graphqlId = graphqlId + self.defaultQueueId = defaultQueueId + self.name = name + self.description = description + self.emoji = emoji + self.color = color + self.url = url + self.webURL = webURL + self.queuesURL = queuesURL + self.defaultQueueURL = defaultQueueURL + self.createdAt = createdAt + self.createdBy = createdBy + } + + private enum CodingKeys: String, CodingKey { + case id + case graphqlId = "graphql_id" + case defaultQueueId = "default_queue_id" + case name + case description + case emoji + case color + case url + case webURL = "web_url" + case queuesURL = "queues_url" + case defaultQueueURL = "default_queue_url" + case createdAt = "created_at" + case createdBy = "created_by" + } +} diff --git a/Sources/Buildkite/Models/ClusterQueue.swift b/Sources/Buildkite/Models/ClusterQueue.swift new file mode 100644 index 0000000..06844ca --- /dev/null +++ b/Sources/Buildkite/Models/ClusterQueue.swift @@ -0,0 +1,89 @@ +// +// ClusterQueue.swift +// Buildkite +// +// Created by Aaron Sky on 6/18/23. +// Copyright © 2023 Aaron Sky. All rights reserved. +// + +import Foundation + +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + +/// Cluster queues are discrete groups of agents within a cluster. Pipelines in that cluster can target cluster queues to run jobs on agents assigned to those queues. +public struct ClusterQueue: Codable, Equatable, Hashable, Identifiable, Sendable { + /// ID of the cluster. + public var id: UUID + /// ID of the cluster to be used with the GraphQL API. + public var graphqlId: String + /// The queue key. + public var key: String + /// Description of the queue. + public var description: String + /// Followable URL to fetch this specific queue. + public var url: Followable + /// Human-readable URL of this queue in the Buildkite dashboard. + public var webURL: URL + /// Followable URL to fetch the cluster the queue belongs to. + public var clusterURL: Followable + /// Indicates whether the queue has paused dispatching jobs to associated agents. + public var dispatchPaused: Bool + /// User who paused the queue. + public var dispatchPausedBy: User? + /// When the queue was paused. + public var dispatchPausedAt: Date? + /// The note left when the queue was paused. + public var dispatchPausedNote: String? + /// When the queue was created. + public var createdAt: Date + /// User who created the queue. + public var createdBy: User? + + public init( + id: UUID, + graphqlId: String, + key: String, + description: String, + url: Followable, + webURL: URL, + clusterURL: Followable, + dispatchPaused: Bool, + dispatchPausedBy: User? = nil, + dispatchPausedAt: Date? = nil, + dispatchPausedNote: String? = nil, + createdAt: Date, + createdBy: User? = nil + ) { + self.id = id + self.graphqlId = graphqlId + self.key = key + self.description = description + self.url = url + self.webURL = webURL + self.clusterURL = clusterURL + self.dispatchPaused = dispatchPaused + self.dispatchPausedBy = dispatchPausedBy + self.dispatchPausedAt = dispatchPausedAt + self.dispatchPausedNote = dispatchPausedNote + self.createdAt = createdAt + self.createdBy = createdBy + } + + private enum CodingKeys: String, CodingKey { + case id + case graphqlId = "graphql_id" + case key + case description + case url + case webURL = "web_url" + case clusterURL = "cluster_url" + case dispatchPaused = "dispatch_paused" + case dispatchPausedBy = "dispatch_paused_by" + case dispatchPausedAt = "dispatch_paused_at" + case dispatchPausedNote = "dispatch_paused_note" + case createdAt = "created_at" + case createdBy = "created_by" + } +} diff --git a/Sources/Buildkite/Models/ClusterToken.swift b/Sources/Buildkite/Models/ClusterToken.swift new file mode 100644 index 0000000..e87fe7d --- /dev/null +++ b/Sources/Buildkite/Models/ClusterToken.swift @@ -0,0 +1,67 @@ +// +// ClusterToken.swift +// Buildkite +// +// Created by Aaron Sky on 6/18/23. +// Copyright © 2023 Aaron Sky. All rights reserved. +// + +import Foundation + +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + +/// A cluster token is used to connect agents to a cluster. +public struct ClusterToken: Codable, Equatable, Hashable, Identifiable, Sendable { + /// ID of the cluster token. + public var id: UUID + /// ID of the token to be used with the GraphQL API. + public var graphqlId: String + /// Description of the token. + public var description: String + /// Followable URL to fetch this specific cluster token.. + public var url: Followable + /// Followable URL to the cluster the token belongs to.. + public var clusterURL: Followable + /// When the token was created. + public var createdAt: Date + /// User who created the token. + public var createdBy: User? + /// The token. + /// + /// To ensure the security of tokens, the value is only included in the response for the request to create the token. + /// Subsequent responses do not contain the token value. + public var token: String? + + public init( + id: UUID, + graphqlId: String, + description: String, + url: Followable, + clusterURL: Followable, + createdAt: Date, + createdBy: User? = nil, + token: String? = nil + ) { + self.id = id + self.graphqlId = graphqlId + self.description = description + self.url = url + self.clusterURL = clusterURL + self.createdAt = createdAt + self.createdBy = createdBy + self.token = token + } + + private enum CodingKeys: String, CodingKey { + case id + case graphqlId = "graphql_id" + case description + case url + case clusterURL = "cluster_url" + case createdAt = "created_at" + case createdBy = "created_by" + case token + } +} diff --git a/Sources/Buildkite/Models/FlakyTest.swift b/Sources/Buildkite/Models/FlakyTest.swift new file mode 100644 index 0000000..3e405b2 --- /dev/null +++ b/Sources/Buildkite/Models/FlakyTest.swift @@ -0,0 +1,65 @@ +// +// FlakyTest.swift +// Buildkite +// +// Created by Aaron Sky on 6/18/23. +// Copyright © 2023 Aaron Sky. All rights reserved. +// + +import Foundation + +/// Information about a flaky test that has been identified in a Test Analytics test suite. +public struct FlakyTest: Codable, Equatable, Hashable, Identifiable, Sendable { + /// ID of the flaky test. + public var id: UUID + /// ID of the user to be used with the GraphQL API. + public var graphqlId: String + /// Human-readable URL of this agent in the Buildkite dashboard. + public var webURL: URL + /// Scope of the test in the source code. + public var scope: String + /// Name of the test. + public var name: String + /// Path and line number to the test file. + public var location: String + /// Path to the test file. + public var fileName: String + /// Number of instances the test has "flaked". + public var instances: Int + /// The latest occurrence of the flake. + public var mostRecentInstanceAt: Date + + public init( + id: UUID, + graphqlId: String, + webURL: URL, + scope: String, + name: String, + location: String, + fileName: String, + instances: Int, + mostRecentInstanceAt: Date + ) { + self.id = id + self.graphqlId = graphqlId + self.webURL = webURL + self.scope = scope + self.name = name + self.location = location + self.fileName = fileName + self.instances = instances + self.mostRecentInstanceAt = mostRecentInstanceAt + } + + private enum CodingKeys: String, CodingKey { + case id + case graphqlId = "graphql_id" + case webURL = "web_url" + case scope + case name + case location + case fileName = "file_name" + case instances + case mostRecentInstanceAt = "most_recent_instance_at" + } +} diff --git a/Sources/Buildkite/Networking/BuildkiteClient+Webhooks.swift b/Sources/Buildkite/Networking/BuildkiteClient+Webhooks.swift index 7b533af..ca572bb 100644 --- a/Sources/Buildkite/Networking/BuildkiteClient+Webhooks.swift +++ b/Sources/Buildkite/Networking/BuildkiteClient+Webhooks.swift @@ -99,7 +99,8 @@ extension BuildkiteClient { private nonisolated func getTimestampAndSignature(_ header: String) throws -> (timestamp: String, signature: Data) { let parts: [String: String] = Dictionary( - uniqueKeysWithValues: header + uniqueKeysWithValues: + header .split(separator: ",") .compactMap { kv in let kvp = kv.split(separator: "=", maxSplits: 2) @@ -132,11 +133,13 @@ extension BuildkiteClient { private nonisolated func checkMAC(message: Data, signature: Data, secretKey: Data) throws { let key = SymmetricKey(data: secretKey) - guard HMAC.isValidAuthenticationCode( - signature, - authenticating: message, - using: key - ) + guard + HMAC + .isValidAuthenticationCode( + signature, + authenticating: message, + using: key + ) else { throw WebhookValidationError.signatureRefused } diff --git a/Sources/Buildkite/Networking/Formatters.swift b/Sources/Buildkite/Networking/Formatters.swift index 4935263..6eb96bf 100644 --- a/Sources/Buildkite/Networking/Formatters.swift +++ b/Sources/Buildkite/Networking/Formatters.swift @@ -52,7 +52,8 @@ extension Date { init?( iso8601String: String ) { - guard let date = Formatters.iso8601WithFractionalSeconds.date(from: iso8601String) + guard + let date = Formatters.iso8601WithFractionalSeconds.date(from: iso8601String) ?? Formatters.iso8601WithoutFractionalSeconds.date(from: iso8601String) else { return nil } self = date diff --git a/Sources/Buildkite/Networking/Pagination.swift b/Sources/Buildkite/Networking/Pagination.swift index d3bd2e5..4a59c91 100644 --- a/Sources/Buildkite/Networking/Pagination.swift +++ b/Sources/Buildkite/Networking/Pagination.swift @@ -31,7 +31,8 @@ public struct Page: Equatable, Hashable, Sendable { } for link in header.split(separator: ",") { - let segments = link + let segments = + link .trimmingCharacters(in: .whitespacesAndNewlines) .split(separator: ";") guard diff --git a/Sources/Buildkite/Networking/Resource.swift b/Sources/Buildkite/Networking/Resource.swift index aeac2ba..b5c4fb8 100644 --- a/Sources/Buildkite/Networking/Resource.swift +++ b/Sources/Buildkite/Networking/Resource.swift @@ -19,7 +19,7 @@ public enum ResourceError: Error, Equatable { } /// Resource describes an endpoint on any of Buildkite's APIs. Do not implement this type. -public protocol Resource { +public protocol Resource: Sendable { /// The input type. associatedtype Body = Void /// The return type. diff --git a/Sources/Buildkite/Networking/TokenProvider.swift b/Sources/Buildkite/Networking/TokenProvider.swift index 0a3db00..9948966 100644 --- a/Sources/Buildkite/Networking/TokenProvider.swift +++ b/Sources/Buildkite/Networking/TokenProvider.swift @@ -24,7 +24,7 @@ import FoundationNetworking /// if you need to read your token dynamically from the keychain, it may be more adventageous to do that as part of a /// ``TokenProvider`` implementation rather than tightly-coupling keychain raw string access directly to your /// client instantiation. -public protocol TokenProvider { +public protocol TokenProvider: Sendable { /// Returns a token string for the given ``APIVersion``. This will usually be a fixed constant such as ``APIVersion/REST/v2`` or ``APIVersion/GraphQL/v1``, so you can switch on the values of one of these. func token(for version: APIVersion) async -> String? } diff --git a/Sources/Buildkite/Networking/Transport.swift b/Sources/Buildkite/Networking/Transport.swift index 145853d..ba6d31c 100644 --- a/Sources/Buildkite/Networking/Transport.swift +++ b/Sources/Buildkite/Networking/Transport.swift @@ -19,7 +19,7 @@ public enum TransportError: Error { } /// Interface for the asynchronous communication layer the ``BuildkiteClient`` uses. -public protocol Transport { +public protocol Transport: Sendable { typealias Output = (data: Data, response: URLResponse) /// Send the request and receive the ``Output`` asynchronously. @@ -32,12 +32,16 @@ extension URLSession: Transport { // Task-based API for URLSession. #if os(Linux) || os(Windows) return try await withCheckedThrowingContinuation { continuation in - send(request: request, completion: continuation.resume) + send(request: request) { + continuation.resume(with: $0) + } } #else guard #available(iOS 15, macOS 12, tvOS 15, watchOS 8, *) else { return try await withCheckedThrowingContinuation { continuation in - send(request: request, completion: continuation.resume) + send(request: request) { + continuation.resume(with: $0) + } } } @@ -45,7 +49,7 @@ extension URLSession: Transport { #endif } - private func send(request: URLRequest, completion: @escaping (Result) -> Void) { + private func send(request: URLRequest, completion: @Sendable @escaping (Result) -> Void) { let task = dataTask(with: request) { data, response, error in if let error = error { completion(.failure(error)) diff --git a/Sources/Buildkite/Resources/REST/ClusterQueues.swift b/Sources/Buildkite/Resources/REST/ClusterQueues.swift new file mode 100644 index 0000000..a12c4c8 --- /dev/null +++ b/Sources/Buildkite/Resources/REST/ClusterQueues.swift @@ -0,0 +1,255 @@ +// +// ClusterQueues.swift +// Buildkite +// +// Created by Aaron Sky on 6/18/23. +// Copyright © 2023 Aaron Sky. All rights reserved. +// + +import Foundation + +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + +extension ClusterQueue { + public enum Resources {} +} + +extension ClusterQueue.Resources { + /// List cluster. + /// + /// Returns a paginated list of an organization's clusters. + public struct List: PaginatedResource, Equatable, Hashable, Sendable { + public typealias Content = [ClusterQueue] + /// organization slug + public var organization: String + /// cluster ID + public var clusterId: UUID + + public var path: String { + "organizations/\(organization)/clusters\(clusterId)/queues" + } + + public init( + organization: String, + clusterId: UUID + ) { + self.organization = organization + self.clusterId = clusterId + } + } + + /// Get a cluster. + public struct Get: Resource, Equatable, Hashable, Sendable { + public typealias Content = ClusterQueue + /// organization slug + public var organization: String + /// cluster ID + public var clusterId: UUID + /// queue ID + public var queueId: UUID + + public var path: String { + "organizations/\(organization)/clusters/\(clusterId)/queues/\(queueId)" + } + + public init( + organization: String, + clusterId: UUID, + queueId: UUID + ) { + self.organization = organization + self.clusterId = clusterId + self.queueId = queueId + } + } + + /// Create a cluster queue. + public struct Create: Resource, Equatable, Hashable, Sendable { + public typealias Content = ClusterQueue + /// organization slug + public var organization: String + /// cluster ID + public var clusterId: UUID + + public var body: Body + + public struct Body: Codable, Equatable, Hashable, Sendable { + /// Key for the queue. + public var key: String + /// Description for the queue. + public var description: String? + + public init( + key: String, + description: String? = nil + ) { + self.key = key + self.description = description + } + } + + public var path: String { + "organizations/\(organization)/clusters/\(clusterId)/queues" + } + + public init( + organization: String, + clusterId: UUID, + body: Body + ) { + self.organization = organization + self.clusterId = clusterId + self.body = body + } + + public func transformRequest(_ request: inout URLRequest) { + request.httpMethod = "POST" + } + } + + /// Update a cluster queue. + public struct Update: Resource, Equatable, Hashable, Sendable { + public typealias Content = ClusterQueue + /// organization slug + public var organization: String + /// cluster ID + public var clusterId: UUID + /// queue ID + public var queueId: UUID + + public var body: Body + + public struct Body: Codable, Equatable, Hashable, Sendable { + /// Description for the cluster. + public var description: String + + } + + public var path: String { + "organizations/\(organization)/clusters/\(clusterId)/queues/\(queueId)" + } + + public init( + organization: String, + clusterId: UUID, + queueId: UUID, + body: Body + ) { + self.organization = organization + self.clusterId = clusterId + self.queueId = queueId + self.body = body + } + + public func transformRequest(_ request: inout URLRequest) { + request.httpMethod = "PUT" + } + } + + /// Delete a cluster queue. + public struct Delete: Resource, Equatable, Hashable, Sendable { + /// organization slug + public var organization: String + /// cluster ID + public var clusterId: UUID + /// queue ID + public var queueId: UUID + + public var path: String { + "organizations/\(organization)/clusters/\(clusterId)/queues/\(queueId)" + } + + public init( + organization: String, + clusterId: UUID, + queueId: UUID + ) { + self.organization = organization + self.clusterId = clusterId + self.queueId = queueId + } + + public func transformRequest(_ request: inout URLRequest) { + request.httpMethod = "DELETE" + } + } + + /// Resume a paused cluster queue. + public struct ResumeDispatch: Resource, Equatable, Hashable, Sendable { + /// organization slug + public var organization: String + /// cluster ID + public var clusterId: UUID + /// queue ID + public var queueId: UUID + + public var path: String { + "organizations/\(organization)/clusters/\(clusterId)/queues/\(queueId)/resume_dispatch" + } + + public init( + organization: String, + clusterId: UUID, + queueId: UUID + ) { + self.organization = organization + self.clusterId = clusterId + self.queueId = queueId + } + + public func transformRequest(_ request: inout URLRequest) { + request.httpMethod = "POST" + } + } +} + +extension Resource where Self == ClusterQueue.Resources.List { + /// List cluster tokens + /// + /// Returns a paginated list of a cluster's tokens. + public static func clusterQueues(in organization: String, clusterId: UUID) -> Self { + Self(organization: organization, clusterId: clusterId) + } +} + +extension Resource where Self == ClusterQueue.Resources.Get { + /// Get a cluster token. + public static func clusterQueue(_ id: UUID, in organization: String, clusterId: UUID) -> Self { + Self(organization: organization, clusterId: clusterId, queueId: id) + } +} + +extension Resource where Self == ClusterQueue.Resources.Create { + /// Create a cluster token. + public static func createClusterQueue(_ body: Self.Body, in organization: String, clusterId: UUID) -> Self { + Self(organization: organization, clusterId: clusterId, body: body) + } +} + +extension Resource where Self == ClusterQueue.Resources.Update { + /// Update a cluster token. + public static func updateClusterQueue( + _ id: UUID, + in organization: String, + clusterId: UUID, + with description: String + ) -> Self { + Self(organization: organization, clusterId: clusterId, queueId: id, body: .init(description: description)) + } +} + +extension Resource where Self == ClusterQueue.Resources.Delete { + /// Delete a cluster token. + public static func deleteClusterQueue(_ id: UUID, in organization: String, clusterId: UUID) -> Self { + Self(organization: organization, clusterId: clusterId, queueId: id) + } +} + +extension Resource where Self == ClusterQueue.Resources.ResumeDispatch { + /// Delete a cluster token. + public static func resumeClusterQueueDispatch(_ id: UUID, in organization: String, clusterId: UUID) -> Self { + Self(organization: organization, clusterId: clusterId, queueId: id) + } +} diff --git a/Sources/Buildkite/Resources/REST/ClusterTokens.swift b/Sources/Buildkite/Resources/REST/ClusterTokens.swift new file mode 100644 index 0000000..e7bdfc1 --- /dev/null +++ b/Sources/Buildkite/Resources/REST/ClusterTokens.swift @@ -0,0 +1,221 @@ +// +// ClusterTokens.swift +// Buildkite +// +// Created by Aaron Sky on 6/18/23. +// Copyright © 2023 Aaron Sky. All rights reserved. +// + +import Foundation + +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + +extension ClusterToken { + public enum Resources {} +} + +extension ClusterToken.Resources { + /// List cluster tokens + /// + /// Returns a paginated list of a cluster's tokens. + public struct List: PaginatedResource, Equatable, Hashable, Sendable { + public typealias Content = [ClusterToken] + /// organization slug + public var organization: String + /// cluster ID + public var clusterId: UUID + + public var path: String { + "organizations/\(organization)/clusters/\(clusterId)/tokens" + } + + public init( + organization: String, + clusterId: UUID + ) { + self.organization = organization + self.clusterId = clusterId + } + } + + /// Get a cluster token. + public struct Get: Resource, Equatable, Hashable, Sendable { + public typealias Content = ClusterToken + /// organization slug + public var organization: String + /// cluster ID + public var clusterId: UUID + /// cluster token ID + public var tokenId: UUID + + public var path: String { + "organizations/\(organization)/clusters/\(clusterId)/tokens/\(tokenId)" + } + + public init( + organization: String, + clusterId: UUID, + tokenId: UUID + ) { + self.organization = organization + self.clusterId = clusterId + self.tokenId = tokenId + } + } + + /// Create a cluster token. + public struct Create: Resource, Equatable, Hashable, Sendable { + public typealias Content = ClusterToken + /// organization slug + public var organization: String + /// cluster ID + public var clusterId: UUID + + public var body: Body + + public struct Body: Codable, Equatable, Hashable, Sendable { + /// Description for the token. + public var description: String + + public init( + description: String + ) { + self.description = description + } + } + + public var path: String { + "organizations/\(organization)/clusters/\(clusterId)/tokens" + } + + public init( + organization: String, + clusterId: UUID, + body: Body + ) { + self.organization = organization + self.clusterId = clusterId + self.body = body + } + + public func transformRequest(_ request: inout URLRequest) { + request.httpMethod = "POST" + } + } + + /// Update a cluster token. + public struct Update: Resource, Equatable, Hashable, Sendable { + public typealias Content = ClusterToken + /// organization slug + public var organization: String + /// cluster ID + public var clusterId: UUID + /// cluster token ID + public var tokenId: UUID + + public var body: Body + + public struct Body: Codable, Equatable, Hashable, Sendable { + /// Description for the token. + public var description: String + + public init( + description: String + ) { + self.description = description + } + } + + public var path: String { + "organizations/\(organization)/clusters/\(clusterId)/tokens/\(tokenId)" + } + + public init( + organization: String, + clusterId: UUID, + tokenId: UUID, + body: Body + ) { + self.organization = organization + self.clusterId = clusterId + self.tokenId = tokenId + self.body = body + } + + public func transformRequest(_ request: inout URLRequest) { + request.httpMethod = "PUT" + } + } + + /// Delete a cluster token. + public struct Delete: Resource, Equatable, Hashable, Sendable { + /// organization slug + public var organization: String + /// cluster ID + public var clusterId: UUID + /// cluster token ID + public var tokenId: UUID + + public var path: String { + "organizations/\(organization)/clusters/\(clusterId)/tokens/\(tokenId)" + } + + public init( + organization: String, + clusterId: UUID, + tokenId: UUID + ) { + self.organization = organization + self.clusterId = clusterId + self.tokenId = tokenId + } + + public func transformRequest(_ request: inout URLRequest) { + request.httpMethod = "DELETE" + } + } +} + +extension Resource where Self == ClusterToken.Resources.List { + /// List cluster tokens + /// + /// Returns a paginated list of a cluster's tokens. + public static func clusterTokens(in organization: String, clusterId: UUID) -> Self { + Self(organization: organization, clusterId: clusterId) + } +} + +extension Resource where Self == ClusterToken.Resources.Get { + /// Get a cluster token. + public static func clusterToken(_ id: UUID, in organization: String, clusterId: UUID) -> Self { + Self(organization: organization, clusterId: clusterId, tokenId: id) + } +} + +extension Resource where Self == ClusterToken.Resources.Create { + /// Create a cluster token. + public static func createClusterToken(with description: String, in organization: String, clusterId: UUID) -> Self { + Self(organization: organization, clusterId: clusterId, body: Self.Body(description: description)) + } +} + +extension Resource where Self == ClusterToken.Resources.Update { + /// Update a cluster token. + public static func updateClusterToken( + _ id: UUID, + in organization: String, + clusterId: UUID, + with description: String + ) -> Self { + Self(organization: organization, clusterId: clusterId, tokenId: id, body: Self.Body(description: description)) + } +} + +extension Resource where Self == ClusterToken.Resources.Delete { + /// Delete a cluster token. + public static func deleteClusterToken(_ id: UUID, in organization: String, clusterId: UUID) -> Self { + Self(organization: organization, clusterId: clusterId, tokenId: id) + } +} diff --git a/Sources/Buildkite/Resources/REST/Clusters.swift b/Sources/Buildkite/Resources/REST/Clusters.swift new file mode 100644 index 0000000..0c65d63 --- /dev/null +++ b/Sources/Buildkite/Resources/REST/Clusters.swift @@ -0,0 +1,232 @@ +// +// Clusters.swift +// Buildkite +// +// Created by Aaron Sky on 6/18/23. +// Copyright © 2023 Aaron Sky. All rights reserved. +// + +import Foundation + +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + +extension Cluster { + public enum Resources {} +} + +extension Cluster.Resources { + /// List cluster. + /// + /// Returns a paginated list of an organization's clusters. + public struct List: PaginatedResource, Equatable, Hashable, Sendable { + public typealias Content = [Cluster] + /// organization slug + public var organization: String + + public var path: String { + "organizations/\(organization)/clusters" + } + + public init( + organization: String + ) { + self.organization = organization + } + } + + /// Get a cluster. + public struct Get: Resource, Equatable, Hashable, Sendable { + public typealias Content = Cluster + /// organization slug + public var organization: String + /// cluster ID + public var clusterId: UUID + + public var path: String { + "organizations/\(organization)/clusters/\(clusterId)" + } + + public init( + organization: String, + clusterId: UUID + ) { + self.organization = organization + self.clusterId = clusterId + } + } + + /// Create a cluster. + public struct Create: Resource, Equatable, Hashable, Sendable { + public typealias Content = Cluster + /// organization slug + public var organization: String + + public var body: Body + + public struct Body: Codable, Equatable, Hashable, Sendable { + /// Name for the cluster. + public var name: String + /// Description for the cluster. + public var description: String? + /// Emoji for the cluster using the emoji syntax. + public var emoji: String? + /// Color hex code for the cluster. + public var color: String? + + public init( + name: String, + description: String? = nil, + emoji: String? = nil, + color: String? = nil + ) { + self.name = name + self.description = description + self.emoji = emoji + self.color = color + } + } + + public var path: String { + "organizations/\(organization)/clusters" + } + + public init( + organization: String, + body: Body + ) { + self.organization = organization + self.body = body + } + + public func transformRequest(_ request: inout URLRequest) { + request.httpMethod = "POST" + } + } + + /// Update a cluster. + public struct Update: Resource, Equatable, Hashable, Sendable { + public typealias Content = Cluster + /// organization slug + public var organization: String + /// cluster ID + public var clusterId: UUID + + public var body: Body + + public struct Body: Codable, Equatable, Hashable, Sendable { + /// Name for the cluster. + public var name: String + /// Description for the cluster. + public var description: String + /// Emoji for the cluster using the emoji syntax. + public var emoji: String + /// Color hex code for the cluster. + public var color: String + /// ID of the queue to set as the cluster's default queue. Agents that connect to the cluster without specifying a queue will accept jobs from this queue. + public var defaultQueueId: UUID + + public init( + name: String, + description: String, + emoji: String, + color: String, + defaultQueueId: UUID + ) { + self.name = name + self.description = description + self.emoji = emoji + self.color = color + self.defaultQueueId = defaultQueueId + } + + private enum CodingKeys: String, CodingKey { + case name + case description + case emoji + case color + case defaultQueueId = "default_queue_id" + } + } + + public var path: String { + "organizations/\(organization)/clusters/\(clusterId)" + } + + public init( + organization: String, + clusterId: UUID, + body: Body + ) { + self.organization = organization + self.clusterId = clusterId + self.body = body + } + + public func transformRequest(_ request: inout URLRequest) { + request.httpMethod = "PUT" + } + } + + /// Delete a cluster. + public struct Delete: Resource, Equatable, Hashable, Sendable { + /// organization slug + public var organization: String + /// cluster ID + public var clusterId: UUID + + public var path: String { + "organizations/\(organization)/clusters/\(clusterId)" + } + + public init( + organization: String, + clusterId: UUID + ) { + self.organization = organization + self.clusterId = clusterId + } + + public func transformRequest(_ request: inout URLRequest) { + request.httpMethod = "DELETE" + } + } +} + +extension Resource where Self == Cluster.Resources.List { + /// List clusters + /// + /// Returns a paginated list of an organization's clusters. + public static func clusters(in organization: String) -> Self { + Self(organization: organization) + } +} + +extension Resource where Self == Cluster.Resources.Get { + /// Get a cluster. + public static func cluster(_ id: UUID, in organization: String) -> Self { + Self(organization: organization, clusterId: id) + } +} + +extension Resource where Self == Cluster.Resources.Create { + /// Create a cluster. + public static func createCluster(_ body: Self.Body, in organization: String) -> Self { + Self(organization: organization, body: body) + } +} + +extension Resource where Self == Cluster.Resources.Update { + /// Update a cluster. + public static func updateCluster(_ id: UUID, in organization: String, with body: Self.Body) -> Self { + Self(organization: organization, clusterId: id, body: body) + } +} + +extension Resource where Self == Cluster.Resources.Delete { + /// Delete a cluster. + public static func deleteCluster(_ id: UUID, in organization: String) -> Self { + Self(organization: organization, clusterId: id) + } +} diff --git a/Sources/Buildkite/Resources/REST/FlakyTests.swift b/Sources/Buildkite/Resources/REST/FlakyTests.swift new file mode 100644 index 0000000..97a4f15 --- /dev/null +++ b/Sources/Buildkite/Resources/REST/FlakyTests.swift @@ -0,0 +1,52 @@ +// +// FlakyTests.swift +// Buildkite +// +// Created by Aaron Sky on 6/18/23. +// Copyright © 2023 Aaron Sky. All rights reserved. +// + +import Foundation + +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + +extension FlakyTest { + /// Resources for performing operations on flaky tests. + public enum Resources {} +} + +extension FlakyTest.Resources { + /// Provides information about tests detected as flaky in a test suite. + /// + /// Returns a paginated list of the flaky tests detected in a test suite. + public struct List: PaginatedResource, Equatable, Hashable, Sendable { + public typealias Content = [FlakyTest] + /// organization slug + public var organization: String + /// test suite slug + public var suite: String + + public var path: String { + "analytics/organizations/\(organization)/suites/\(suite)/flaky-tests" + } + + public init( + organization: String, + suite: String + ) { + self.organization = organization + self.suite = suite + } + } +} + +extension Resource where Self == FlakyTest.Resources.List { + /// List flaky tests + /// + /// Returns a paginated list of a test suite's flaky tests. + public static func flakyTests(in organization: String, suite: String) -> Self { + Self(organization: organization, suite: suite) + } +} diff --git a/Tests/BuildkiteTests/Networking/TransportTests.swift b/Tests/BuildkiteTests/Networking/TransportTests.swift index 7783cee..4f1f605 100644 --- a/Tests/BuildkiteTests/Networking/TransportTests.swift +++ b/Tests/BuildkiteTests/Networking/TransportTests.swift @@ -15,66 +15,80 @@ import XCTest import FoundationNetworking #endif -class TransportTests: XCTestCase { - private func createSession(testCase: MockURLProtocol.Case = .success) -> URLSession { - switch testCase { - case .success: - MockURLProtocol.requestHandler = MockData.mockingSuccessNoContent - case .error: - MockURLProtocol.requestHandler = MockData.mockingError - } +extension URLSession { + fileprivate convenience init( + _ testCase: MockURLProtocol.Case + ) { let config = URLSessionConfiguration.ephemeral config.protocolClasses = [MockURLProtocol.self] - return URLSession(configuration: config) - } - - private class MockURLProtocol: URLProtocol { - enum Case { - case success - case error - } - - typealias RequestHandler = (URLRequest) throws -> (Data, URLResponse) - static var requestHandler: RequestHandler? - - override class func canInit(with request: URLRequest) -> Bool { - return true - } - - override class func canonicalRequest(for request: URLRequest) -> URLRequest { - return request - } - - override func startLoading() { - guard let handler = MockURLProtocol.requestHandler else { - return - } - do { - let (data, response) = try handler(request) - client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) - client?.urlProtocol(self, didLoad: data) - client?.urlProtocolDidFinishLoading(self) - } catch { - client?.urlProtocol(self, didFailWithError: error) - } - } - - override func stopLoading() {} + self.init(configuration: config) } } -// MARK: - Async/Await-based Requests +private final class MockURLProtocol: URLProtocol { + typealias RequestHandler = @Sendable (URLRequest) throws -> (Data, URLResponse) -extension TransportTests { + enum Case { + case success + case error + + var handler: RequestHandler { + switch self { + case .success: + return MockData.mockingSuccessNoContent(for:) + case .error: + return MockData.mockingError(for:) + } + } + } + + override class func canInit(with request: URLRequest) -> Bool { true } + + override class func canonicalRequest(for request: URLRequest) -> URLRequest { request } + + override func startLoading() { + guard let client = client, let handler = Self.testCase(for: request)?.handler else { return } + do { + let (data, response) = try handler(request) + client.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + client.urlProtocol(self, didLoad: data) + client.urlProtocolDidFinishLoading(self) + } catch { + client.urlProtocol(self, didFailWithError: error) + } + } + + override func stopLoading() {} + + static func testCase(for request: URLRequest) -> Case? { + property(forKey: "testCase", in: request) as? Case + } + + static func setTestCase(_ testCase: Case, for request: inout URLRequest) { + let mutableRequest = (request as NSURLRequest) as! NSMutableURLRequest + setProperty( + testCase, + forKey: "testCase", + in: mutableRequest + ) + request = mutableRequest as URLRequest + } +} + +class TransportTests: XCTestCase { func testURLSessionSendRequest() async throws { - let request = URLRequest(url: URL()) - _ = try await createSession().send(request: request) + var request = URLRequest(url: URL()) + let session = URLSession(.success) + MockURLProtocol.setTestCase(.success, for: &request) + _ = try await session.send(request: request) } func testURLSessionSendRequestFailure() async { - let request = URLRequest(url: URL()) + var request = URLRequest(url: URL()) + let session = URLSession(.error) + MockURLProtocol.setTestCase(.error, for: &request) try await XCTAssertThrowsError( - await createSession(testCase: .error).send(request: request) + await session.send(request: request) ) } } diff --git a/Tests/BuildkiteTests/Resources/GraphQL/GraphQLTests.swift b/Tests/BuildkiteTests/Resources/GraphQL/GraphQLTests.swift index 1083f2e..cb534f2 100644 --- a/Tests/BuildkiteTests/Resources/GraphQL/GraphQLTests.swift +++ b/Tests/BuildkiteTests/Resources/GraphQL/GraphQLTests.swift @@ -98,10 +98,11 @@ class GraphQLTests: XCTestCase { func testGraphQLContentGet() throws { try XCTAssertNoThrow(GraphQL.Content.data("hello").get()) - let errors = GraphQL.Errors( - errors: [], - type: nil - ) + let errors = GraphQL + .Errors( + errors: [], + type: nil + ) try XCTAssertThrowsError( GraphQL.Content.errors(errors) .get(), diff --git a/Tests/BuildkiteTests/Resources/REST/ClusterQueuesTests.swift b/Tests/BuildkiteTests/Resources/REST/ClusterQueuesTests.swift new file mode 100644 index 0000000..8dadac9 --- /dev/null +++ b/Tests/BuildkiteTests/Resources/REST/ClusterQueuesTests.swift @@ -0,0 +1,90 @@ +// +// ClusterQueuesTests.swift +// Buildkite +// +// Created by Aaron Sky on 6/18/23. +// Copyright © 2023 Aaron Sky. All rights reserved. +// + +import Foundation +import XCTest + +@testable import Buildkite + +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + +extension ClusterQueue { + init() { + self.init( + id: UUID(), + graphqlId: "", + key: "", + description: "", + url: .init(), + webURL: URL(), + clusterURL: .init(), + dispatchPaused: false, + dispatchPausedBy: nil, + dispatchPausedAt: nil, + dispatchPausedNote: nil, + createdAt: Date(timeIntervalSince1970: 1000), + createdBy: nil + ) + } +} + +class ClusterQueuesTests: XCTestCase { + func testClusterQueuesList() async throws { + let expected = [ClusterQueue(), ClusterQueue()] + let context = try MockContext(content: expected) + + let response = try await context.client.send(.clusterQueues(in: "organization", clusterId: UUID())) + + XCTAssertEqual(expected, response.content) + } + + func testClusterQueuesGet() async throws { + let expected = ClusterQueue() + let context = try MockContext(content: expected) + + let response = try await context.client.send(.clusterQueue(UUID(), in: "organization", clusterId: UUID())) + + XCTAssertEqual(expected, response.content) + } + + func testClusterQueuesCreate() async throws { + let expected = ClusterQueue() + let context = try MockContext(content: expected) + + let response = try await context.client.send( + .createClusterQueue(.init(key: "", description: ""), in: "organization", clusterId: UUID()) + ) + + XCTAssertEqual(expected, response.content) + } + + func testClusterQueuesUpdate() async throws { + let expected = ClusterQueue() + let context = try MockContext(content: expected) + + let response = try await context.client.send( + .updateClusterQueue(UUID(), in: "organization", clusterId: UUID(), with: "") + ) + + XCTAssertEqual(expected, response.content) + } + + func testClusterQueuesDelete() async throws { + let context = MockContext() + + _ = try await context.client.send(.deleteClusterQueue(UUID(), in: "organization", clusterId: UUID())) + } + + func testClusterQueuesResumeDispatch() async throws { + let context = MockContext() + + _ = try await context.client.send(.resumeClusterQueueDispatch(UUID(), in: "organization", clusterId: UUID())) + } +} diff --git a/Tests/BuildkiteTests/Resources/REST/ClusterTokensTests.swift b/Tests/BuildkiteTests/Resources/REST/ClusterTokensTests.swift new file mode 100644 index 0000000..a9bc58f --- /dev/null +++ b/Tests/BuildkiteTests/Resources/REST/ClusterTokensTests.swift @@ -0,0 +1,79 @@ +// +// ClusterTokensTests.swift +// Buildkite +// +// Created by Aaron Sky on 6/18/23. +// Copyright © 2023 Aaron Sky. All rights reserved. +// + +import Foundation +import XCTest + +@testable import Buildkite + +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + +extension ClusterToken { + init() { + self.init( + id: UUID(), + graphqlId: "", + description: "", + url: .init(), + clusterURL: .init(), + createdAt: Date(timeIntervalSince1970: 1000), + createdBy: nil, + token: nil + ) + } +} + +class ClusterTokensTests: XCTestCase { + func testClusterTokensList() async throws { + let expected = [ClusterToken(), ClusterToken()] + let context = try MockContext(content: expected) + + let response = try await context.client.send(.clusterTokens(in: "organization", clusterId: UUID())) + + XCTAssertEqual(expected, response.content) + } + + func testClusterTokensGet() async throws { + let expected = ClusterToken() + let context = try MockContext(content: expected) + + let response = try await context.client.send(.clusterToken(UUID(), in: "organization", clusterId: UUID())) + + XCTAssertEqual(expected, response.content) + } + + func testClusterTokensCreate() async throws { + let expected = ClusterToken() + let context = try MockContext(content: expected) + + let response = try await context.client.send( + .createClusterToken(with: "", in: "organization", clusterId: UUID()) + ) + + XCTAssertEqual(expected, response.content) + } + + func testClusterTokensUpdate() async throws { + let expected = ClusterToken() + let context = try MockContext(content: expected) + + let response = try await context.client.send( + .updateClusterToken(UUID(), in: "organization", clusterId: UUID(), with: "") + ) + + XCTAssertEqual(expected, response.content) + } + + func testClusterTokensDelete() async throws { + let context = MockContext() + + _ = try await context.client.send(.deleteClusterToken(UUID(), in: "organization", clusterId: UUID())) + } +} diff --git a/Tests/BuildkiteTests/Resources/REST/ClustersTests.swift b/Tests/BuildkiteTests/Resources/REST/ClustersTests.swift new file mode 100644 index 0000000..feafadd --- /dev/null +++ b/Tests/BuildkiteTests/Resources/REST/ClustersTests.swift @@ -0,0 +1,88 @@ +// +// ClustersTests.swift +// Buildkite +// +// Created by Aaron Sky on 6/18/23. +// Copyright © 2023 Aaron Sky. All rights reserved. +// + +import Foundation +import XCTest + +@testable import Buildkite + +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + +extension Cluster { + init() { + self.init( + id: UUID(), + graphqlId: "", + defaultQueueId: UUID(), + name: "", + description: "", + emoji: "", + color: "", + url: .init(), + webURL: URL(), + queuesURL: .init(), + defaultQueueURL: .init(), + createdAt: Date(timeIntervalSince1970: 1000), + createdBy: nil + ) + } +} + +class ClustersTests: XCTestCase { + func testClustersList() async throws { + let expected = [Cluster(), Cluster()] + let context = try MockContext(content: expected) + + let response = try await context.client.send(.clusters(in: "buildkite")) + + XCTAssertEqual(expected, response.content) + } + + func testClustersGet() async throws { + let expected = Cluster() + let context = try MockContext(content: expected) + + let response = try await context.client.send(.cluster(UUID(), in: "organization")) + + XCTAssertEqual(expected, response.content) + } + + func testClustersCreate() async throws { + let expected = Cluster() + let context = try MockContext(content: expected) + + let response = try await context.client.send( + .createCluster(.init(name: "", description: nil, emoji: nil, color: nil), in: "organization") + ) + + XCTAssertEqual(expected, response.content) + } + + func testClustersUpdate() async throws { + let expected = Cluster() + let context = try MockContext(content: expected) + + let response = try await context.client.send( + .updateCluster( + UUID(), + in: "organization", + with: .init(name: "", description: "", emoji: "", color: "", defaultQueueId: UUID()) + ) + ) + + XCTAssertEqual(expected, response.content) + } + + func testClustersDelete() async throws { + let context = MockContext() + + _ = try await context.client.send(.deleteCluster(UUID(), in: "organization")) + } +} diff --git a/Tests/BuildkiteTests/Resources/REST/FlakyTestsTests.swift b/Tests/BuildkiteTests/Resources/REST/FlakyTestsTests.swift new file mode 100644 index 0000000..e2a9a21 --- /dev/null +++ b/Tests/BuildkiteTests/Resources/REST/FlakyTestsTests.swift @@ -0,0 +1,43 @@ +// +// PipelinesTests.swift +// Buildkite +// +// Created by Aaron Sky on 5/4/20. +// Copyright © 2020 Aaron Sky. All rights reserved. +// + +import Foundation +import XCTest + +@testable import Buildkite + +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + +extension FlakyTest { + init() { + self.init( + id: UUID(), + graphqlId: "", + webURL: URL(), + scope: "Test#failure", + name: "failure", + location: "./file:30", + fileName: "./file", + instances: 10, + mostRecentInstanceAt: Date(timeIntervalSince1970: 1000) + ) + } +} + +class FlakyTestsTests: XCTestCase { + func testFlakyTestsList() async throws { + let expected = [FlakyTest(), FlakyTest()] + let context = try MockContext(content: expected) + + let response = try await context.client.send(.flakyTests(in: "buildkite", suite: "my-suite")) + + XCTAssertEqual(expected, response.content) + } +} diff --git a/Tests/BuildkiteTests/Utilities/MockData.swift b/Tests/BuildkiteTests/Utilities/MockData.swift index ed426d3..b3477f4 100644 --- a/Tests/BuildkiteTests/Utilities/MockData.swift +++ b/Tests/BuildkiteTests/Utilities/MockData.swift @@ -103,19 +103,23 @@ enum MockData { } extension MockData { + @Sendable static func mockingSuccess(with content: Content, url: URL) throws -> (Data, URLResponse) { let data = try encoder.encode(content) return (data, urlResponse(for: url, status: .ok)) } + @Sendable static func mockingSuccessNoContent(url: URL) -> (Data, URLResponse) { return (Data(), urlResponse(for: url, status: .ok)) } + @Sendable static func mockingIncompatibleResponse(for url: URL) -> (Data, URLResponse) { return (Data(), urlResponse(for: url, rawStatus: -128)) } + @Sendable static func mockingUnsuccessfulResponse(for url: URL) -> (Data, URLResponse) { let json = """ {"message":"not found","errors": ["go away"]} @@ -125,18 +129,22 @@ extension MockData { return (json, urlResponse(for: url, status: .notFound)) } + @Sendable static func mockingSuccessNoContent(for request: URLRequest) throws -> (Data, URLResponse) { return mockingSuccessNoContent(url: request.url!) } + @Sendable static func mockingError(for request: URLRequest) throws -> (Data, URLResponse) { throw URLError(.notConnectedToInternet) } + @Sendable static func mockingError(_ error: Error) throws -> (Data, URLResponse) { throw error } + @Sendable static func mockingUnrecognizedBuildkiteError(for url: URL) -> (Data, URLResponse) { let json = """ {"message":-1000} @@ -145,10 +153,12 @@ extension MockData { return (json, urlResponse(for: url, status: .notFound)) } + @Sendable private static func urlResponse(for url: URL, status: StatusCode) -> URLResponse { urlResponse(for: url, rawStatus: status.rawValue) } + @Sendable private static func urlResponse(for url: URL, rawStatus status: Int) -> URLResponse { HTTPURLResponse( url: url, diff --git a/Tests/BuildkiteTests/Utilities/MockTransport.swift b/Tests/BuildkiteTests/Utilities/MockTransport.swift index d933490..868aacd 100644 --- a/Tests/BuildkiteTests/Utilities/MockTransport.swift +++ b/Tests/BuildkiteTests/Utilities/MockTransport.swift @@ -13,7 +13,7 @@ import Foundation import FoundationNetworking #endif -final class MockTransport { +actor MockTransport { enum Error: Swift.Error { case tooManyRequests }