a native ios client for gitbay

client ios swift

https://gitbay.org

gitbayTests/GitbayClientTests.swift

ui-smoke
gitbay-ios/gitbayTests/GitbayClientTests.swift history · blame · raw

380 lines · 14691 bytes

  1import Foundation
  2import Testing
  3@testable import gitbay
  4
  5/// Recorded envelopes, verbatim from gitbay.org v1.0.0.
  6private enum Recorded {
  7    static let whoami = """
  8        {"protocol_version":1,"data":{"username":"cmc","admin":false,"key_scope":"full"},"exit_code":0}
  9        """
 10    static let repoList = """
 11        {"protocol_version":1,"data":[\
 12        {"path":"krz/gitbay","visibility":"public","description":"a CLI-first git forge"},\
 13        {"path":"krz/secrets","visibility":"private","archived":true}\
 14        ],"exit_code":0}
 15        """
 16    static let emptyList = """
 17        {"protocol_version":1,"exit_code":0}
 18        """
 19    static let notFound = """
 20        {"protocol_version":1,"error":"no such issue 99 in krz/gitbay","exit_code":3}
 21        """
 22    static let denied = """
 23        {"protocol_version":1,"error":"krz/gitbay is archived and read-only","exit_code":4}
 24        """
 25    static let usage = """
 26        {"protocol_version":1,"error":"usage: repo tree <owner/name> [<path>] [--ref <ref>]","exit_code":2}
 27        """
 28    static let writeOnReadSurface = """
 29        {"protocol_version":1,"error":"repo create changes state; POST it to /api/v1/cmd"}
 30        """
 31    static let badToken = """
 32        {"protocol_version":1,"error":"invalid or expired token"}
 33        """
 34    static let diff = """
 35        {"protocol_version":1,"output":"diff --git a/main.go b/main.go\\n--- a/main.go\\n+++ b/main.go\\n","exit_code":0}
 36        """
 37}
 38
 39nonisolated private struct WhoamiPayload: Decodable, Sendable, Equatable {
 40    let username: String
 41    let admin: Bool
 42}
 43
 44nonisolated private struct RepoPayload: Decodable, Sendable, Equatable {
 45    let path: String
 46    let visibility: String
 47    let description: String?
 48    let archived: Bool?
 49}
 50
 51private func makeClient() throws -> (GitbayClient, StubProtocol.Box) {
 52    let box = StubProtocol.box()
 53    let client = GitbayClient(
 54        instance: try GitbayInstance(url: "https://gitbay.org"),
 55        token: "test-token",
 56        session: box.session()
 57    )
 58    return (client, box)
 59}
 60
 61struct GitbayClientReadTests {
 62
 63    @Test func readDecodesEnvelopeData() async throws {
 64        let (client, stub) = try makeClient()
 65        stub.enqueue(.init(status: 200, json: Recorded.whoami))
 66
 67        let who = try await client.read(["whoami"], as: WhoamiPayload.self)
 68
 69        #expect(who == WhoamiPayload(username: "cmc", admin: false))
 70        let seen = try #require(stub.seen.first)
 71        #expect(seen.method == "GET")
 72        #expect(seen.url.path() == "/api/v1/read")
 73        #expect(seen.url.query() == "argv=whoami")
 74        #expect(seen.headers["Authorization"] == "Bearer test-token")
 75        #expect(seen.headers["If-None-Match"] == nil)
 76    }
 77
 78    @Test func argvArrivesAsRepeatedQueryParameters() async throws {
 79        let (client, stub) = try makeClient()
 80        stub.enqueue(.init(status: 200, json: Recorded.emptyList))
 81
 82        _ = try await client.readList(
 83            ["repo", "tree", "krz/gitbay", "internal"], of: RepoPayload.self
 84        )
 85
 86        let seen = try #require(stub.seen.first)
 87        #expect(seen.url.query() == "argv=repo&argv=tree&argv=krz/gitbay&argv=internal")
 88    }
 89
 90    @Test func secondReadRevalidatesAndA304ServesTheCachedBody() async throws {
 91        let (client, stub) = try makeClient()
 92        let etag = "\"12e3e2b8914d9abfceec233dac9a7454\""
 93        stub.enqueue(.init(status: 200, headers: ["ETag": etag], json: Recorded.repoList))
 94        stub.enqueue(.init(status: 304, headers: ["ETag": etag]))
 95
 96        let first = try await client.readList(["repo", "list"], of: RepoPayload.self)
 97        let second = try await client.readList(["repo", "list"], of: RepoPayload.self)
 98
 99        #expect(first == second)
100        #expect(first.count == 2)
101        #expect(first[0].path == "krz/gitbay")
102        let requests = stub.seen
103        #expect(requests.count == 2)
104        #expect(requests[0].headers["If-None-Match"] == nil)
105        #expect(requests[1].headers["If-None-Match"] == etag)
106    }
107
108    @Test func changedBodyReplacesTheCachedValidator() async throws {
109        let (client, stub) = try makeClient()
110        stub.enqueue(.init(status: 200, headers: ["ETag": "\"aa\""], json: Recorded.repoList))
111        stub.enqueue(.init(status: 200, headers: ["ETag": "\"bb\""], json: Recorded.emptyList))
112        stub.enqueue(.init(status: 304))
113
114        _ = try await client.readList(["repo", "list"], of: RepoPayload.self)
115        let refreshed = try await client.readList(["repo", "list"], of: RepoPayload.self)
116        let revalidated = try await client.readList(["repo", "list"], of: RepoPayload.self)
117
118        #expect(refreshed.isEmpty)
119        #expect(revalidated.isEmpty)
120        #expect(stub.seen[2].headers["If-None-Match"] == "\"bb\"")
121    }
122
123    @Test func absentDataDecodesAsEmptyList() async throws {
124        let (client, stub) = try makeClient()
125        stub.enqueue(.init(status: 200, json: Recorded.emptyList))
126
127        let repos = try await client.readList(["repo", "list"], of: RepoPayload.self)
128
129        #expect(repos.isEmpty)
130    }
131
132    @Test func rawTextCommandsComeBackAsOutput() async throws {
133        let (client, stub) = try makeClient()
134        stub.enqueue(.init(status: 200, json: Recorded.diff))
135
136        let diff = try await client.readText(["mr", "diff", "krz/gitbay", "7"])
137
138        #expect(diff.hasPrefix("diff --git a/main.go"))
139    }
140}
141
142struct GitbayClientWriteTests {
143
144    @Test func writePostsArgvAndStdinAsJSON() async throws {
145        let (client, stub) = try makeClient()
146        stub.enqueue(.init(status: 200, json: Recorded.emptyList))
147
148        try await client.run(
149            ["issue", "comment", "krz/gitbay", "35"],
150            stdin: "looks right to me"
151        )
152
153        let seen = try #require(stub.seen.first)
154        #expect(seen.method == "POST")
155        #expect(seen.url.path() == "/api/v1/cmd")
156        let body = try #require(
157            try JSONSerialization.jsonObject(with: seen.body) as? [String: Any]
158        )
159        #expect(body["argv"] as? [String] == ["issue", "comment", "krz/gitbay", "35"])
160        #expect(body["stdin"] as? String == "looks right to me")
161    }
162
163    @Test func stdinIsOmittedWhenAbsent() async throws {
164        let (client, stub) = try makeClient()
165        stub.enqueue(.init(status: 200, json: Recorded.emptyList))
166
167        try await client.run(["mr", "merge", "krz/gitbay", "7"])
168
169        let seen = try #require(stub.seen.first)
170        let body = try #require(
171            try JSONSerialization.jsonObject(with: seen.body) as? [String: Any]
172        )
173        #expect(body["stdin"] == nil)
174    }
175}
176
177struct GitbayClientFailureTests {
178
179    @Test func exitThreeIsAnEmptyStateNotAnError() async throws {
180        let (client, stub) = try makeClient()
181        stub.enqueue(.init(status: 404, json: Recorded.notFound))
182
183        await #expect {
184            _ = try await client.read(["issue", "show", "krz/gitbay", "99"], as: WhoamiPayload.self)
185        } throws: { error in
186            guard let error = error as? GitbayError else { return false }
187            return error.isEmptyState
188                && error.userFacingMessage == "no such issue 99 in krz/gitbay"
189        }
190    }
191
192    @Test func exitFourSurfacesTheServersMessageVerbatim() async throws {
193        let (client, stub) = try makeClient()
194        stub.enqueue(.init(status: 403, json: Recorded.denied))
195
196        await #expect {
197            try await client.run(["issue", "comment", "krz/gitbay", "1"], stdin: "hi")
198        } throws: { error in
199            guard case .denied(let message)? = error as? GitbayError else { return false }
200            return message == "krz/gitbay is archived and read-only"
201        }
202    }
203
204    @Test func exitTwoNeverPutsArgvInTheUserFacingMessage() async throws {
205        let (client, stub) = try makeClient()
206        stub.enqueue(.init(status: 400, json: Recorded.usage))
207
208        await #expect {
209            _ = try await client.read(["repo", "tree"], as: WhoamiPayload.self)
210        } throws: { error in
211            guard case .usage? = error as? GitbayError else { return false }
212            let shown = (error as! GitbayError).userFacingMessage
213            return !shown.contains("repo tree") && !shown.contains("usage")
214        }
215    }
216
217    @Test func gateRejectionsWithoutExitCodesMapOffTheStatus() async throws {
218        let (client, stub) = try makeClient()
219        stub.enqueue(.init(status: 401, json: Recorded.badToken))
220
221        await #expect {
222            _ = try await client.read(["whoami"], as: WhoamiPayload.self)
223        } throws: { error in
224            (error as? GitbayError)?.requiresReauthentication == true
225        }
226    }
227
228    @Test func writeSentToTheReadSurfaceIsAnAppBug() async throws {
229        let (client, stub) = try makeClient()
230        stub.enqueue(.init(status: 400, json: Recorded.writeOnReadSurface))
231
232        await #expect {
233            _ = try await client.read(["repo", "create", "krz/new"], as: WhoamiPayload.self)
234        } throws: { error in
235            guard case .notReadable? = error as? GitbayError else { return false }
236            return true
237        }
238    }
239
240    @Test func serverFailureRetriesOnceThenSurfaces() async throws {
241        let (client, stub) = try makeClient()
242        stub.enqueue(.init(status: 500, json: #"{"protocol_version":1,"error":"boom","exit_code":1}"#))
243        stub.enqueue(.init(status: 500, json: #"{"protocol_version":1,"error":"boom","exit_code":1}"#))
244
245        await #expect {
246            _ = try await client.read(["repo", "list"], as: WhoamiPayload.self)
247        } throws: { error in
248            guard case .failure(let message)? = error as? GitbayError else { return false }
249            return message == "boom"
250        }
251        #expect(stub.seen.count == 2)
252    }
253
254    @Test func serverFailureRecoversWhenTheRetrySucceeds() async throws {
255        let (client, stub) = try makeClient()
256        stub.enqueue(.init(status: 500, json: #"{"protocol_version":1,"error":"boom","exit_code":1}"#))
257        stub.enqueue(.init(status: 200, json: Recorded.whoami))
258
259        let who = try await client.read(["whoami"], as: WhoamiPayload.self)
260
261        #expect(who.username == "cmc")
262        #expect(stub.seen.count == 2)
263    }
264
265    @Test func shortRetryAfterIsHonouredThenTheCallSucceeds() async throws {
266        let (client, stub) = try makeClient()
267        stub.enqueue(.init(
268            status: 429,
269            headers: ["Retry-After": "1"],
270            json: #"{"protocol_version":1,"error":"rate limited; retry in 1s"}"#
271        ))
272        stub.enqueue(.init(status: 200, json: Recorded.whoami))
273
274        let start = ContinuousClock.now
275        let who = try await client.read(["whoami"], as: WhoamiPayload.self)
276
277        #expect(who.username == "cmc")
278        #expect(ContinuousClock.now - start >= .seconds(1))
279        #expect(stub.seen.count == 2)
280    }
281
282    @Test func longRetryAfterSurfacesInsteadOfHanging() async throws {
283        let (client, stub) = try makeClient()
284        stub.enqueue(.init(
285            status: 429,
286            headers: ["Retry-After": "30"],
287            json: #"{"protocol_version":1,"error":"rate limited; retry in 30s"}"#
288        ))
289
290        await #expect {
291            _ = try await client.read(["whoami"], as: WhoamiPayload.self)
292        } throws: { error in
293            guard case .rateLimited(let wait)? = error as? GitbayError else { return false }
294            return wait == 30
295        }
296        #expect(stub.seen.count == 1)
297    }
298
299    @Test func unknownProtocolVersionIsRefused() async throws {
300        let (client, stub) = try makeClient()
301        stub.enqueue(.init(status: 200, json: #"{"protocol_version":2,"data":{},"exit_code":0}"#))
302
303        await #expect {
304            _ = try await client.read(["whoami"], as: WhoamiPayload.self)
305        } throws: { error in
306            guard case .protocolMismatch(2)? = error as? GitbayError else { return false }
307            return true
308        }
309    }
310}
311
312struct GitbayInstanceTests {
313
314    @Test func bareHostGetsHTTPS() throws {
315        let instance = try GitbayInstance(url: "gitbay.org")
316        #expect(instance.baseURL.absoluteString == "https://gitbay.org")
317    }
318
319    @Test func pastedPathAndQueryAreStripped() throws {
320        let instance = try GitbayInstance(url: "https://gitbay.org/krz/gitbay?tab=readme")
321        #expect(instance.baseURL.absoluteString == "https://gitbay.org")
322    }
323
324    @Test func plainHTTPIsRefusedExceptLoopback() throws {
325        #expect(throws: GitbayInstance.InvalidURL.self) {
326            _ = try GitbayInstance(url: "http://gitbay.org")
327        }
328        let local = try GitbayInstance(url: "http://localhost:3000")
329        #expect(local.baseURL.absoluteString == "http://localhost:3000")
330    }
331
332    @Test func readURLEncodesArgvInOrder() throws {
333        let instance = try GitbayInstance(url: "https://gitbay.org")
334        let url = instance.readURL(argv: ["repo", "cat", "krz/gitbay", "cmd/main.go"])
335        #expect(url.query() == "argv=repo&argv=cat&argv=krz/gitbay&argv=cmd/main.go")
336    }
337
338    @Test func ownHostCheckCoversSchemeHostAndPort() throws {
339        let instance = try GitbayInstance(url: "https://gitbay.org")
340        #expect(instance.isOwn(URL(string: "https://gitbay.org/api/v1/read")!))
341        #expect(instance.isOwn(URL(string: "https://GITBAY.ORG/api/v1/cmd")!))
342        #expect(!instance.isOwn(URL(string: "https://evil.example/api/v1/read")!))
343        #expect(!instance.isOwn(URL(string: "http://gitbay.org/api/v1/read")!))
344        #expect(!instance.isOwn(URL(string: "https://gitbay.org.evil.example/")!))
345    }
346}
347
348struct ETagStoreTests {
349
350    @Test func keysAreScopedByAccount() {
351        let a = ETagStore.key(account: "aaaa", argv: ["repo", "list"])
352        let b = ETagStore.key(account: "bbbb", argv: ["repo", "list"])
353        #expect(a != b)
354    }
355
356    @Test func joiningCannotCollideAcrossArgvBoundaries() {
357        let a = ETagStore.key(account: "x", argv: ["repo list"])
358        let b = ETagStore.key(account: "x", argv: ["repo", "list"])
359        #expect(a != b)
360    }
361
362    @Test func evictionDropsTheLeastRecentlyUsedEntry() {
363        let store = ETagStore(capacity: 2)
364        store.store(ETagEntry(etag: "a", payload: Data()), for: "a")
365        store.store(ETagEntry(etag: "b", payload: Data()), for: "b")
366        _ = store.entry(for: "a")
367        store.store(ETagEntry(etag: "c", payload: Data()), for: "c")
368
369        #expect(store.entry(for: "a") != nil)
370        #expect(store.entry(for: "b") == nil)
371        #expect(store.entry(for: "c") != nil)
372    }
373
374    @Test func clearEmptiesTheStore() {
375        let store = ETagStore()
376        store.store(ETagEntry(etag: "a", payload: Data()), for: "a")
377        store.clear()
378        #expect(store.count == 0)
379    }
380}