diff --git a/Sources/App/Commands/TriggerBuilds.swift b/Sources/App/Commands/TriggerBuilds.swift index 4f32270cc..ff361df2c 100644 --- a/Sources/App/Commands/TriggerBuilds.swift +++ b/Sources/App/Commands/TriggerBuilds.swift @@ -85,9 +85,7 @@ struct TriggerBuildsCommand: AsyncCommand { } do { - try await triggerBuilds(on: context.application.db, - client: context.application.client, - mode: mode) + try await triggerBuilds(on: context.application.db, mode: mode) } catch { Current.logger().critical("\(error)") } @@ -122,9 +120,7 @@ extension TriggerBuildsCommand { /// - client: `Client` used for http request /// - parameter: `BuildTriggerCommand.Parameter` holding either a list of package ids /// or a fetch limit for candidate selection. -func triggerBuilds(on database: Database, - client: Client, - mode: TriggerBuildsCommand.Mode) async throws { +func triggerBuilds(on database: Database, mode: TriggerBuildsCommand.Mode) async throws { @Dependency(\.environment) var environment let start = DispatchTime.now().uptimeNanoseconds @@ -138,15 +134,12 @@ func triggerBuilds(on database: Database, AppMetrics.buildCandidatesCount?.set(candidates.count) let limitedCandidates = Array(candidates.prefix(limit)) - try await triggerBuilds(on: database, - client: client, - packages: limitedCandidates) + try await triggerBuilds(on: database, packages: limitedCandidates) AppMetrics.buildTriggerDurationSeconds?.time(since: start) case let .packageId(id, force): Current.logger().info("Triggering builds (packageID: \(id)) ...") try await triggerBuilds(on: database, - client: client, packages: [id], force: force) AppMetrics.buildTriggerDurationSeconds?.time(since: start) @@ -159,9 +152,7 @@ func triggerBuilds(on database: Database, Current.logger().error("Failed to create trigger.") return } - try await triggerBuildsUnchecked(on: database, - client: client, - triggers: [trigger]) + try await triggerBuildsUnchecked(on: database, triggers: [trigger]) } } @@ -175,7 +166,6 @@ func triggerBuilds(on database: Database, /// - packages: list of `Package.Id`s to trigger /// - force: do not check pipeline capacity and ignore downscaling func triggerBuilds(on database: Database, - client: Client, packages: [Package.Id], force: Bool = false) async throws { @Dependency(\.environment) var environment @@ -191,15 +181,15 @@ func triggerBuilds(on database: Database, for package in packages { group.addTask { let triggerInfo = try await findMissingBuilds(database, packageId: package) - try await triggerBuildsUnchecked(on: database, client: client, triggers: triggerInfo) + try await triggerBuildsUnchecked(on: database, triggers: triggerInfo) } } } } let getStatusCount = buildSystem.getStatusCount - async let pendingJobsTask = getStatusCount(client, .pending) - async let runningJobsTask = getStatusCount(client, .running) + async let pendingJobsTask = getStatusCount(.pending) + async let runningJobsTask = getStatusCount(.running) let pendingJobs = try await pendingJobsTask let runningJobs = try await runningJobsTask @@ -237,9 +227,7 @@ func triggerBuilds(on database: Database, let triggeredJobCount = triggers.reduce(0) { $0 + $1.buildPairs.count } await newJobs.withValue { $0 += triggeredJobCount } - try await triggerBuildsUnchecked(on: database, - client: client, - triggers: triggers) + try await triggerBuildsUnchecked(on: database, triggers: triggers) } } } @@ -255,9 +243,7 @@ func triggerBuilds(on database: Database, /// - database: `Database` handle used for database access /// - client: `Client` used for http request /// - triggers: trigger information for builds to trigger -func triggerBuildsUnchecked(on database: Database, - client: Client, - triggers: [BuildTriggerInfo]) async throws { +func triggerBuildsUnchecked(on database: Database, triggers: [BuildTriggerInfo]) async throws { await withThrowingTaskGroup(of: Void.self) { group in for trigger in triggers { if let packageName = trigger.packageName, let reference = trigger.reference { @@ -272,7 +258,6 @@ func triggerBuildsUnchecked(on database: Database, let buildId = Build.Id() let response = try await Build.trigger(database: database, - client: client, buildId: buildId, isDocBuild: trigger.docPairs.contains(pair), platform: pair.platform, diff --git a/Sources/App/Core/Dependencies/BuildSystemClient.swift b/Sources/App/Core/Dependencies/BuildSystemClient.swift index 02abdb17c..7b9fbb9f2 100644 --- a/Sources/App/Core/Dependencies/BuildSystemClient.swift +++ b/Sources/App/Core/Dependencies/BuildSystemClient.swift @@ -14,16 +14,12 @@ import Dependencies import DependenciesMacros -import Vapor @DependencyClient struct BuildSystemClient { -#warning("remove client") - var getStatusCount: @Sendable (_ client: Client, _ status: Gitlab.Builder.Status) async throws -> Int -#warning("remove client") - var triggerBuild: @Sendable (_ client: Client, - _ buildId: Build.Id, + var getStatusCount: @Sendable (_ status: Gitlab.Builder.Status) async throws -> Int + var triggerBuild: @Sendable (_ buildId: Build.Id, _ cloneURL: String, _ isDocBuild: Bool, _ platform: Build.Platform, @@ -36,16 +32,14 @@ struct BuildSystemClient { extension BuildSystemClient: DependencyKey { static var liveValue: Self { .init( - getStatusCount: { client, status in - try await Gitlab.Builder.getStatusCount(client: client, - status: status, + getStatusCount: { status in + try await Gitlab.Builder.getStatusCount(status: status, page: 1, pageSize: 100, maxPageCount: 5) }, - triggerBuild: { client, buildId, cloneURL, isDocBuild, platform, ref, swiftVersion, versionID in - try await Gitlab.Builder.triggerBuild(client: client, - buildId: buildId, + triggerBuild: { buildId, cloneURL, isDocBuild, platform, ref, swiftVersion, versionID in + try await Gitlab.Builder.triggerBuild(buildId: buildId, cloneURL: cloneURL, isDocBuild: isDocBuild, platform: platform, diff --git a/Sources/App/Core/Dependencies/HTTPClient.swift b/Sources/App/Core/Dependencies/HTTPClient.swift index af00e6d25..96cdcd94f 100644 --- a/Sources/App/Core/Dependencies/HTTPClient.swift +++ b/Sources/App/Core/Dependencies/HTTPClient.swift @@ -68,6 +68,9 @@ extension HTTPClient: DependencyKey { } func get(url: String) async throws -> Response { try await get(url: url, headers: .init()) } + func post(url: String, body: Data?) async throws -> Response { + try await post(url: url, headers: .init(), body: body) + } } @@ -112,6 +115,7 @@ extension HTTPClient.Response { static var notFound: Self { .init(status: .notFound) } static var tooManyRequests: Self { .init(status: .tooManyRequests) } static var ok: Self { .init(status: .ok) } + static var created: Self { .init(status: .created) } static func ok(body: String, headers: HTTPHeaders = .init()) -> Self { .init(status: .ok, headers: headers, body: .init(string: body)) @@ -121,5 +125,10 @@ extension HTTPClient.Response { let data = try JSONEncoder().encode(value) return .init(status: .ok, headers: headers, body: .init(data: data)) } + + static func created(jsonEncode value: T, headers: HTTPHeaders = .init()) throws -> Self { + let data = try JSONEncoder().encode(value) + return .init(status: .created, headers: headers, body: .init(data: data)) + } } #endif diff --git a/Sources/App/Core/Gitlab.swift b/Sources/App/Core/Gitlab.swift index 6225ea05c..baca2a87a 100644 --- a/Sources/App/Core/Gitlab.swift +++ b/Sources/App/Core/Gitlab.swift @@ -23,15 +23,16 @@ enum Gitlab { enum Error: LocalizedError { case missingConfiguration(String) case missingToken - case requestFailed(HTTPStatus, URI) + case noBody + case requestFailed(status: HTTPStatus, url: String) } - static let decoder: JSONDecoder = { + static var decoder: JSONDecoder { let d = JSONDecoder() d.keyDecodingStrategy = .convertFromSnakeCase d.dateDecodingStrategy = .formatted(DateFormatter.iso8601Full) return d - }() + } } @@ -69,8 +70,7 @@ extension Gitlab.Builder { } } - static func triggerBuild(client: Client, - buildId: Build.Id, + static func triggerBuild(buildId: Build.Id, cloneURL: String, isDocBuild: Bool, platform: Build.Platform, @@ -78,6 +78,7 @@ extension Gitlab.Builder { swiftVersion: SwiftVersion, versionID: Version.Id) async throws -> Build.TriggerResponse { @Dependency(\.environment) var environment + @Dependency(\.httpClient) var httpClient guard let pipelineToken = environment.gitlabPipelineToken(), let builderToken = environment.builderToken() @@ -88,31 +89,33 @@ extension Gitlab.Builder { } let timeout = environment.buildTimeout() + (isDocBuild ? 5 : 0) - let uri: URI = .init(string: "\(projectURL)/trigger/pipeline") - let response = try await client - .post(uri) { req in - let data = PostDTO( - token: pipelineToken, - ref: branch, - variables: [ - "API_BASEURL": SiteURL.apiBaseURL, - "AWS_DOCS_BUCKET": awsDocsBucket, - "BUILD_ID": buildId.uuidString, - "BUILD_PLATFORM": platform.rawValue, - "BUILDER_TOKEN": builderToken, - "CLONE_URL": cloneURL, - "REFERENCE": "\(reference)", - "SWIFT_VERSION": "\(swiftVersion.major).\(swiftVersion.minor)", - "TIMEOUT": "\(timeout)m", - "VERSION_ID": versionID.uuidString - ]) - try req.query.encode(data) - } + let dto = PostDTO( + token: pipelineToken, + ref: branch, + variables: [ + "API_BASEURL": SiteURL.apiBaseURL, + "AWS_DOCS_BUCKET": awsDocsBucket, + "BUILD_ID": buildId.uuidString, + "BUILD_PLATFORM": platform.rawValue, + "BUILDER_TOKEN": builderToken, + "CLONE_URL": cloneURL, + "REFERENCE": "\(reference)", + "SWIFT_VERSION": "\(swiftVersion.major).\(swiftVersion.minor)", + "TIMEOUT": "\(timeout)m", + "VERSION_ID": versionID.uuidString + ] + ) + let body = try URLEncodedFormEncoder().encode(dto) + let response = try await httpClient.post( + url: "\(projectURL)/trigger/pipeline", + headers: .contentTypeFormURLEncoded, + body: Data(body.utf8) + ) + do { - let res = Build.TriggerResponse( - status: response.status, - webUrl: try response.content.decode(Response.self).webUrl - ) + guard let body = response.body else { throw Gitlab.Error.noBody } + let webUrl = try JSONDecoder().decode(Response.self, from: body).webUrl + let res = Build.TriggerResponse(status: response.status, webUrl: webUrl) Current.logger().info("Triggered build: \(res.webUrl)") return res } catch { @@ -154,30 +157,31 @@ extension Gitlab.Builder { } // https://docs.gitlab.com/ee/api/pipelines.html - static func fetchPipelines(client: Client, - status: Status, + static func fetchPipelines(status: Status, page: Int, pageSize: Int = 20) async throws -> [Pipeline] { @Dependency(\.environment) var environment + @Dependency(\.httpClient) var httpClient guard let apiToken = environment.gitlabApiToken() else { throw Gitlab.Error.missingToken } + let url = "\(projectURL)/pipelines?status=\(status)&page=\(page)&per_page=\(pageSize)" - let uri: URI = .init(string: "\(projectURL)/pipelines?status=\(status)&page=\(page)&per_page=\(pageSize)") - let response = try await client.get(uri, headers: .bearer(apiToken)) + let response = try await httpClient.get(url: url, headers: .bearer(apiToken)) - guard response.status == .ok else { throw Gitlab.Error.requestFailed(response.status, uri) } + guard response.status == .ok else { + throw Gitlab.Error.requestFailed(status: response.status, url: url) + } + guard let body = response.body else { throw Gitlab.Error.noBody } - return try response.content.decode([Pipeline].self, using: Gitlab.decoder) + return try Gitlab.decoder.decode([Pipeline].self, from: body) } - static func getStatusCount(client: Client, - status: Status, + static func getStatusCount(status: Status, page: Int = 1, pageSize: Int = 20, maxPageCount: Int = 5) async throws -> Int { - let count = try await fetchPipelines(client: client, status: status, page: page, pageSize: pageSize).count + let count = try await fetchPipelines(status: status, page: page, pageSize: pageSize).count if count == pageSize && page < maxPageCount { - let statusCount = try await getStatusCount(client: client, - status: status, + let statusCount = try await getStatusCount(status: status, page: page + 1, pageSize: pageSize, maxPageCount: maxPageCount) @@ -205,6 +209,10 @@ private extension HTTPHeaders { static func bearer(_ token: String) -> Self { .init([("Authorization", "Bearer \(token)")]) } + + static var contentTypeFormURLEncoded: Self { + .init([("Content-Type", "application/x-www-form-urlencoded")]) + } } diff --git a/Sources/App/Models/Build.swift b/Sources/App/Models/Build.swift index 0cee9f3c4..e9246cfdb 100644 --- a/Sources/App/Models/Build.swift +++ b/Sources/App/Models/Build.swift @@ -175,7 +175,6 @@ extension Build { } static func trigger(database: Database, - client: Client, buildId: Build.Id, isDocBuild: Bool, platform: Build.Platform, @@ -189,8 +188,7 @@ extension Build { .unwrap(or: Abort(.notFound)) @Dependency(\.buildSystem) var buildSystem - return try await buildSystem.triggerBuild(client, - buildId, + return try await buildSystem.triggerBuild(buildId, version.package.url, isDocBuild, platform, diff --git a/Tests/AppTests/BuildTests.swift b/Tests/AppTests/BuildTests.swift index 5d4b1b55b..86a927312 100644 --- a/Tests/AppTests/BuildTests.swift +++ b/Tests/AppTests/BuildTests.swift @@ -129,126 +129,96 @@ class BuildTests: AppTestCase { } func test_trigger() async throws { + let buildId = UUID.id0 + let versionId = UUID.id1 + let called = QueueIsolated(false) try await withDependencies { $0.environment.awsDocsBucket = { "awsDocsBucket" } $0.environment.builderToken = { "builder token" } $0.environment.buildTimeout = { 10 } $0.environment.gitlabPipelineToken = { "pipeline token" } $0.environment.siteURL = { "http://example.com" } - // Use live dependency but replace actual client with a mock so we can - // assert on the details being sent without actually making a request - $0.buildSystem.triggerBuild = { @Sendable client, buildId, cloneURL, isDocBuild, platform, ref, swiftVersion, versionID in - try await Gitlab.Builder.triggerBuild(client: client, - buildId: buildId, - cloneURL: cloneURL, - isDocBuild: isDocBuild, - platform: platform, - reference: ref, - swiftVersion: swiftVersion, - versionID: versionID) + $0.buildSystem.triggerBuild = BuildSystemClient.liveValue.triggerBuild + $0.httpClient.post = { @Sendable _, _, body in + called.setValue(true) + let body = try XCTUnwrap(body) + XCTAssertEqual( + try URLEncodedFormDecoder().decode(Gitlab.Builder.PostDTO.self, from: body), + .init(token: "pipeline token", + ref: "main", + variables: [ + "API_BASEURL": "http://example.com/api", + "AWS_DOCS_BUCKET": "awsDocsBucket", + "BUILD_ID": buildId.uuidString, + "BUILD_PLATFORM": "macos-xcodebuild", + "BUILDER_TOKEN": "builder token", + "CLONE_URL": "1", + "REFERENCE": "main", + "SWIFT_VERSION": "5.2", + "TIMEOUT": "10m", + "VERSION_ID": versionId.uuidString, + ]) + ) + return try .created(jsonEncode: Gitlab.Builder.Response(webUrl: "http://web_url")) } } operation: { // setup let p = try await savePackage(on: app.db, "1") - let v = try Version(package: p, reference: .branch("main")) + let v = try Version(id: versionId, package: p, reference: .branch("main")) try await v.save(on: app.db) - let buildId = UUID() - let versionID = try XCTUnwrap(v.id) - - var called = false - let client = MockClient { req, res in - called = true - res.status = .created - try? res.content.encode( - Gitlab.Builder.Response.init(webUrl: "http://web_url") - ) - // validate request data - XCTAssertEqual(try? req.query.decode(Gitlab.Builder.PostDTO.self), - Gitlab.Builder.PostDTO( - token: "pipeline token", - ref: "main", - variables: [ - "API_BASEURL": "http://example.com/api", - "AWS_DOCS_BUCKET": "awsDocsBucket", - "BUILD_ID": buildId.uuidString, - "BUILD_PLATFORM": "macos-xcodebuild", - "BUILDER_TOKEN": "builder token", - "CLONE_URL": "1", - "REFERENCE": "main", - "SWIFT_VERSION": "5.2", - "TIMEOUT": "10m", - "VERSION_ID": versionID.uuidString, - ])) - } // MUT let res = try await Build.trigger(database: app.db, - client: client, buildId: buildId, isDocBuild: false, platform: .macosXcodebuild, swiftVersion: .init(5, 2, 4), - versionId: versionID) + versionId: versionId) // validate - XCTAssertTrue(called) + XCTAssertTrue(called.value) XCTAssertEqual(res.status, .created) } } func test_trigger_isDocBuild() async throws { + // Same test as test_trigger above, except we trigger with isDocBuild: true + // and expect a 15m TIMEOUT instead of 10m + let buildId = UUID.id0 + let versionId = UUID.id1 + let called = QueueIsolated(false) try await withDependencies { $0.environment.awsDocsBucket = { "awsDocsBucket" } $0.environment.builderToken = { "builder token" } $0.environment.buildTimeout = { 10 } $0.environment.gitlabPipelineToken = { "pipeline token" } $0.environment.siteURL = { "http://example.com" } - // Use live dependency but replace actual client with a mock so we can - // assert on the details being sent without actually making a request - $0.buildSystem.triggerBuild = { @Sendable client, buildId, cloneURL, isDocBuild, platform, ref, swiftVersion, versionID in - try await Gitlab.Builder.triggerBuild(client: client, - buildId: buildId, - cloneURL: cloneURL, - isDocBuild: isDocBuild, - platform: platform, - reference: ref, - swiftVersion: swiftVersion, - versionID: versionID) + $0.buildSystem.triggerBuild = BuildSystemClient.liveValue.triggerBuild + $0.httpClient.post = { @Sendable _, _, body in + called.setValue(true) + let body = try XCTUnwrap(body) + // only test the TIMEOUT value, the rest is already tested in `test_trigger` above + let response = try? URLEncodedFormDecoder().decode(Gitlab.Builder.PostDTO.self, from: body) + XCTAssertNotNil(response) + XCTAssertEqual(response?.variables["TIMEOUT"], "15m") + return try .created(jsonEncode: Gitlab.Builder.Response(webUrl: "http://web_url")) } } operation: { - // Same test as test_trigger above, except we trigger with isDocBuild: true - // and expect a 15m TIMEOUT instead of 10m // setup let p = try await savePackage(on: app.db, "1") - let v = try Version(package: p, reference: .branch("main")) + let v = try Version(id: versionId, package: p, reference: .branch("main")) try await v.save(on: app.db) - let buildId = UUID() - let versionID = try XCTUnwrap(v.id) - - var called = false - let client = MockClient { req, res in - called = true - res.status = .created - try? res.content.encode( - Gitlab.Builder.Response.init(webUrl: "http://web_url") - ) - // only test the TIMEOUT value, the rest is already tested in `test_trigger` above - let response = try? req.query.decode(Gitlab.Builder.PostDTO.self) - XCTAssertNotNil(response) - XCTAssertEqual(response?.variables["TIMEOUT"], "15m") - } // MUT let res = try await Build.trigger(database: app.db, - client: client, buildId: buildId, isDocBuild: true, platform: .macosXcodebuild, swiftVersion: .init(5, 2, 4), - versionId: versionID) + versionId: versionId) // validate - XCTAssertTrue(called) + XCTAssertTrue(called.value) XCTAssertEqual(res.status, .created) } } diff --git a/Tests/AppTests/BuildTriggerTests.swift b/Tests/AppTests/BuildTriggerTests.swift index 8e284474c..5c6f5193c 100644 --- a/Tests/AppTests/BuildTriggerTests.swift +++ b/Tests/AppTests/BuildTriggerTests.swift @@ -341,35 +341,22 @@ class BuildTriggerTests: AppTestCase { } func test_triggerBuildsUnchecked() async throws { + let queries = QueueIsolated<[Gitlab.Builder.PostDTO]>([]) try await withDependencies { $0.environment.awsDocsBucket = { "awsDocsBucket" } $0.environment.builderToken = { "builder token" } $0.environment.buildTimeout = { 10 } $0.environment.gitlabPipelineToken = { "pipeline token" } $0.environment.siteURL = { "http://example.com" } - // Use live dependency but replace actual client with a mock so we can - // assert on the details being sent without actually making a request - $0.buildSystem.triggerBuild = { @Sendable client, buildId, cloneURL, isDocBuild, platform, ref, swiftVersion, versionID in - try await Gitlab.Builder.triggerBuild(client: client, - buildId: buildId, - cloneURL: cloneURL, - isDocBuild: isDocBuild, - platform: platform, - reference: ref, - swiftVersion: swiftVersion, - versionID: versionID) + $0.buildSystem.triggerBuild = BuildSystemClient.liveValue.triggerBuild + $0.httpClient.post = { @Sendable _, _, body in + let body = try XCTUnwrap(body) + let query = try URLEncodedFormDecoder().decode(Gitlab.Builder.PostDTO.self, from: body) + queries.withValue { $0.append(query) } + return .created(webUrl: "http://web_url") } } operation: { // setup - let queries = QueueIsolated<[Gitlab.Builder.PostDTO]>([]) - let client = MockClient { req, res in - guard let query = try? req.query.decode(Gitlab.Builder.PostDTO.self) else { return } - queries.withValue { $0.append(query) } - try? res.content.encode( - Gitlab.Builder.Response.init(webUrl: "http://web_url") - ) - } - let versionId = UUID() do { // save package with partially completed builds let p = Package(id: UUID(), url: "2") @@ -381,9 +368,7 @@ class BuildTriggerTests: AppTestCase { buildPairs: [BuildPair(.iOS, .v1)])!] // MUT - try await triggerBuildsUnchecked(on: app.db, - client: client, - triggers: triggers) + try await triggerBuildsUnchecked(on: app.db, triggers: triggers) // validate // ensure Gitlab requests go out @@ -402,6 +387,8 @@ class BuildTriggerTests: AppTestCase { } func test_triggerBuildsUnchecked_supported() async throws { + // Explicitly test the full range of all currently triggered platforms and swift versions + let queries = QueueIsolated<[Gitlab.Builder.PostDTO]>([]) try await withDependencies { $0.environment.awsDocsBucket = { "awsDocsBucket" } $0.environment.builderToken = { "builder token" } @@ -409,30 +396,15 @@ class BuildTriggerTests: AppTestCase { $0.environment.buildTriggerAllowList = { [] } $0.environment.gitlabPipelineToken = { "pipeline token" } $0.environment.siteURL = { "http://example.com" } - // Use live dependency but replace actual client with a mock so we can - // assert on the details being sent without actually making a request - $0.buildSystem.triggerBuild = { @Sendable client, buildId, cloneURL, isDocBuild, platform, ref, swiftVersion, versionID in - try await Gitlab.Builder.triggerBuild(client: client, - buildId: buildId, - cloneURL: cloneURL, - isDocBuild: isDocBuild, - platform: platform, - reference: ref, - swiftVersion: swiftVersion, - versionID: versionID) + $0.buildSystem.triggerBuild = BuildSystemClient.liveValue.triggerBuild + $0.httpClient.post = { @Sendable _, _, body in + let body = try XCTUnwrap(body) + let query = try URLEncodedFormDecoder().decode(Gitlab.Builder.PostDTO.self, from: body) + queries.withValue { $0.append(query) } + return .created(webUrl: "http://web_url") } } operation: { - // Explicitly test the full range of all currently triggered platforms and swift versions // setup - let queries = QueueIsolated<[Gitlab.Builder.PostDTO]>([]) - let client = MockClient { req, res in - guard let query = try? req.query.decode(Gitlab.Builder.PostDTO.self) else { return } - queries.withValue { $0.append(query) } - try? res.content.encode( - Gitlab.Builder.Response.init(webUrl: "http://web_url") - ) - } - let pkgId = UUID() let versionId = UUID() do { // save package with partially completed builds @@ -444,9 +416,7 @@ class BuildTriggerTests: AppTestCase { let triggers = try await findMissingBuilds(app.db, packageId: pkgId) // MUT - try await triggerBuildsUnchecked(on: app.db, - client: client, - triggers: triggers) + try await triggerBuildsUnchecked(on: app.db, triggers: triggers) // validate // ensure Gitlab requests go out @@ -483,45 +453,32 @@ class BuildTriggerTests: AppTestCase { } func test_triggerBuildsUnchecked_build_exists() async throws { + // Tests error handling when a build record already exists and `create` raises a + // uq:builds.version_id+builds.platform+builds.swift_version+v2 + // unique key violation. + // The only way this can currently happen is by running a manual trigger command + // from a container in the dev or prod envs (docker exec ...), like so: + // ./Run trigger-builds -v {version-id} -p macos-spm -s 5.7 + // This is how we routinely manually trigger doc-related builds. + // This test ensures that the build record is updated in this case rather than + // being completely ignored because the command errors out. + // See https://github.com/SwiftPackageIndex/SwiftPackageIndex-Server/issues/2237 + let queries = QueueIsolated<[Gitlab.Builder.PostDTO]>([]) try await withDependencies { $0.environment.awsDocsBucket = { "awsDocsBucket" } $0.environment.builderToken = { "builder token" } $0.environment.buildTimeout = { 10 } $0.environment.gitlabPipelineToken = { "pipeline token" } $0.environment.siteURL = { "http://example.com" } - // Use live dependency but replace actual client with a mock so we can - // assert on the details being sent without actually making a request - $0.buildSystem.triggerBuild = { @Sendable client, buildId, cloneURL, isDocBuild, platform, ref, swiftVersion, versionID in - try await Gitlab.Builder.triggerBuild(client: client, - buildId: buildId, - cloneURL: cloneURL, - isDocBuild: isDocBuild, - platform: platform, - reference: ref, - swiftVersion: swiftVersion, - versionID: versionID) + $0.buildSystem.triggerBuild = BuildSystemClient.liveValue.triggerBuild + $0.httpClient.post = { @Sendable _, _, body in + let body = try XCTUnwrap(body) + let query = try URLEncodedFormDecoder().decode(Gitlab.Builder.PostDTO.self, from: body) + queries.withValue { $0.append(query) } + return .created(webUrl: "http://web_url") } } operation: { - // Tests error handling when a build record already exists and `create` raises a - // uq:builds.version_id+builds.platform+builds.swift_version+v2 - // unique key violation. - // The only way this can currently happen is by running a manual trigger command - // from a container in the dev or prod envs (docker exec ...), like so: - // ./Run trigger-builds -v {version-id} -p macos-spm -s 5.7 - // This is how we routinely manually trigger doc-related builds. - // This test ensures that the build record is updated in this case rather than - // being completely ignored because the command errors out. - // See https://github.com/SwiftPackageIndex/SwiftPackageIndex-Server/issues/2237 // setup - let queries = QueueIsolated<[Gitlab.Builder.PostDTO]>([]) - let client = MockClient { req, res in - guard let query = try? req.query.decode(Gitlab.Builder.PostDTO.self) else { return } - queries.withValue { $0.append(query) } - try? res.content.encode( - Gitlab.Builder.Response.init(webUrl: "http://web_url") - ) - } - let buildId = UUID() let versionId = UUID() do { // save package with a build that we re-trigger @@ -540,9 +497,7 @@ class BuildTriggerTests: AppTestCase { buildPairs: [BuildPair(.macosSpm, .v3)])!] // MUT - try await triggerBuildsUnchecked(on: app.db, - client: client, - triggers: triggers) + try await triggerBuildsUnchecked(on: app.db, triggers: triggers) // validate // triggerBuildsUnchecked always creates a new buildId, @@ -562,6 +517,8 @@ class BuildTriggerTests: AppTestCase { } func test_triggerBuilds_checked() async throws { + // Ensure we respect the pipeline limit when triggering builds + let triggerCount = QueueIsolated(0) try await withDependencies { $0.environment.allowBuildTriggers = { true } $0.environment.awsDocsBucket = { "awsDocsBucket" } @@ -575,30 +532,15 @@ class BuildTriggerTests: AppTestCase { $0.environment.siteURL = { "http://example.com" } // Use live dependency but replace actual client with a mock so we can // assert on the details being sent without actually making a request - $0.buildSystem.triggerBuild = { @Sendable client, buildId, cloneURL, isDocBuild, platform, ref, swiftVersion, versionID in - try await Gitlab.Builder.triggerBuild(client: client, - buildId: buildId, - cloneURL: cloneURL, - isDocBuild: isDocBuild, - platform: platform, - reference: ref, - swiftVersion: swiftVersion, - versionID: versionID) + $0.buildSystem.triggerBuild = BuildSystemClient.liveValue.triggerBuild + $0.httpClient.post = { @Sendable _, _, body in + triggerCount.increment() + return .created(webUrl: "http://web_url") } } operation: { - // Ensure we respect the pipeline limit when triggering builds - // setup - var triggerCount = 0 - let client = MockClient { _, res in - triggerCount += 1 - try? res.content.encode( - Gitlab.Builder.Response.init(webUrl: "http://web_url") - ) - } - do { // fist run: we are at capacity and should not be triggering more builds try await withDependencies { - $0.buildSystem.getStatusCount = { @Sendable _, _ in 300 } + $0.buildSystem.getStatusCount = { @Sendable _ in 300 } } operation: { let pkgId = UUID() let versionId = UUID() @@ -608,12 +550,10 @@ class BuildTriggerTests: AppTestCase { .save(on: app.db) // MUT - try await triggerBuilds(on: app.db, - client: client, - mode: .packageId(pkgId, force: false)) + try await triggerBuilds(on: app.db, mode: .packageId(pkgId, force: false)) // validate - XCTAssertEqual(triggerCount, 0) + XCTAssertEqual(triggerCount.value, 0) // ensure no build stubs have been created either let v = try await Version.find(versionId, on: app.db) try await v?.$builds.load(on: app.db) @@ -621,11 +561,11 @@ class BuildTriggerTests: AppTestCase { } } - triggerCount = 0 + triggerCount.setValue(0) do { // second run: we are just below capacity and allow more builds to be triggered try await withDependencies { - $0.buildSystem.getStatusCount = { @Sendable _, _ in 299 } + $0.buildSystem.getStatusCount = { @Sendable _ in 299 } } operation: { let pkgId = UUID() let versionId = UUID() @@ -635,12 +575,10 @@ class BuildTriggerTests: AppTestCase { .save(on: app.db) // MUT - try await triggerBuilds(on: app.db, - client: client, - mode: .packageId(pkgId, force: false)) + try await triggerBuilds(on: app.db, mode: .packageId(pkgId, force: false)) // validate - XCTAssertEqual(triggerCount, 27) + XCTAssertEqual(triggerCount.value, 27) // ensure builds are now in progress let v = try await Version.find(versionId, on: app.db) try await v?.$builds.load(on: app.db) @@ -648,18 +586,12 @@ class BuildTriggerTests: AppTestCase { } } + triggerCount.setValue(0) + do { // third run: we are at capacity and using the `force` flag try await withDependencies { - $0.buildSystem.getStatusCount = { @Sendable _, _ in 300 } + $0.buildSystem.getStatusCount = { @Sendable _ in 300 } } operation: { - var triggerCount = 0 - let client = MockClient { _, res in - triggerCount += 1 - try? res.content.encode( - Gitlab.Builder.Response.init(webUrl: "http://web_url") - ) - } - let pkgId = UUID() let versionId = UUID() let p = Package(id: pkgId, url: "3") @@ -668,12 +600,10 @@ class BuildTriggerTests: AppTestCase { .save(on: app.db) // MUT - try await triggerBuilds(on: app.db, - client: client, - mode: .packageId(pkgId, force: true)) + try await triggerBuilds(on: app.db, mode: .packageId(pkgId, force: true)) // validate - XCTAssertEqual(triggerCount, 27) + XCTAssertEqual(triggerCount.value, 27) // ensure builds are now in progress let v = try await Version.find(versionId, on: app.db) try await v?.$builds.load(on: app.db) @@ -684,10 +614,11 @@ class BuildTriggerTests: AppTestCase { } func test_triggerBuilds_multiplePackages() async throws { - let triggerCount = NIOLockedValueBox(0) + // Ensure we respect the pipeline limit when triggering builds for multiple package ids + let triggerCount = QueueIsolated(0) try await withDependencies { - $0.buildSystem.getStatusCount = { @Sendable c, _ in - 299 + triggerCount.withLockedValue { $0 } + $0.buildSystem.getStatusCount = { @Sendable _ in + 299 + triggerCount.value } $0.environment.allowBuildTriggers = { true } $0.environment.awsDocsBucket = { "awsDocsBucket" } @@ -700,28 +631,13 @@ class BuildTriggerTests: AppTestCase { $0.environment.gitlabPipelineToken = { "pipeline token" } $0.environment.random = { @Sendable _ in 0 } $0.environment.siteURL = { "http://example.com" } - // Use live dependency but replace actual client with a mock so we can - // assert on the details being sent without actually making a request - $0.buildSystem.triggerBuild = { @Sendable client, buildId, cloneURL, isDocBuild, platform, ref, swiftVersion, versionID in - try await Gitlab.Builder.triggerBuild(client: client, - buildId: buildId, - cloneURL: cloneURL, - isDocBuild: isDocBuild, - platform: platform, - reference: ref, - swiftVersion: swiftVersion, - versionID: versionID) + $0.buildSystem.triggerBuild = BuildSystemClient.liveValue.triggerBuild + $0.httpClient.post = { @Sendable _, _, body in + triggerCount.increment() + return .created(webUrl: "http://web_url") } } operation: { - // Ensure we respect the pipeline limit when triggering builds for multiple package ids // setup - let client = MockClient { _, res in - triggerCount.withLockedValue { $0 += 1 } - try? res.content.encode( - Gitlab.Builder.Response.init(webUrl: "http://web_url") - ) - } - let pkgIds = [UUID(), UUID()] for id in pkgIds { let p = Package(id: id, url: id.uuidString.url) @@ -731,18 +647,16 @@ class BuildTriggerTests: AppTestCase { } // MUT - try await triggerBuilds(on: app.db, - client: client, - mode: .limit(4)) + try await triggerBuilds(on: app.db, mode: .limit(4)) // validate - only the first batch must be allowed to trigger - XCTAssertEqual(triggerCount.withLockedValue { $0 }, 27) + XCTAssertEqual(triggerCount.value, 27) } } func test_triggerBuilds_trimming() async throws { try await withDependencies { - $0.buildSystem.getStatusCount = { @Sendable _, _ in 100 } + $0.buildSystem.getStatusCount = { @Sendable _ in 100 } $0.environment.allowBuildTriggers = { true } $0.environment.awsDocsBucket = { "awsDocsBucket" } $0.environment.builderToken = { "builder token" } @@ -755,9 +669,6 @@ class BuildTriggerTests: AppTestCase { } operation: { // Ensure we trim builds as part of triggering // setup - - let client = MockClient { _, _ in } - let p = Package(id: .id0, url: "2") try await p.save(on: app.db) let v = try Version(id: .id1, package: p, latest: nil, reference: .branch("main")) @@ -770,9 +681,7 @@ class BuildTriggerTests: AppTestCase { try await XCTAssertEqualAsync(try await Build.query(on: db).count(), 1) // MUT - try await triggerBuilds(on: app.db, - client: client, - mode: .packageId(p.id!, force: false)) + try await triggerBuilds(on: app.db, mode: .packageId(p.id!, force: false)) // validate let count = try await Build.query(on: app.db).count() @@ -781,8 +690,10 @@ class BuildTriggerTests: AppTestCase { } func test_triggerBuilds_error() async throws { + // Ensure we trim builds as part of triggering + let triggerCount = QueueIsolated(0) try await withDependencies { - $0.buildSystem.getStatusCount = { @Sendable _, _ in 100 } + $0.buildSystem.getStatusCount = { @Sendable _ in 100 } $0.environment.allowBuildTriggers = { true } $0.environment.awsDocsBucket = { "awsDocsBucket" } $0.environment.builderToken = { "builder token" } @@ -793,47 +704,26 @@ class BuildTriggerTests: AppTestCase { $0.environment.gitlabPipelineToken = { "pipeline token" } $0.environment.random = { @Sendable _ in 0 } $0.environment.siteURL = { "http://example.com" } - // Use live dependency but replace actual client with a mock so we can - // assert on the details being sent without actually making a request - $0.buildSystem.triggerBuild = { @Sendable client, buildId, cloneURL, isDocBuild, platform, ref, swiftVersion, versionID in - try await Gitlab.Builder.triggerBuild(client: client, - buildId: buildId, - cloneURL: cloneURL, - isDocBuild: isDocBuild, - platform: platform, - reference: ref, - swiftVersion: swiftVersion, - versionID: versionID) - } - } operation: { - // Ensure we trim builds as part of triggering - // setup - var triggerCount = 0 - let client = MockClient { _, res in + $0.buildSystem.triggerBuild = BuildSystemClient.liveValue.triggerBuild + $0.httpClient.post = { @Sendable _, _, body in + defer { triggerCount.increment() } // let the 5th trigger succeed to ensure we don't early out on errors - if triggerCount == 5 { - try? res.content.encode( - Gitlab.Builder.Response.init(webUrl: "http://web_url") - ) + if triggerCount.value == 5 { + return .created(webUrl: "http://web_url") } else { - struct Response: Content { - var message: String - } - try? res.content.encode(Response(message: "Too many pipelines created in the last minute. Try again later.")) - res.status = .tooManyRequests + struct Response: Content { var message: String } + return try .tooManyRequests(jsonEncode: Response(message: "Too many pipelines created in the last minute. Try again later.")) } - triggerCount += 1 } - + } operation: { + // setup let p = Package(id: .id0, url: "1") try await p.save(on: app.db) let v = try Version(id: .id1, package: p, latest: .defaultBranch, reference: .branch("main")) try await v.save(on: app.db) // MUT - try await triggerBuilds(on: app.db, - client: client, - mode: .packageId(.id0, force: false)) + try await triggerBuilds(on: app.db, mode: .packageId(.id0, force: false)) // validate that one build record is saved, for the successful trigger let count = try await Build.query(on: app.db).count() @@ -911,8 +801,10 @@ class BuildTriggerTests: AppTestCase { } func test_override_switch() async throws { + // Ensure we don't trigger if the override is off + let triggerCount = QueueIsolated(0) try await withDependencies { - $0.buildSystem.getStatusCount = { @Sendable _, _ in 100 } + $0.buildSystem.getStatusCount = { @Sendable _ in 100 } $0.environment.awsDocsBucket = { "awsDocsBucket" } $0.environment.builderToken = { "builder token" } $0.environment.buildTimeout = { 10 } @@ -922,29 +814,13 @@ class BuildTriggerTests: AppTestCase { $0.environment.gitlabPipelineToken = { "pipeline token" } $0.environment.random = { @Sendable _ in 0 } $0.environment.siteURL = { "http://example.com" } - // Use live dependency but replace actual client with a mock so we can - // assert on the details being sent without actually making a request - $0.buildSystem.triggerBuild = { @Sendable client, buildId, cloneURL, isDocBuild, platform, ref, swiftVersion, versionID in - try await Gitlab.Builder.triggerBuild(client: client, - buildId: buildId, - cloneURL: cloneURL, - isDocBuild: isDocBuild, - platform: platform, - reference: ref, - swiftVersion: swiftVersion, - versionID: versionID) + $0.buildSystem.triggerBuild = BuildSystemClient.liveValue.triggerBuild + $0.httpClient.post = { @Sendable _, _, body in + triggerCount.increment() + return .created(webUrl: "http://web_url") } } operation: { - // Ensure don't trigger if the override is off // setup - var triggerCount = 0 - let client = MockClient { _, res in - triggerCount += 1 - try? res.content.encode( - Gitlab.Builder.Response.init(webUrl: "http://web_url") - ) - } - try await withDependencies { // confirm that the off switch prevents triggers $0.environment.allowBuildTriggers = { false } @@ -957,15 +833,13 @@ class BuildTriggerTests: AppTestCase { .save(on: app.db) // MUT - try await triggerBuilds(on: app.db, - client: client, - mode: .packageId(pkgId, force: false)) + try await triggerBuilds(on: app.db, mode: .packageId(pkgId, force: false)) // validate - XCTAssertEqual(triggerCount, 0) + XCTAssertEqual(triggerCount.value, 0) } - triggerCount = 0 + triggerCount.setValue(0) try await withDependencies { // flipping the switch to on should allow triggers to proceed @@ -979,19 +853,19 @@ class BuildTriggerTests: AppTestCase { .save(on: app.db) // MUT - try await triggerBuilds(on: app.db, - client: client, - mode: .packageId(pkgId, force: false)) + try await triggerBuilds(on: app.db, mode: .packageId(pkgId, force: false)) // validate - XCTAssertEqual(triggerCount, 27) + XCTAssertEqual(triggerCount.value, 27) } } } func test_downscaling() async throws { + // Test build trigger downscaling behaviour + let triggerCount = QueueIsolated(0) try await withDependencies { - $0.buildSystem.getStatusCount = { @Sendable _, _ in 100 } + $0.buildSystem.getStatusCount = { @Sendable _ in 100 } $0.environment.allowBuildTriggers = { true } $0.environment.awsDocsBucket = { "awsDocsBucket" } $0.environment.builderToken = { "builder token" } @@ -1001,29 +875,12 @@ class BuildTriggerTests: AppTestCase { $0.environment.gitlabPipelineLimit = { Constants.defaultGitlabPipelineLimit } $0.environment.gitlabPipelineToken = { "pipeline token" } $0.environment.siteURL = { "http://example.com" } - // Use live dependency but replace actual client with a mock so we can - // assert on the details being sent without actually making a request - $0.buildSystem.triggerBuild = { @Sendable client, buildId, cloneURL, isDocBuild, platform, ref, swiftVersion, versionID in - try await Gitlab.Builder.triggerBuild(client: client, - buildId: buildId, - cloneURL: cloneURL, - isDocBuild: isDocBuild, - platform: platform, - reference: ref, - swiftVersion: swiftVersion, - versionID: versionID) + $0.buildSystem.triggerBuild = BuildSystemClient.liveValue.triggerBuild + $0.httpClient.post = { @Sendable _, _, body in + triggerCount.increment() + return .created(webUrl: "http://web_url") } } operation: { - // Test build trigger downscaling behaviour - // setup - var triggerCount = 0 - let client = MockClient { _, res in - triggerCount += 1 - try? res.content.encode( - Gitlab.Builder.Response.init(webUrl: "http://web_url") - ) - } - // confirm that bad luck prevents triggers try await withDependencies { $0.environment.random = { @Sendable _ in 0.05 } // rolling a 0.05 ... so close! @@ -1036,15 +893,13 @@ class BuildTriggerTests: AppTestCase { .save(on: app.db) // MUT - try await triggerBuilds(on: app.db, - client: client, - mode: .packageId(pkgId, force: false)) + try await triggerBuilds(on: app.db, mode: .packageId(pkgId, force: false)) // validate - XCTAssertEqual(triggerCount, 0) + XCTAssertEqual(triggerCount.value, 0) } - triggerCount = 0 + triggerCount.setValue(0) // if we get lucky however... try await withDependencies { @@ -1058,19 +913,19 @@ class BuildTriggerTests: AppTestCase { .save(on: app.db) // MUT - try await triggerBuilds(on: app.db, - client: client, - mode: .packageId(pkgId, force: false)) + try await triggerBuilds(on: app.db, mode: .packageId(pkgId, force: false)) // validate - XCTAssertEqual(triggerCount, 27) + XCTAssertEqual(triggerCount.value, 27) } } } func test_downscaling_allow_list_override() async throws { + // Test build trigger downscaling behaviour for allow-listed packages + let triggerCount = QueueIsolated(0) try await withDependencies { - $0.buildSystem.getStatusCount = { @Sendable _, _ in 100 } + $0.buildSystem.getStatusCount = { @Sendable _ in 100 } $0.environment.allowBuildTriggers = { true } $0.environment.awsDocsBucket = { "awsDocsBucket" } $0.environment.builderToken = { "builder token" } @@ -1080,29 +935,12 @@ class BuildTriggerTests: AppTestCase { $0.environment.gitlabPipelineLimit = { Constants.defaultGitlabPipelineLimit } $0.environment.gitlabPipelineToken = { "pipeline token" } $0.environment.siteURL = { "http://example.com" } - // Use live dependency but replace actual client with a mock so we can - // assert on the details being sent without actually making a request - $0.buildSystem.triggerBuild = { @Sendable client, buildId, cloneURL, isDocBuild, platform, ref, swiftVersion, versionID in - try await Gitlab.Builder.triggerBuild(client: client, - buildId: buildId, - cloneURL: cloneURL, - isDocBuild: isDocBuild, - platform: platform, - reference: ref, - swiftVersion: swiftVersion, - versionID: versionID) + $0.buildSystem.triggerBuild = BuildSystemClient.liveValue.triggerBuild + $0.httpClient.post = { @Sendable _, _, body in + triggerCount.increment() + return .created(webUrl: "http://web_url") } } operation: { - // Test build trigger downscaling behaviour for allow-listed packages - // setup - var triggerCount = 0 - let client = MockClient { _, res in - triggerCount += 1 - try? res.content.encode( - Gitlab.Builder.Response.init(webUrl: "http://web_url") - ) - } - // confirm that we trigger even when rolling above the threshold try await withDependencies { $0.environment.random = { @Sendable _ in 0.051 } @@ -1114,12 +952,10 @@ class BuildTriggerTests: AppTestCase { .save(on: app.db) // MUT - try await triggerBuilds(on: app.db, - client: client, - mode: .packageId(.id0, force: false)) + try await triggerBuilds(on: app.db, mode: .packageId(.id0, force: false)) // validate - XCTAssertEqual(triggerCount, 27) + XCTAssertEqual(triggerCount.value, 27) } } } @@ -1442,3 +1278,15 @@ private func updateBuildCreatedAt(id: Build.Id, addTimeInterval timeInterval: Ti b.createdAt = b.createdAt?.addingTimeInterval(timeInterval) try await b.save(on: database) } + + +private extension HTTPClient.Response { + static func created(webUrl: String) -> Self { + return try! .created(jsonEncode: Gitlab.Builder.Response(webUrl: webUrl)) + } + + static func tooManyRequests(jsonEncode value: T) throws -> Self { + let data = try JSONEncoder().encode(value) + return .init(status: .tooManyRequests, body: .init(data: data)) + } +} diff --git a/Tests/AppTests/GitlabBuilderTests.swift b/Tests/AppTests/GitlabBuilderTests.swift index ce311f662..f45a08ea5 100644 --- a/Tests/AppTests/GitlabBuilderTests.swift +++ b/Tests/AppTests/GitlabBuilderTests.swift @@ -54,111 +54,105 @@ class GitlabBuilderTests: AppTestCase { } func test_triggerBuild() async throws { + let buildId = UUID.id0 + let versionId = UUID.id1 + let called = QueueIsolated(false) try await withDependencies { $0.environment.awsDocsBucket = { "docs-bucket" } $0.environment.builderToken = { "builder token" } $0.environment.buildTimeout = { 10 } $0.environment.gitlabPipelineToken = { "pipeline token" } $0.environment.siteURL = { "http://example.com" } - } operation: { - let buildId = UUID() - let versionID = UUID() - - var called = false - let client = MockClient { req, res in - called = true - try? res.content.encode( - Gitlab.Builder.Response.init(webUrl: "http://web_url") - ) + $0.httpClient.post = { @Sendable _, _, body in + called.setValue(true) + let body = try XCTUnwrap(body) // validate - XCTAssertEqual(try? req.query.decode(Gitlab.Builder.PostDTO.self), - Gitlab.Builder.PostDTO( - token: "pipeline token", - ref: "main", - variables: [ - "API_BASEURL": "http://example.com/api", - "AWS_DOCS_BUCKET": "docs-bucket", - "BUILD_ID": buildId.uuidString, - "BUILD_PLATFORM": "macos-spm", - "BUILDER_TOKEN": "builder token", - "CLONE_URL": "https://github.com/daveverwer/LeftPad.git", - "REFERENCE": "1.2.3", - "SWIFT_VERSION": "5.2", - "TIMEOUT": "10m", - "VERSION_ID": versionID.uuidString, - ])) + XCTAssertEqual( + try? URLEncodedFormDecoder().decode(Gitlab.Builder.PostDTO.self, from: body), + Gitlab.Builder.PostDTO( + token: "pipeline token", + ref: "main", + variables: [ + "API_BASEURL": "http://example.com/api", + "AWS_DOCS_BUCKET": "docs-bucket", + "BUILD_ID": buildId.uuidString, + "BUILD_PLATFORM": "macos-spm", + "BUILDER_TOKEN": "builder token", + "CLONE_URL": "https://github.com/daveverwer/LeftPad.git", + "REFERENCE": "1.2.3", + "SWIFT_VERSION": "5.2", + "TIMEOUT": "10m", + "VERSION_ID": versionId.uuidString, + ]) + ) + return try .created(jsonEncode: Gitlab.Builder.Response(webUrl: "http://web_url")) } - + } operation: { // MUT - _ = try await Gitlab.Builder.triggerBuild(client: client, - buildId: buildId, + _ = try await Gitlab.Builder.triggerBuild(buildId: buildId, cloneURL: "https://github.com/daveverwer/LeftPad.git", isDocBuild: false, platform: .macosSpm, reference: .tag(.init(1, 2, 3)), swiftVersion: .init(5, 2, 4), - versionID: versionID) - XCTAssertTrue(called) + versionID: versionId) + XCTAssertTrue(called.value) } } func test_issue_588() async throws { + let called = QueueIsolated(false) try await withDependencies { $0.environment.awsDocsBucket = { "docs-bucket" } $0.environment.builderToken = { "builder token" } $0.environment.buildTimeout = { 10 } $0.environment.gitlabPipelineToken = { "pipeline token" } $0.environment.siteURL = { "http://example.com" } - } operation: { - var called = false - let client = MockClient { req, res in - called = true - try? res.content.encode( - Gitlab.Builder.Response.init(webUrl: "http://web_url") - ) + $0.httpClient.post = { @Sendable _, _, body in + called.setValue(true) + let body = try XCTUnwrap(body) // validate - let swiftVersion = (try? req.query.decode(Gitlab.Builder.PostDTO.self)) + let swiftVersion = (try? URLEncodedFormDecoder().decode(Gitlab.Builder.PostDTO.self, from: body)) .flatMap { $0.variables["SWIFT_VERSION"] } XCTAssertEqual(swiftVersion, "6.0") + return try .created(jsonEncode: Gitlab.Builder.Response(webUrl: "http://web_url")) } - + } operation: { // MUT - _ = try await Gitlab.Builder.triggerBuild(client: client, - buildId: .id0, + _ = try await Gitlab.Builder.triggerBuild(buildId: .id0, cloneURL: "https://github.com/daveverwer/LeftPad.git", isDocBuild: false, platform: .macosSpm, reference: .tag(.init(1, 2, 3)), swiftVersion: .v6_0, versionID: .id1) - XCTAssertTrue(called) + XCTAssertTrue(called.value) } } func test_getStatusCount() async throws { + let page = QueueIsolated(1) try await withDependencies { $0.environment.gitlabApiToken = { "api token" } - } operation: { - var page = 1 - let client = MockClient { req, res in - XCTAssertEqual(req.url.string, "https://gitlab.com/api/v4/projects/19564054/pipelines?status=pending&page=\(page)&per_page=20") - res.status = .ok + $0.httpClient.get = { @Sendable url, _ in + XCTAssertEqual( + url, + "https://gitlab.com/api/v4/projects/19564054/pipelines?status=pending&page=\(page.value)&per_page=20" + ) let pending = #"{"id": 1, "status": "pending"}"# - switch page { - case 1: - let list = Array(repeating: pending, count: 20).joined(separator: ", ") - res.body = makeBody("[\(list)]") - case 2: - let list = Array(repeating: pending, count: 10).joined(separator: ", ") - res.body = makeBody("[\(list)]") + defer { page.increment() } + let elementsPerPage = switch page.value { + case 1: 20 + case 2: 10 default: XCTFail("unexpected page: \(page)") + throw Abort(.badRequest) } - page += 1 + let list = Array(repeating: pending, count: elementsPerPage).joined(separator: ", ") + return .ok(body: "[\(list)]") } - - let res = try await Gitlab.Builder.getStatusCount(client: client, - status: .pending, + } operation: { + let res = try await Gitlab.Builder.getStatusCount(status: .pending, pageSize: 20, maxPageCount: 3) XCTAssertEqual(res, 30) @@ -183,11 +177,13 @@ class LiveGitlabBuilderTests: AppTestCase { // Set this to a valid value if you want to report build results back to the server ProcessInfo.processInfo.environment["LIVE_BUILDER_TOKEN"] } + $0.environment.buildTimeout = { 10 } $0.environment.gitlabPipelineToken = { // This Gitlab token is required in order to trigger the pipeline ProcessInfo.processInfo.environment["LIVE_GITLAB_PIPELINE_TOKEN"] } $0.environment.siteURL = { "https://staging.swiftpackageindex.com" } + $0.httpClient = .liveValue } operation: { // set build branch to trigger on Gitlab.Builder.branch = "main" @@ -200,14 +196,14 @@ class LiveGitlabBuilderTests: AppTestCase { // MUT let res = try await Gitlab.Builder.triggerBuild( - client: app.client, buildId: buildId, cloneURL: "https://github.com/SwiftPackageIndex/SemanticVersion.git", isDocBuild: false, platform: .macosSpm, reference: .tag(.init(0, 3, 2)), swiftVersion: .v4, - versionID: versionID) + versionID: versionID + ) print("status: \(res.status)") print("buildId: \(buildId)") diff --git a/Tests/AppTests/Helpers/QueueIsolated.swift b/Tests/AppTests/Helpers/QueueIsolated.swift index 4a3f73a2a..b603b7e74 100644 --- a/Tests/AppTests/Helpers/QueueIsolated.swift +++ b/Tests/AppTests/Helpers/QueueIsolated.swift @@ -60,10 +60,10 @@ public final class QueueIsolated: @unchecked Sendable { extension QueueIsolated where Value == Int { public func increment(by delta: Int = 1) { - self._value += delta + withValue { $0 += delta } } public func decrement(by delta: Int = 1) { - self._value -= delta + withValue { $0 -= delta } } } diff --git a/Tests/AppTests/Helpers/URLEncodedFormDecoder+ext.swift b/Tests/AppTests/Helpers/URLEncodedFormDecoder+ext.swift new file mode 100644 index 000000000..568b80b2d --- /dev/null +++ b/Tests/AppTests/Helpers/URLEncodedFormDecoder+ext.swift @@ -0,0 +1,22 @@ +// Copyright Dave Verwer, Sven A. Schmidt, and other contributors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import Vapor + + +extension URLEncodedFormDecoder { + func decode(_: D.Type, from data: Data) throws -> D { + try self.decode(D.self, from: String(decoding: data, as: UTF8.self), userInfo: [:]) + } +} diff --git a/Tests/AppTests/MetricsTests.swift b/Tests/AppTests/MetricsTests.swift index 3280be75d..eab244170 100644 --- a/Tests/AppTests/MetricsTests.swift +++ b/Tests/AppTests/MetricsTests.swift @@ -23,7 +23,7 @@ class MetricsTests: AppTestCase { func test_basic() async throws { try await withDependencies { - $0.buildSystem.triggerBuild = { @Sendable _, _, _, _, _, _, _, _ in + $0.buildSystem.triggerBuild = { @Sendable _, _, _, _, _, _, _ in .init(status: .ok, webUrl: "") } $0.environment.builderToken = { "builder token" } @@ -37,7 +37,6 @@ class MetricsTests: AppTestCase { try await Version(id: versionId, package: p, reference: .branch("main")).save(on: app.db) } try await triggerBuildsUnchecked(on: app.db, - client: app.client, triggers: [ .init(versionId: versionId, buildPairs: [.init(.macosSpm, .v3)])! @@ -148,7 +147,7 @@ class MetricsTests: AppTestCase { let pkg = try await savePackage(on: app.db, "1") // MUT - try await triggerBuilds(on: app.db, client: app.client, mode: .packageId(pkg.id!, force: true)) + try await triggerBuilds(on: app.db, mode: .packageId(pkg.id!, force: true)) // validation XCTAssert((AppMetrics.buildTriggerDurationSeconds?.get()) ?? 0 > 0) diff --git a/Tests/AppTests/PackageController+routesTests.swift b/Tests/AppTests/PackageController+routesTests.swift index 1741fc8a4..420702b58 100644 --- a/Tests/AppTests/PackageController+routesTests.swift +++ b/Tests/AppTests/PackageController+routesTests.swift @@ -1435,8 +1435,6 @@ class PackageController_routesTests: SnapshotTestCase { // MUT let latestMajorVersions = versions.latestMajorVersions() let latestMajorRerefences = latestMajorVersions.map { "\($0.reference)" } - print(latestMajorRerefences) - XCTAssertEqual(latestMajorRerefences, ["1.1.2", "2.1.1", "3.0.0"]) }