import Foundation import Testing @testable import gitbay /// Recorded envelopes, verbatim from gitbay.org v1.0.0. private enum Recorded { static let whoami = """ {"protocol_version":1,"data":{"username":"cmc","admin":false,"key_scope":"full"},"exit_code":0} """ static let repoList = """ {"protocol_version":1,"data":[\ {"path":"krz/gitbay","visibility":"public","description":"a CLI-first git forge"},\ {"path":"krz/secrets","visibility":"private","archived":true}\ ],"exit_code":0} """ static let emptyList = """ {"protocol_version":1,"exit_code":0} """ static let notFound = """ {"protocol_version":1,"error":"no such issue 99 in krz/gitbay","exit_code":3} """ static let denied = """ {"protocol_version":1,"error":"krz/gitbay is archived and read-only","exit_code":4} """ static let usage = """ {"protocol_version":1,"error":"usage: repo tree [] [--ref ]","exit_code":2} """ static let writeOnReadSurface = """ {"protocol_version":1,"error":"repo create changes state; POST it to /api/v1/cmd"} """ static let badToken = """ {"protocol_version":1,"error":"invalid or expired token"} """ static let diff = """ {"protocol_version":1,"output":"diff --git a/main.go b/main.go\\n--- a/main.go\\n+++ b/main.go\\n","exit_code":0} """ } nonisolated private struct WhoamiPayload: Decodable, Sendable, Equatable { let username: String let admin: Bool } nonisolated private struct RepoPayload: Decodable, Sendable, Equatable { let path: String let visibility: String let description: String? let archived: Bool? } private func makeClient() throws -> (GitbayClient, StubProtocol.Box) { let box = StubProtocol.box() let client = GitbayClient( instance: try GitbayInstance(url: "https://gitbay.org"), token: "test-token", session: box.session() ) return (client, box) } struct GitbayClientReadTests { @Test func readDecodesEnvelopeData() async throws { let (client, stub) = try makeClient() stub.enqueue(.init(status: 200, json: Recorded.whoami)) let who = try await client.read(["whoami"], as: WhoamiPayload.self) #expect(who == WhoamiPayload(username: "cmc", admin: false)) let seen = try #require(stub.seen.first) #expect(seen.method == "GET") #expect(seen.url.path() == "/api/v1/read") #expect(seen.url.query() == "argv=whoami") #expect(seen.headers["Authorization"] == "Bearer test-token") #expect(seen.headers["If-None-Match"] == nil) } @Test func argvArrivesAsRepeatedQueryParameters() async throws { let (client, stub) = try makeClient() stub.enqueue(.init(status: 200, json: Recorded.emptyList)) _ = try await client.readList( ["repo", "tree", "krz/gitbay", "internal"], of: RepoPayload.self ) let seen = try #require(stub.seen.first) #expect(seen.url.query() == "argv=repo&argv=tree&argv=krz/gitbay&argv=internal") } @Test func secondReadRevalidatesAndA304ServesTheCachedBody() async throws { let (client, stub) = try makeClient() let etag = "\"12e3e2b8914d9abfceec233dac9a7454\"" stub.enqueue(.init(status: 200, headers: ["ETag": etag], json: Recorded.repoList)) stub.enqueue(.init(status: 304, headers: ["ETag": etag])) let first = try await client.readList(["repo", "list"], of: RepoPayload.self) let second = try await client.readList(["repo", "list"], of: RepoPayload.self) #expect(first == second) #expect(first.count == 2) #expect(first[0].path == "krz/gitbay") let requests = stub.seen #expect(requests.count == 2) #expect(requests[0].headers["If-None-Match"] == nil) #expect(requests[1].headers["If-None-Match"] == etag) } @Test func changedBodyReplacesTheCachedValidator() async throws { let (client, stub) = try makeClient() stub.enqueue(.init(status: 200, headers: ["ETag": "\"aa\""], json: Recorded.repoList)) stub.enqueue(.init(status: 200, headers: ["ETag": "\"bb\""], json: Recorded.emptyList)) stub.enqueue(.init(status: 304)) _ = try await client.readList(["repo", "list"], of: RepoPayload.self) let refreshed = try await client.readList(["repo", "list"], of: RepoPayload.self) let revalidated = try await client.readList(["repo", "list"], of: RepoPayload.self) #expect(refreshed.isEmpty) #expect(revalidated.isEmpty) #expect(stub.seen[2].headers["If-None-Match"] == "\"bb\"") } @Test func absentDataDecodesAsEmptyList() async throws { let (client, stub) = try makeClient() stub.enqueue(.init(status: 200, json: Recorded.emptyList)) let repos = try await client.readList(["repo", "list"], of: RepoPayload.self) #expect(repos.isEmpty) } @Test func rawTextCommandsComeBackAsOutput() async throws { let (client, stub) = try makeClient() stub.enqueue(.init(status: 200, json: Recorded.diff)) let diff = try await client.readText(["mr", "diff", "krz/gitbay", "7"]) #expect(diff.hasPrefix("diff --git a/main.go")) } } struct GitbayClientWriteTests { @Test func writePostsArgvAndStdinAsJSON() async throws { let (client, stub) = try makeClient() stub.enqueue(.init(status: 200, json: Recorded.emptyList)) try await client.run( ["issue", "comment", "krz/gitbay", "35"], stdin: "looks right to me" ) let seen = try #require(stub.seen.first) #expect(seen.method == "POST") #expect(seen.url.path() == "/api/v1/cmd") let body = try #require( try JSONSerialization.jsonObject(with: seen.body) as? [String: Any] ) #expect(body["argv"] as? [String] == ["issue", "comment", "krz/gitbay", "35"]) #expect(body["stdin"] as? String == "looks right to me") } @Test func stdinIsOmittedWhenAbsent() async throws { let (client, stub) = try makeClient() stub.enqueue(.init(status: 200, json: Recorded.emptyList)) try await client.run(["mr", "merge", "krz/gitbay", "7"]) let seen = try #require(stub.seen.first) let body = try #require( try JSONSerialization.jsonObject(with: seen.body) as? [String: Any] ) #expect(body["stdin"] == nil) } } struct GitbayClientFailureTests { @Test func exitThreeIsAnEmptyStateNotAnError() async throws { let (client, stub) = try makeClient() stub.enqueue(.init(status: 404, json: Recorded.notFound)) await #expect { _ = try await client.read(["issue", "show", "krz/gitbay", "99"], as: WhoamiPayload.self) } throws: { error in guard let error = error as? GitbayError else { return false } return error.isEmptyState && error.userFacingMessage == "no such issue 99 in krz/gitbay" } } @Test func exitFourSurfacesTheServersMessageVerbatim() async throws { let (client, stub) = try makeClient() stub.enqueue(.init(status: 403, json: Recorded.denied)) await #expect { try await client.run(["issue", "comment", "krz/gitbay", "1"], stdin: "hi") } throws: { error in guard case .denied(let message)? = error as? GitbayError else { return false } return message == "krz/gitbay is archived and read-only" } } @Test func exitTwoNeverPutsArgvInTheUserFacingMessage() async throws { let (client, stub) = try makeClient() stub.enqueue(.init(status: 400, json: Recorded.usage)) await #expect { _ = try await client.read(["repo", "tree"], as: WhoamiPayload.self) } throws: { error in guard case .usage? = error as? GitbayError else { return false } let shown = (error as! GitbayError).userFacingMessage return !shown.contains("repo tree") && !shown.contains("usage") } } @Test func gateRejectionsWithoutExitCodesMapOffTheStatus() async throws { let (client, stub) = try makeClient() stub.enqueue(.init(status: 401, json: Recorded.badToken)) await #expect { _ = try await client.read(["whoami"], as: WhoamiPayload.self) } throws: { error in (error as? GitbayError)?.requiresReauthentication == true } } @Test func writeSentToTheReadSurfaceIsAnAppBug() async throws { let (client, stub) = try makeClient() stub.enqueue(.init(status: 400, json: Recorded.writeOnReadSurface)) await #expect { _ = try await client.read(["repo", "create", "krz/new"], as: WhoamiPayload.self) } throws: { error in guard case .notReadable? = error as? GitbayError else { return false } return true } } @Test func serverFailureRetriesOnceThenSurfaces() async throws { let (client, stub) = try makeClient() stub.enqueue(.init(status: 500, json: #"{"protocol_version":1,"error":"boom","exit_code":1}"#)) stub.enqueue(.init(status: 500, json: #"{"protocol_version":1,"error":"boom","exit_code":1}"#)) await #expect { _ = try await client.read(["repo", "list"], as: WhoamiPayload.self) } throws: { error in guard case .failure(let message)? = error as? GitbayError else { return false } return message == "boom" } #expect(stub.seen.count == 2) } @Test func serverFailureRecoversWhenTheRetrySucceeds() async throws { let (client, stub) = try makeClient() stub.enqueue(.init(status: 500, json: #"{"protocol_version":1,"error":"boom","exit_code":1}"#)) stub.enqueue(.init(status: 200, json: Recorded.whoami)) let who = try await client.read(["whoami"], as: WhoamiPayload.self) #expect(who.username == "cmc") #expect(stub.seen.count == 2) } @Test func shortRetryAfterIsHonouredThenTheCallSucceeds() async throws { let (client, stub) = try makeClient() stub.enqueue(.init( status: 429, headers: ["Retry-After": "1"], json: #"{"protocol_version":1,"error":"rate limited; retry in 1s"}"# )) stub.enqueue(.init(status: 200, json: Recorded.whoami)) let start = ContinuousClock.now let who = try await client.read(["whoami"], as: WhoamiPayload.self) #expect(who.username == "cmc") #expect(ContinuousClock.now - start >= .seconds(1)) #expect(stub.seen.count == 2) } @Test func longRetryAfterSurfacesInsteadOfHanging() async throws { let (client, stub) = try makeClient() stub.enqueue(.init( status: 429, headers: ["Retry-After": "30"], json: #"{"protocol_version":1,"error":"rate limited; retry in 30s"}"# )) await #expect { _ = try await client.read(["whoami"], as: WhoamiPayload.self) } throws: { error in guard case .rateLimited(let wait)? = error as? GitbayError else { return false } return wait == 30 } #expect(stub.seen.count == 1) } @Test func unknownProtocolVersionIsRefused() async throws { let (client, stub) = try makeClient() stub.enqueue(.init(status: 200, json: #"{"protocol_version":2,"data":{},"exit_code":0}"#)) await #expect { _ = try await client.read(["whoami"], as: WhoamiPayload.self) } throws: { error in guard case .protocolMismatch(2)? = error as? GitbayError else { return false } return true } } } struct GitbayInstanceTests { @Test func bareHostGetsHTTPS() throws { let instance = try GitbayInstance(url: "gitbay.org") #expect(instance.baseURL.absoluteString == "https://gitbay.org") } @Test func pastedPathAndQueryAreStripped() throws { let instance = try GitbayInstance(url: "https://gitbay.org/krz/gitbay?tab=readme") #expect(instance.baseURL.absoluteString == "https://gitbay.org") } @Test func plainHTTPIsRefusedExceptLoopback() throws { #expect(throws: GitbayInstance.InvalidURL.self) { _ = try GitbayInstance(url: "http://gitbay.org") } let local = try GitbayInstance(url: "http://localhost:3000") #expect(local.baseURL.absoluteString == "http://localhost:3000") } @Test func readURLEncodesArgvInOrder() throws { let instance = try GitbayInstance(url: "https://gitbay.org") let url = instance.readURL(argv: ["repo", "cat", "krz/gitbay", "cmd/main.go"]) #expect(url.query() == "argv=repo&argv=cat&argv=krz/gitbay&argv=cmd/main.go") } @Test func ownHostCheckCoversSchemeHostAndPort() throws { let instance = try GitbayInstance(url: "https://gitbay.org") #expect(instance.isOwn(URL(string: "https://gitbay.org/api/v1/read")!)) #expect(instance.isOwn(URL(string: "https://GITBAY.ORG/api/v1/cmd")!)) #expect(!instance.isOwn(URL(string: "https://evil.example/api/v1/read")!)) #expect(!instance.isOwn(URL(string: "http://gitbay.org/api/v1/read")!)) #expect(!instance.isOwn(URL(string: "https://gitbay.org.evil.example/")!)) } } struct ETagStoreTests { @Test func keysAreScopedByAccount() { let a = ETagStore.key(account: "aaaa", argv: ["repo", "list"]) let b = ETagStore.key(account: "bbbb", argv: ["repo", "list"]) #expect(a != b) } @Test func joiningCannotCollideAcrossArgvBoundaries() { let a = ETagStore.key(account: "x", argv: ["repo list"]) let b = ETagStore.key(account: "x", argv: ["repo", "list"]) #expect(a != b) } @Test func evictionDropsTheLeastRecentlyUsedEntry() { let store = ETagStore(capacity: 2) store.store(ETagEntry(etag: "a", payload: Data()), for: "a") store.store(ETagEntry(etag: "b", payload: Data()), for: "b") _ = store.entry(for: "a") store.store(ETagEntry(etag: "c", payload: Data()), for: "c") #expect(store.entry(for: "a") != nil) #expect(store.entry(for: "b") == nil) #expect(store.entry(for: "c") != nil) } @Test func clearEmptiesTheStore() { let store = ETagStore() store.store(ETagEntry(etag: "a", payload: Data()), for: "a") store.clear() #expect(store.count == 0) } }