gitbayTests/GitbayClientTests.swift

v1.0.7
gitbay-ios/gitbayTests/GitbayClientTests.swift history · blame · raw

383 lines · 14875 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        let firstRequest = try #require(requests.first)
105        #expect(firstRequest.headers["If-None-Match"] == nil)
106        let secondRequest = try #require(requests.dropFirst().first)
107        #expect(secondRequest.headers["If-None-Match"] == etag)
108    }
109
110    @Test func changedBodyReplacesTheCachedValidator() async throws {
111        let (client, stub) = try makeClient()
112        stub.enqueue(.init(status: 200, headers: ["ETag": "\"aa\""], json: Recorded.repoList))
113        stub.enqueue(.init(status: 200, headers: ["ETag": "\"bb\""], json: Recorded.emptyList))
114        stub.enqueue(.init(status: 304))
115
116        _ = try await client.readList(["repo", "list"], of: RepoPayload.self)
117        let refreshed = try await client.readList(["repo", "list"], of: RepoPayload.self)
118        let revalidated = try await client.readList(["repo", "list"], of: RepoPayload.self)
119
120        #expect(refreshed.isEmpty)
121        #expect(revalidated.isEmpty)
122        let third = try #require(stub.seen.dropFirst(2).first)
123        #expect(third.headers["If-None-Match"] == "\"bb\"")
124    }
125
126    @Test func absentDataDecodesAsEmptyList() async throws {
127        let (client, stub) = try makeClient()
128        stub.enqueue(.init(status: 200, json: Recorded.emptyList))
129
130        let repos = try await client.readList(["repo", "list"], of: RepoPayload.self)
131
132        #expect(repos.isEmpty)
133    }
134
135    @Test func rawTextCommandsComeBackAsOutput() async throws {
136        let (client, stub) = try makeClient()
137        stub.enqueue(.init(status: 200, json: Recorded.diff))
138
139        let diff = try await client.readText(["mr", "diff", "krz/gitbay", "7"])
140
141        #expect(diff.hasPrefix("diff --git a/main.go"))
142    }
143}
144
145struct GitbayClientWriteTests {
146
147    @Test func writePostsArgvAndStdinAsJSON() async throws {
148        let (client, stub) = try makeClient()
149        stub.enqueue(.init(status: 200, json: Recorded.emptyList))
150
151        try await client.run(
152            ["issue", "comment", "krz/gitbay", "35"],
153            stdin: "looks right to me"
154        )
155
156        let seen = try #require(stub.seen.first)
157        #expect(seen.method == "POST")
158        #expect(seen.url.path() == "/api/v1/cmd")
159        let body = try #require(
160            try JSONSerialization.jsonObject(with: seen.body) as? [String: Any]
161        )
162        #expect(body["argv"] as? [String] == ["issue", "comment", "krz/gitbay", "35"])
163        #expect(body["stdin"] as? String == "looks right to me")
164    }
165
166    @Test func stdinIsOmittedWhenAbsent() async throws {
167        let (client, stub) = try makeClient()
168        stub.enqueue(.init(status: 200, json: Recorded.emptyList))
169
170        try await client.run(["mr", "merge", "krz/gitbay", "7"])
171
172        let seen = try #require(stub.seen.first)
173        let body = try #require(
174            try JSONSerialization.jsonObject(with: seen.body) as? [String: Any]
175        )
176        #expect(body["stdin"] == nil)
177    }
178}
179
180struct GitbayClientFailureTests {
181
182    @Test func exitThreeIsAnEmptyStateNotAnError() async throws {
183        let (client, stub) = try makeClient()
184        stub.enqueue(.init(status: 404, json: Recorded.notFound))
185
186        await #expect {
187            _ = try await client.read(["issue", "show", "krz/gitbay", "99"], as: WhoamiPayload.self)
188        } throws: { error in
189            guard let error = error as? GitbayError else { return false }
190            return error.isEmptyState
191                && error.userFacingMessage == "no such issue 99 in krz/gitbay"
192        }
193    }
194
195    @Test func exitFourSurfacesTheServersMessageVerbatim() async throws {
196        let (client, stub) = try makeClient()
197        stub.enqueue(.init(status: 403, json: Recorded.denied))
198
199        await #expect {
200            try await client.run(["issue", "comment", "krz/gitbay", "1"], stdin: "hi")
201        } throws: { error in
202            guard case .denied(let message)? = error as? GitbayError else { return false }
203            return message == "krz/gitbay is archived and read-only"
204        }
205    }
206
207    @Test func exitTwoNeverPutsArgvInTheUserFacingMessage() async throws {
208        let (client, stub) = try makeClient()
209        stub.enqueue(.init(status: 400, json: Recorded.usage))
210
211        await #expect {
212            _ = try await client.read(["repo", "tree"], as: WhoamiPayload.self)
213        } throws: { error in
214            guard case .usage? = error as? GitbayError else { return false }
215            let shown = (error as! GitbayError).userFacingMessage
216            return !shown.contains("repo tree") && !shown.contains("usage")
217        }
218    }
219
220    @Test func gateRejectionsWithoutExitCodesMapOffTheStatus() async throws {
221        let (client, stub) = try makeClient()
222        stub.enqueue(.init(status: 401, json: Recorded.badToken))
223
224        await #expect {
225            _ = try await client.read(["whoami"], as: WhoamiPayload.self)
226        } throws: { error in
227            (error as? GitbayError)?.requiresReauthentication == true
228        }
229    }
230
231    @Test func writeSentToTheReadSurfaceIsAnAppBug() async throws {
232        let (client, stub) = try makeClient()
233        stub.enqueue(.init(status: 400, json: Recorded.writeOnReadSurface))
234
235        await #expect {
236            _ = try await client.read(["repo", "create", "krz/new"], as: WhoamiPayload.self)
237        } throws: { error in
238            guard case .notReadable? = error as? GitbayError else { return false }
239            return true
240        }
241    }
242
243    @Test func serverFailureRetriesOnceThenSurfaces() async throws {
244        let (client, stub) = try makeClient()
245        stub.enqueue(.init(status: 500, json: #"{"protocol_version":1,"error":"boom","exit_code":1}"#))
246        stub.enqueue(.init(status: 500, json: #"{"protocol_version":1,"error":"boom","exit_code":1}"#))
247
248        await #expect {
249            _ = try await client.read(["repo", "list"], as: WhoamiPayload.self)
250        } throws: { error in
251            guard case .failure(let message)? = error as? GitbayError else { return false }
252            return message == "boom"
253        }
254        #expect(stub.seen.count == 2)
255    }
256
257    @Test func serverFailureRecoversWhenTheRetrySucceeds() async throws {
258        let (client, stub) = try makeClient()
259        stub.enqueue(.init(status: 500, json: #"{"protocol_version":1,"error":"boom","exit_code":1}"#))
260        stub.enqueue(.init(status: 200, json: Recorded.whoami))
261
262        let who = try await client.read(["whoami"], as: WhoamiPayload.self)
263
264        #expect(who.username == "cmc")
265        #expect(stub.seen.count == 2)
266    }
267
268    @Test func shortRetryAfterIsHonouredThenTheCallSucceeds() async throws {
269        let (client, stub) = try makeClient()
270        stub.enqueue(.init(
271            status: 429,
272            headers: ["Retry-After": "1"],
273            json: #"{"protocol_version":1,"error":"rate limited; retry in 1s"}"#
274        ))
275        stub.enqueue(.init(status: 200, json: Recorded.whoami))
276
277        let start = ContinuousClock.now
278        let who = try await client.read(["whoami"], as: WhoamiPayload.self)
279
280        #expect(who.username == "cmc")
281        #expect(ContinuousClock.now - start >= .seconds(1))
282        #expect(stub.seen.count == 2)
283    }
284
285    @Test func longRetryAfterSurfacesInsteadOfHanging() async throws {
286        let (client, stub) = try makeClient()
287        stub.enqueue(.init(
288            status: 429,
289            headers: ["Retry-After": "30"],
290            json: #"{"protocol_version":1,"error":"rate limited; retry in 30s"}"#
291        ))
292
293        await #expect {
294            _ = try await client.read(["whoami"], as: WhoamiPayload.self)
295        } throws: { error in
296            guard case .rateLimited(let wait)? = error as? GitbayError else { return false }
297            return wait == 30
298        }
299        #expect(stub.seen.count == 1)
300    }
301
302    @Test func unknownProtocolVersionIsRefused() async throws {
303        let (client, stub) = try makeClient()
304        stub.enqueue(.init(status: 200, json: #"{"protocol_version":2,"data":{},"exit_code":0}"#))
305
306        await #expect {
307            _ = try await client.read(["whoami"], as: WhoamiPayload.self)
308        } throws: { error in
309            guard case .protocolMismatch(2)? = error as? GitbayError else { return false }
310            return true
311        }
312    }
313}
314
315struct GitbayInstanceTests {
316
317    @Test func bareHostGetsHTTPS() throws {
318        let instance = try GitbayInstance(url: "gitbay.org")
319        #expect(instance.baseURL.absoluteString == "https://gitbay.org")
320    }
321
322    @Test func pastedPathAndQueryAreStripped() throws {
323        let instance = try GitbayInstance(url: "https://gitbay.org/krz/gitbay?tab=readme")
324        #expect(instance.baseURL.absoluteString == "https://gitbay.org")
325    }
326
327    @Test func plainHTTPIsRefusedExceptLoopback() throws {
328        #expect(throws: GitbayInstance.InvalidURL.self) {
329            _ = try GitbayInstance(url: "http://gitbay.org")
330        }
331        let local = try GitbayInstance(url: "http://localhost:3000")
332        #expect(local.baseURL.absoluteString == "http://localhost:3000")
333    }
334
335    @Test func readURLEncodesArgvInOrder() throws {
336        let instance = try GitbayInstance(url: "https://gitbay.org")
337        let url = instance.readURL(argv: ["repo", "cat", "krz/gitbay", "cmd/main.go"])
338        #expect(url.query() == "argv=repo&argv=cat&argv=krz/gitbay&argv=cmd/main.go")
339    }
340
341    @Test func ownHostCheckCoversSchemeHostAndPort() throws {
342        let instance = try GitbayInstance(url: "https://gitbay.org")
343        #expect(instance.isOwn(URL(string: "https://gitbay.org/api/v1/read")!))
344        #expect(instance.isOwn(URL(string: "https://GITBAY.ORG/api/v1/cmd")!))
345        #expect(!instance.isOwn(URL(string: "https://evil.example/api/v1/read")!))
346        #expect(!instance.isOwn(URL(string: "http://gitbay.org/api/v1/read")!))
347        #expect(!instance.isOwn(URL(string: "https://gitbay.org.evil.example/")!))
348    }
349}
350
351struct ETagStoreTests {
352
353    @Test func keysAreScopedByAccount() {
354        let a = ETagStore.key(account: "aaaa", argv: ["repo", "list"])
355        let b = ETagStore.key(account: "bbbb", argv: ["repo", "list"])
356        #expect(a != b)
357    }
358
359    @Test func joiningCannotCollideAcrossArgvBoundaries() {
360        let a = ETagStore.key(account: "x", argv: ["repo list"])
361        let b = ETagStore.key(account: "x", argv: ["repo", "list"])
362        #expect(a != b)
363    }
364
365    @Test func evictionDropsTheLeastRecentlyUsedEntry() {
366        let store = ETagStore(capacity: 2)
367        store.store(ETagEntry(etag: "a", payload: Data()), for: "a")
368        store.store(ETagEntry(etag: "b", payload: Data()), for: "b")
369        _ = store.entry(for: "a")
370        store.store(ETagEntry(etag: "c", payload: Data()), for: "c")
371
372        #expect(store.entry(for: "a") != nil)
373        #expect(store.entry(for: "b") == nil)
374        #expect(store.entry(for: "c") != nil)
375    }
376
377    @Test func clearEmptiesTheStore() {
378        let store = ETagStore()
379        store.store(ETagEntry(etag: "a", payload: Data()), for: "a")
380        store.clear()
381        #expect(store.count == 0)
382    }
383}