gitbayTests/GitbayClientTests.swift
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}