import Foundation import Testing #if canImport(FoundationNetworking) // `URLRequest` lives in FoundationNetworking on Linux, as it does in RunnerCore. import FoundationNetworking #endif @testable import RunnerCore /// Ways a fixture can be wrong about its own inputs. private enum FixtureFailure: Error { case unexpectedRequestCount(Int) case unusableURL } /// A canned ``HTTPTransport`` that records what it was asked to send. /// /// An actor rather than a locked class: ``HTTPTransport/send(_:)`` is `async`, /// so actor isolation satisfies the requirement directly and the recorded /// requests need no lock of their own. No `URLProtocol`, no loopback server — /// the seam is the protocol. private actor MockTransport: HTTPTransport { /// Every request handed to ``send(_:)``, in order. private(set) var requests: [URLRequest] = [] private let handler: @Sendable (URLRequest) -> (Data, Int) /// - Parameter handler: Produces the canned `(body, status)` for a request. init(handler: @escaping @Sendable (URLRequest) -> (Data, Int)) { self.handler = handler } /// Convenience: always answer with one status and body. init(status: Int, body: String = "") { self.handler = { (_: URLRequest) in (Data(body.utf8), status) } } func send(_ request: URLRequest) async throws -> (Data, Int) { requests.append(request) return handler(request) } /// The single request that was sent, or a failure if the count differs. func onlyRequest() throws -> URLRequest { guard requests.count == 1, let only = requests.first else { throw FixtureFailure.unexpectedRequestCount(requests.count) } return only } } /// Tests for ``GiteaClient`` request shaping and error mapping, driven through a /// fake ``HTTPTransport``. @Suite("GiteaClient") struct GiteaClientTests { private let base = URL(string: "https://gitea.example.com")! private func components(_ request: URLRequest) throws -> URLComponents { guard let url = request.url, let components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { throw FixtureFailure.unusableURL } return components } private func queryValue(_ request: URLRequest, _ name: String) throws -> String? { try components(request).queryItems?.first(where: { $0.name == name })?.value } // MARK: - Construction @Test("a client retains the base URL it was constructed with") func clientRetainsBaseURL() throws { let url = try #require(URL(string: "https://gitea.example.com")) let client = GiteaClient(baseURL: url, token: "t") #expect(client.baseURL == url) } // MARK: - Headers @Test("every request carries Gitea's token auth and a JSON Accept header") func requestHeaders() throws { let client = GiteaClient(baseURL: base, token: "s3cret") let request = try client.makeRequest(method: "GET", path: "/api/v1/admin/actions/runners") #expect(request.value(forHTTPHeaderField: "Authorization") == "token s3cret") #expect(request.value(forHTTPHeaderField: "Accept") == "application/json") // No body, so no Content-Type. #expect(request.value(forHTTPHeaderField: "Content-Type") == nil) } @Test("a request with a body declares JSON content") func requestWithBody() throws { let client = GiteaClient(baseURL: base, token: "t") let request = try client.makeRequest( method: "POST", path: "/api/v1/x", body: Data(#"{"a":1}"#.utf8)) #expect(request.httpMethod == "POST") #expect(request.value(forHTTPHeaderField: "Content-Type") == "application/json") #expect(request.httpBody == Data(#"{"a":1}"#.utf8)) } @Test("a trailing slash on the base URL does not double up") func baseURLTrailingSlash() throws { let client = GiteaClient(baseURL: try #require(URL(string: "https://gitea.example.com/")), token: "t") let request = try client.makeRequest(method: "GET", path: "/api/v1/version") #expect(request.url?.absoluteString == "https://gitea.example.com/api/v1/version") } @Test("an instance served under a subpath keeps that subpath") func baseURLWithSubpath() throws { // `URL(string:relativeTo:)` would drop "/gitea" here; string joining does not. let client = GiteaClient(baseURL: try #require(URL(string: "https://example.com/gitea")), token: "t") let request = try client.makeRequest(method: "GET", path: "/api/v1/version") #expect(request.url?.absoluteString == "https://example.com/gitea/api/v1/version") } // MARK: - listQueuedJobs @Test("listQueuedJobs GETs the admin jobs endpoint with status=queued") func listQueuedJobsRequestShape() async throws { let body = """ {"total_count": 1, "jobs": [ {"id": 4711, "run_id": 12, "name": "build", "labels": ["macos-arm64"], "status": "queued", "created_at": "2026-08-07T09:15:04Z"} ]} """ let transport = MockTransport(status: 200, body: body) let client = GiteaClient(baseURL: base, token: "t", transport: transport) let jobs = try await client.listQueuedJobs(limit: 25) #expect(jobs.map(\.id) == [4711]) #expect(jobs.first?.labels == ["macos-arm64"]) let request = try await transport.onlyRequest() #expect(request.httpMethod == "GET") #expect(try components(request).path == "/api/v1/admin/actions/jobs") // "queued" and never "waiting": the latter means blocked on a dependency. #expect(try queryValue(request, "status") == "queued") #expect(try queryValue(request, "limit") == "25") #expect(request.value(forHTTPHeaderField: "Authorization") == "token t") } @Test("listQueuedJobs defaults to a limit of 50") func listQueuedJobsDefaultLimit() async throws { let transport = MockTransport(status: 200, body: #"{"total_count": 0, "jobs": []}"#) let client = GiteaClient(baseURL: base, token: "t", transport: transport) #expect(try await client.listQueuedJobs().isEmpty) #expect(try await queryValue(transport.onlyRequest(), "limit") == "50") } @Test("listQueuedJobs never asks for a limit below 1") func listQueuedJobsClampsLimit() async throws { let transport = MockTransport(status: 200, body: #"{"jobs": []}"#) let client = GiteaClient(baseURL: base, token: "t", transport: transport) _ = try await client.listQueuedJobs(limit: 0) #expect(try await queryValue(transport.onlyRequest(), "limit") == "1") } @Test("listQueuedJobs surfaces a 401 as a gitea error") func listQueuedJobsUnauthorized() async throws { let transport = MockTransport(status: 401, body: #"{"message": "token is invalid", "url": "..."}"#) let client = GiteaClient(baseURL: base, token: "bad", transport: transport) await #expect(throws: CoreError.self) { _ = try await client.listQueuedJobs() } do { _ = try await client.listQueuedJobs() Issue.record("expected a CoreError.gitea") } catch let CoreError.gitea(status, message) { #expect(status == 401) // Gitea's own `message` field, not the raw envelope. #expect(message == "token is invalid") } } @Test("a 500 with a non-JSON body reports a body excerpt") func serverErrorReportsExcerpt() async throws { let transport = MockTransport(status: 500, body: "Internal Server Error") let client = GiteaClient(baseURL: base, token: "t", transport: transport) do { _ = try await client.listQueuedJobs() Issue.record("expected a CoreError.gitea") } catch let CoreError.gitea(status, message) { #expect(status == 500) #expect(message.contains("Internal Server Error")) } } @Test("a 2xx with an undecodable body is an error, not a silent empty list") func undecodableBodyIsAnError() async throws { let transport = MockTransport(status: 200, body: "not json at all") let client = GiteaClient(baseURL: base, token: "t", transport: transport) await #expect(throws: CoreError.self) { _ = try await client.listQueuedJobs() } } // MARK: - listRunners @Test("listRunners GETs the admin runners endpoint and decodes object labels") func listRunnersRequestShape() async throws { let body = """ {"total_count": 1, "runners": [ {"id": 9, "name": "macos-vm-abc", "status": "online", "busy": false, "ephemeral": true, "labels": [{"id": 3, "name": "macos-arm64", "type": "custom"}]} ]} """ let transport = MockTransport(status: 200, body: body) let client = GiteaClient(baseURL: base, token: "t", transport: transport) let runners = try await client.listRunners() #expect(runners.count == 1) #expect(runners.first?.labels == ["macos-arm64"]) #expect(runners.first?.isEphemeral == true) #expect(runners.first?.isBusy == false) let request = try await transport.onlyRequest() #expect(request.httpMethod == "GET") #expect(try components(request).path == "/api/v1/admin/actions/runners") // Paginated: `total_count` says there is nothing past this page, so one // request is all it takes. let query = try components(request).queryItems ?? [] #expect(query.contains(URLQueryItem(name: "page", value: "1"))) #expect(query.contains(URLQueryItem(name: "limit", value: "50"))) } @Test("listRunners walks every page rather than returning only the first") func listRunnersPaginates() async throws { // Gitea clamps `limit` to its own maximum, so a page shorter than the // one asked for does not mean the walk is over — only `total_count` does. let transport = MockTransport { request in let page = URLComponents(url: request.url!, resolvingAgainstBaseURL: false)? .queryItems?.first { $0.name == "page" }?.value ?? "1" let id = page == "1" ? 1 : 2 let body = """ {"total_count": 2, "runners": [ {"id": \(id), "name": "macos-vm-\(id)", "status": "online", "busy": false, "ephemeral": true, "labels": ["macos-arm64"]} ]} """ return (Data(body.utf8), 200) } let client = GiteaClient(baseURL: base, token: "t", transport: transport) let runners = try await client.listRunners() #expect(runners.map(\.id) == [1, 2]) await #expect(transport.requests.count == 2) } @Test("listRunners stops on an empty page when the server omits total_count") func listRunnersStopsOnEmptyPage() async throws { let transport = MockTransport { request in let page = URLComponents(url: request.url!, resolvingAgainstBaseURL: false)? .queryItems?.first { $0.name == "page" }?.value ?? "1" let body = page == "1" ? #"{"runners": [{"id": 1, "name": "macos-vm-1", "ephemeral": true, "labels": []}]}"# : #"{"runners": []}"# return (Data(body.utf8), 200) } let client = GiteaClient(baseURL: base, token: "t", transport: transport) let runners = try await client.listRunners() #expect(runners.map(\.id) == [1]) await #expect(transport.requests.count == 2) } @Test("listRunners surfaces a 403 as a gitea error") func listRunnersForbidden() async throws { let transport = MockTransport(status: 403, body: #"{"message": "not an admin"}"#) let client = GiteaClient(baseURL: base, token: "t", transport: transport) do { _ = try await client.listRunners() Issue.record("expected a CoreError.gitea") } catch let CoreError.gitea(status, message) { #expect(status == 403) #expect(message == "not an admin") } } // MARK: - deleteRunner @Test("deleteRunner DELETEs the runner by id and accepts 204") func deleteRunnerRequestShape() async throws { let transport = MockTransport(status: 204) let client = GiteaClient(baseURL: base, token: "t", transport: transport) try await client.deleteRunner(id: 9) let request = try await transport.onlyRequest() #expect(request.httpMethod == "DELETE") #expect(try components(request).path == "/api/v1/admin/actions/runners/9") } @Test("deleteRunner tolerates a 404") func deleteRunnerTolerates404() async throws { // The goal is only that the row be gone. We race Gitea's own midnight // sweep and `--ephemeral` auto-deregistration, so "already absent" is // success, not a failure worth logging every five minutes. let transport = MockTransport(status: 404, body: #"{"message": "runner not found"}"#) let client = GiteaClient(baseURL: base, token: "t", transport: transport) try await client.deleteRunner(id: 9) } @Test("deleteRunner accepts a 200 as well as a 204") func deleteRunnerTolerates200() async throws { let client = GiteaClient(baseURL: base, token: "t", transport: MockTransport(status: 200)) try await client.deleteRunner(id: 1) } @Test("deleteRunner still fails on a 500") func deleteRunnerFailsOn500() async throws { let transport = MockTransport(status: 500, body: #"{"message": "boom"}"#) let client = GiteaClient(baseURL: base, token: "t", transport: transport) do { try await client.deleteRunner(id: 9) Issue.record("expected a CoreError.gitea") } catch let CoreError.gitea(status, message) { #expect(status == 500) #expect(message == "boom") } } @Test("deleteRunner rejects a 401 rather than treating it as done") func deleteRunnerFailsOn401() async throws { let client = GiteaClient( baseURL: base, token: "t", transport: MockTransport(status: 401, body: "unauthorized")) await #expect(throws: CoreError.self) { try await client.deleteRunner(id: 9) } } // MARK: - getRegistrationToken @Test("getRegistrationToken POSTs and returns the token") func registrationTokenRequestShape() async throws { let transport = MockTransport(status: 200, body: #"{"token": "AABBCC00112233"}"#) let client = GiteaClient(baseURL: base, token: "t", transport: transport) #expect(try await client.getRegistrationToken() == "AABBCC00112233") let request = try await transport.onlyRequest() #expect(request.httpMethod == "POST") #expect(try components(request).path == "/api/v1/admin/actions/runners/registration-token") #expect(request.value(forHTTPHeaderField: "Authorization") == "token t") } @Test("getRegistrationToken trims surrounding whitespace") func registrationTokenIsTrimmed() async throws { let transport = MockTransport(status: 200, body: "{\"token\": \" AABB \\n\"}") let client = GiteaClient(baseURL: base, token: "t", transport: transport) #expect(try await client.getRegistrationToken() == "AABB") } @Test("getRegistrationToken rejects an empty token") func registrationTokenRejectsEmpty() async throws { // An empty token would be written into the guest and fail at // `gitea-runner register`, tens of seconds and one VM boot later. let client = GiteaClient( baseURL: base, token: "t", transport: MockTransport(status: 200, body: #"{"token": ""}"#)) await #expect(throws: CoreError.self) { _ = try await client.getRegistrationToken() } } @Test("getRegistrationToken surfaces a 500") func registrationTokenServerError() async throws { let client = GiteaClient( baseURL: base, token: "t", transport: MockTransport(status: 500, body: #"{"message": "nope"}"#)) do { _ = try await client.getRegistrationToken() Issue.record("expected a CoreError.gitea") } catch let CoreError.gitea(status, _) { #expect(status == 500) } } // MARK: - ping @Test("ping probes an admin endpoint, so a non-admin token fails") func pingUsesAnAdminEndpoint() async throws { let transport = MockTransport(status: 200, body: #"{"total_count": 0, "runners": []}"#) let client = GiteaClient(baseURL: base, token: "t", transport: transport) try await client.ping() let request = try await transport.onlyRequest() // Deliberately not /api/v1/version, which most instances serve // anonymously and would therefore pass with a bad token. #expect(try components(request).path == "/api/v1/admin/actions/runners") #expect(request.value(forHTTPHeaderField: "Authorization") == "token t") } @Test("ping fails when the token is rejected") func pingFailsOnBadToken() async throws { let client = GiteaClient( baseURL: base, token: "bad", transport: MockTransport(status: 401, body: #"{"message": "token is invalid"}"#)) do { try await client.ping() Issue.record("expected a CoreError.gitea") } catch let CoreError.gitea(status, _) { #expect(status == 401) } } // MARK: - Transport-level failures @Test("a transport error propagates unchanged") func transportErrorPropagates() async throws { // A dropped connection is not an API error; the poll loop logs it and // retries on the next tick without touching any state. It must not be // laundered into a `CoreError.gitea` with a made-up status. let client = GiteaClient(baseURL: base, token: "t", transport: ThrowingTransport()) await #expect(throws: ThrowingTransport.Offline.self) { _ = try await client.listQueuedJobs() } } } /// A transport that always fails, standing in for an unreachable instance. private struct ThrowingTransport: HTTPTransport { struct Offline: Error {} func send(_ request: URLRequest) async throws -> (Data, Int) { throw Offline() } }