gitbayTests/RepoManagementTests.swift

v1.8.0
gitbay-ios/gitbayTests/RepoManagementTests.swift history · blame · raw

404 lines · 17987 bytes

  1import Foundation
  2import Testing
  3@testable import gitbay
  4
  5private func makeClient() throws -> (GitbayClient, StubProtocol.Box) {
  6    let box = StubProtocol.box()
  7    let client = GitbayClient(
  8        instance: try GitbayInstance(url: "https://gitbay.org"),
  9        token: "test-token",
 10        session: box.session()
 11    )
 12    return (client, box)
 13}
 14
 15private func argvOf(_ seen: StubProtocol.Seen) throws -> [String] {
 16    let body = try #require(try JSONSerialization.jsonObject(with: seen.body) as? [String: Any])
 17    return try #require(body["argv"] as? [String])
 18}
 19
 20private let okJSON = #"{"protocol_version":1,"data":{},"exit_code":0}"#
 21
 22private let settingsShowJSON = """
 23    {"protocol_version":1,"data":{"protected_branches":["main"],"require_approvals":1,\
 24    "require_resolved":true,"require_codeowners":true,"require_mr":true,\
 25    "protected_tags":["v*"],"website":"https://gitbay.org"},"exit_code":0}
 26    """
 27
 28private let repoShowJSON = """
 29    {"protocol_version":1,"data":{"path":"krz/gitbay","description":"a forge",\
 30    "visibility":"public","default_branch":"main","topics":["git"]},"exit_code":0}
 31    """
 32
 33@MainActor
 34struct RepoSettingsViewModelTests {
 35
 36    private func loadedModel() async throws -> (RepoSettingsViewModel, StubProtocol.Box) {
 37        let (client, stub) = try makeClient()
 38        stub.enqueue(.init(status: 200, json: settingsShowJSON, match: "argv=settings"))
 39        stub.enqueue(.init(status: 200, json: repoShowJSON, match: "argv=show&argv=krz"))
 40        let model = RepoSettingsViewModel(client: client, repoPath: "krz/gitbay")
 41        await model.load()
 42        return (model, stub)
 43    }
 44
 45    @Test func loadsSettingsAndDetailTogether() async throws {
 46        let (model, _) = try await loadedModel()
 47
 48        let loaded = try #require(model.state.value)
 49        #expect(loaded.settings.branches == ["main"])
 50        #expect(loaded.settings.requireApprovals == 1)
 51        #expect(loaded.settings.requireResolved == true)
 52        // Absent means default, not unknown.
 53        #expect(loaded.settings.requireChecks == nil)
 54        #expect(loaded.settings.requireCodeowners == true)
 55        #expect(loaded.settings.requireMR == true)
 56        #expect(loaded.settings.tags == ["v*"])
 57        #expect(loaded.detail.topics == ["git"])
 58    }
 59
 60    @Test func aNonAdminGetsTheRefusalAsTheScreenState() async throws {
 61        let (client, stub) = try makeClient()
 62        stub.enqueue(.init(status: 403, json:
 63            #"{"protocol_version":1,"error":"admin access required","exit_code":4}"#,
 64            match: "argv=settings"))
 65        stub.enqueue(.init(status: 200, json: repoShowJSON, match: "argv=show&argv=krz"))
 66        let model = RepoSettingsViewModel(client: client, repoPath: "krz/gitbay")
 67
 68        await model.load()
 69
 70        guard case .failed(let message) = model.state else {
 71            Issue.record("expected .failed, got \(model.state)")
 72            return
 73        }
 74        #expect(message == "admin access required")
 75    }
 76
 77    @Test func everyKnobSendsItsOwnCommand() async throws {
 78        let (model, stub) = try await loadedModel()
 79        for _ in 0..<17 {
 80            stub.enqueue(.init(status: 200, json: okJSON, match: "cmd"))
 81            stub.enqueue(.init(status: 200, json: settingsShowJSON, match: "argv=settings"))
 82            stub.enqueue(.init(status: 200, json: repoShowJSON, match: "argv=show&argv=krz"))
 83        }
 84
 85        await model.setDescription("new text")
 86        await model.setWebsite("https://x.example")
 87        await model.setVisibility("private")
 88        await model.setGitDaemon(true)
 89        await model.protectBranch("release")
 90        await model.unprotectBranch("main")
 91        await model.setRequireApprovals(2)
 92        await model.setRequireResolved(false)
 93        await model.setRequireChecks(true)
 94        await model.setRequireSigned(true)
 95        await model.setRequireCodeowners(true)
 96        await model.setRequireMR(true)
 97        await model.protectTag("v*")
 98        await model.unprotectTag("v*")
 99        await model.setDefaultBranch("release")
100        await model.addTopic("ios")
101        await model.removeTopic("git")
102
103        let writes = try stub.seen.filter { $0.method == "POST" }.map(argvOf)
104        #expect(writes == [
105            ["repo", "settings", "description", "krz/gitbay", "new text"],
106            ["repo", "settings", "website", "krz/gitbay", "https://x.example"],
107            ["repo", "settings", "visibility", "krz/gitbay", "private"],
108            ["repo", "settings", "git-daemon", "krz/gitbay", "on"],
109            ["repo", "settings", "protect", "krz/gitbay", "release"],
110            ["repo", "settings", "unprotect", "krz/gitbay", "main"],
111            ["repo", "settings", "require-approvals", "krz/gitbay", "2"],
112            ["repo", "settings", "require-resolved", "krz/gitbay", "off"],
113            ["repo", "settings", "require-checks", "krz/gitbay", "on"],
114            ["repo", "settings", "require-signed", "krz/gitbay", "on"],
115            ["repo", "settings", "require-codeowners", "krz/gitbay", "on"],
116            ["repo", "settings", "require-mr", "krz/gitbay", "on"],
117            ["repo", "settings", "protect-tag", "krz/gitbay", "v*"],
118            ["repo", "settings", "unprotect-tag", "krz/gitbay", "v*"],
119            ["repo", "settings", "default-branch", "krz/gitbay", "release"],
120            ["repo", "topics", "add", "krz/gitbay", "ios"],
121            ["repo", "topics", "remove", "krz/gitbay", "git"],
122        ])
123    }
124
125    @Test func renameSendsTheNewNameAndReturnsTheNewPath() async throws {
126        let (model, stub) = try await loadedModel()
127        stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"data":{"repo":"krz/forge","was":"krz/gitbay"},"exit_code":0}"#, match: "cmd"))
128
129        let renamed = await model.rename(to: " forge ")
130
131        #expect(renamed == "krz/forge")
132        let write = try #require(stub.seen.first { $0.method == "POST" })
133        #expect(try argvOf(write) == ["repo", "rename", "krz/gitbay", "forge"])
134        // The old path is gone; nothing reloads it.
135        #expect(stub.seen.filter { $0.method == "GET" }.count == 2)
136    }
137
138    @Test func aRefusedRenameSurfacesAndReturnsNil() async throws {
139        let (model, stub) = try await loadedModel()
140        stub.enqueue(.init(status: 403, json: #"{"protocol_version":1,"error":"admin access required","exit_code":4}"#, match: "cmd"))
141
142        let renamed = await model.rename(to: "forge")
143
144        #expect(renamed == nil)
145        #expect(model.actionError == "admin access required")
146    }
147
148    @Test func branchesLoadForTheDefaultBranchPicker() async throws {
149        let (model, stub) = try await loadedModel()
150        stub.enqueue(.init(status: 200, json: """
151            {"protocol_version":1,"data":{"branches":[{"name":"main","sha":"aa"},\
152            {"name":"release","sha":"bb"}],"tags":[{"name":"v1","sha":"cc"}]},"exit_code":0}
153            """, match: "argv=refs"))
154
155        await model.loadBranches()
156
157        #expect(model.branches == ["main", "release"])
158        #expect(stub.seen.last?.url.query() == "argv=repo&argv=refs&argv=krz/gitbay")
159    }
160
161    @Test func accessGrantsLoadSeparately() async throws {
162        let (model, stub) = try await loadedModel()
163        stub.enqueue(.init(status: 200, json: """
164            {"protocol_version":1,"data":[{"user":"alice","role":"write","source":"direct"},\
165            {"user":"bob","role":"read","source":"team"},{"user":"cmc","role":"admin"}],"exit_code":0}
166            """, match: "argv=access"))
167
168        await model.loadGrants()
169
170        #expect(model.grants?.map(\.user) == ["alice", "bob", "cmc"])
171        #expect(model.grants?.first?.role == "write")
172        // v1.16.0 says where the access comes from; only a direct grant
173        // can be revoked here. An older server says nothing.
174        #expect(model.grants?[0].source == "direct")
175        #expect(model.grants?[0].isDirect == true)
176        #expect(model.grants?[1].source == "team")
177        #expect(model.grants?[1].isDirect == false)
178        #expect(model.grants?[2].source == nil)
179        #expect(model.grants?[2].isDirect == true)
180        #expect(model.grantsError == nil)
181        let read = try #require(stub.seen.last)
182        #expect(read.url.query() == "argv=repo&argv=access&argv=list&argv=krz/gitbay")
183    }
184
185    @Test func aFailedGrantsReadIsAnErrorNotAnEmptyList() async throws {
186        let (model, stub) = try await loadedModel()
187        stub.enqueue(.init(status: 403, json: #"{"protocol_version":1,"error":"admin access required","exit_code":4}"#, match: "argv=access"))
188
189        await model.loadGrants()
190
191        #expect(model.grants == nil)
192        #expect(model.grantsError == "admin access required")
193    }
194
195    @Test func grantAndRevokeSendTheirCommandsThenReloadGrants() async throws {
196        let (model, stub) = try await loadedModel()
197        for _ in 0..<2 {
198            stub.enqueue(.init(status: 200, json: okJSON, match: "cmd"))
199            stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"data":[],"exit_code":0}"#, match: "argv=access"))
200        }
201
202        await model.grant(user: " alice ", role: "write")
203        await model.revoke(user: "alice")
204
205        let writes = try stub.seen.filter { $0.method == "POST" }.map(argvOf)
206        #expect(writes == [
207            ["repo", "access", "grant", "krz/gitbay", "alice", "write"],
208            ["repo", "access", "revoke", "krz/gitbay", "alice"],
209        ])
210        let grantReads = stub.seen.filter { $0.url.query()?.contains("argv=access&argv=list") == true }
211        #expect(grantReads.count == 2)
212        #expect(model.grants?.isEmpty == true)
213    }
214
215    @Test func aDeniedKnobSurfacesAndKeepsTheScreen() async throws {
216        let (model, stub) = try await loadedModel()
217        stub.enqueue(.init(status: 403, json:
218            #"{"protocol_version":1,"error":"only admins can change visibility","exit_code":4}"#,
219            match: "cmd"))
220
221        await model.setVisibility("private")
222
223        #expect(model.actionError == "only admins can change visibility")
224        #expect(model.state.value != nil)
225    }
226}
227
228@MainActor
229struct RepoPinArchiveTests {
230
231    private let dashboardPinnedJSON = """
232        {"protocol_version":1,"data":{"pinned":[{"path":"krz/gitbay","visibility":"public"}],\
233        "open_mrs":[],"assigned_issues":[],"builds":[]},"exit_code":0}
234        """
235    private let dashboardEmptyJSON = """
236        {"protocol_version":1,"data":{"pinned":[],"open_mrs":[],"assigned_issues":[],"builds":[]},\
237        "exit_code":0}
238        """
239    private let treeJSON = """
240        {"protocol_version":1,"data":{"path":"krz/gitbay","ref":"main","dir":"","entries":[]},\
241        "exit_code":0}
242        """
243
244    @Test func pinStateComesFromTheDashboardAggregate() async throws {
245        let (client, stub) = try makeClient()
246        stub.enqueue(.init(status: 200, json: repoShowJSON, match: "argv=show"))
247        stub.enqueue(.init(status: 200, json: dashboardPinnedJSON, match: "argv=dashboard"))
248        stub.enqueue(.init(status: 200, json: treeJSON, match: "argv=tree"))
249        let model = RepoDetailViewModel(client: client, path: "krz/gitbay")
250
251        await model.load()
252
253        #expect(model.isPinned == true)
254    }
255
256    @Test func unpinSendsTheCommandAndRefreshesState() async throws {
257        let (client, stub) = try makeClient()
258        stub.enqueue(.init(status: 200, json: repoShowJSON, match: "argv=show"))
259        stub.enqueue(.init(status: 200, json: dashboardPinnedJSON, match: "argv=dashboard"))
260        stub.enqueue(.init(status: 200, json: treeJSON, match: "argv=tree"))
261        let model = RepoDetailViewModel(client: client, path: "krz/gitbay")
262        await model.load()
263
264        stub.enqueue(.init(status: 200, json: okJSON, match: "cmd"))
265        stub.enqueue(.init(status: 200, json: repoShowJSON, match: "argv=show"))
266        stub.enqueue(.init(status: 200, json: dashboardEmptyJSON, match: "argv=dashboard"))
267
268        await model.setPinned(false)
269
270        let write = try #require(stub.seen.first { $0.method == "POST" })
271        #expect(try argvOf(write) == ["repo", "unpin", "krz/gitbay"])
272        #expect(model.isPinned == false)
273    }
274
275    @Test func archiveSendsTheCommand() async throws {
276        let (client, stub) = try makeClient()
277        stub.enqueue(.init(status: 200, json: repoShowJSON, match: "argv=show"))
278        stub.enqueue(.init(status: 200, json: dashboardEmptyJSON, match: "argv=dashboard"))
279        stub.enqueue(.init(status: 200, json: treeJSON, match: "argv=tree"))
280        let model = RepoDetailViewModel(client: client, path: "krz/gitbay")
281        await model.load()
282
283        stub.enqueue(.init(status: 200, json: okJSON, match: "cmd"))
284        stub.enqueue(.init(status: 200, json: repoShowJSON, match: "argv=show"))
285
286        await model.setArchived(true)
287
288        let write = try #require(stub.seen.first { $0.method == "POST" })
289        #expect(try argvOf(write) == ["repo", "archive", "krz/gitbay"])
290    }
291}
292
293@MainActor
294struct RepoCreateViewModelTests {
295
296    @Test func createSendsPathAndPrivateFlag() async throws {
297        let (client, stub) = try makeClient()
298        stub.enqueue(.init(status: 200, json:
299            #"{"protocol_version":1,"data":{"path":"cmc/notes","visibility":"private","ssh_url":"ssh://git@gitbay.org/cmc/notes.git"},"exit_code":0}"#))
300        let model = RepoCreateViewModel(client: client)
301
302        let path = await model.create(path: " cmc/notes ", isPrivate: true)
303
304        #expect(path == "cmc/notes")
305        let write = try #require(stub.seen.first)
306        #expect(try argvOf(write) == ["repo", "create", "cmc/notes", "--private"])
307    }
308
309    @Test func publicCreateOmitsTheFlag() async throws {
310        let (client, stub) = try makeClient()
311        stub.enqueue(.init(status: 200, json:
312            #"{"protocol_version":1,"data":{"path":"cmc/notes","visibility":"public","ssh_url":"x"},"exit_code":0}"#))
313        let model = RepoCreateViewModel(client: client)
314
315        _ = await model.create(path: "cmc/notes", isPrivate: false)
316
317        #expect(try argvOf(try #require(stub.seen.first)) == ["repo", "create", "cmc/notes"])
318    }
319
320    @Test func importSendsTheSourceAndTheTokenOverStdin() async throws {
321        let (client, stub) = try makeClient()
322        stub.enqueue(.init(status: 200, json:
323            #"{"protocol_version":1,"data":{"path":"cmc/mirror","visibility":"private"},"exit_code":0}"#))
324        let model = RepoCreateViewModel(client: client)
325
326        let path = await model.importRepo(
327            path: " cmc/mirror ", from: "https://example.com/x/y.git", isPrivate: true, token: "s3cret")
328
329        #expect(path == "cmc/mirror")
330        let write = try #require(stub.seen.first)
331        #expect(try argvOf(write) == ["repo", "import", "cmc/mirror",
332                                      "--from", "https://example.com/x/y.git", "--private", "--token-stdin"])
333        let body = try #require(try JSONSerialization.jsonObject(with: write.body) as? [String: Any])
334        #expect(body["stdin"] as? String == "s3cret\n")
335    }
336
337    @Test func importWithoutATokenSendsNoStdin() async throws {
338        let (client, stub) = try makeClient()
339        stub.enqueue(.init(status: 200, json:
340            #"{"protocol_version":1,"data":{"path":"cmc/mirror","visibility":"public"},"exit_code":0}"#))
341        let model = RepoCreateViewModel(client: client)
342
343        _ = await model.importRepo(path: "cmc/mirror", from: "https://example.com/x/y.git", isPrivate: false, token: "")
344
345        let write = try #require(stub.seen.first)
346        #expect(try argvOf(write) == ["repo", "import", "cmc/mirror", "--from", "https://example.com/x/y.git"])
347        let body = try #require(try JSONSerialization.jsonObject(with: write.body) as? [String: Any])
348        #expect(body["stdin"] == nil)
349    }
350
351    @Test func aRefusalSurfaces() async throws {
352        let (client, stub) = try makeClient()
353        stub.enqueue(.init(status: 403, json:
354            #"{"protocol_version":1,"error":"you cannot create repositories under krz","exit_code":4}"#))
355        let model = RepoCreateViewModel(client: client)
356
357        let path = await model.create(path: "krz/nope", isPrivate: false)
358
359        #expect(path == nil)
360        #expect(model.errorMessage == "you cannot create repositories under krz")
361    }
362}
363
364@MainActor
365struct BuildTriggerTests {
366
367    private let buildListJSON = """
368        {"protocol_version":1,"data":{"items":[\
369        {"number":3,"job":"ci","status":"success","sha":"65ba14e0000000000000",\
370        "ref":"refs/heads/main","created_at":"2026-08-20T10:00:00.000Z"}]},"exit_code":0}
371        """
372
373    @Test func triggerSendsTheJobAndReloads() async throws {
374        let (client, stub) = try makeClient()
375        stub.enqueue(.init(status: 200, json: buildListJSON, match: "argv=build&argv=list"))
376        let model = BuildListViewModel(client: client, repoPath: "krz/gitbay")
377        await model.load()
378
379        stub.enqueue(.init(status: 200, json:
380            #"{"protocol_version":1,"data":{"build":4,"job":"ci","sha":"aa"},"exit_code":0}"#,
381            match: "cmd"))
382        stub.enqueue(.init(status: 200, json: buildListJSON, match: "argv=build&argv=list"))
383
384        await model.trigger(job: "ci")
385
386        let write = try #require(stub.seen.first { $0.method == "POST" })
387        #expect(try argvOf(write) == ["build", "trigger", "krz/gitbay", "ci"])
388        #expect(model.actionError == nil)
389    }
390
391    @Test func anUnknownJobSurfacesAsAnError() async throws {
392        let (client, stub) = try makeClient()
393        stub.enqueue(.init(status: 200, json: buildListJSON, match: "argv=build&argv=list"))
394        let model = BuildListViewModel(client: client, repoPath: "krz/gitbay")
395        await model.load()
396        stub.enqueue(.init(status: 404, json:
397            #"{"protocol_version":1,"error":"no job \"deploy\" in .gitbay/ci.yml","exit_code":3}"#,
398            match: "cmd"))
399
400        await model.trigger(job: "deploy")
401
402        #expect(model.actionError == #"no job "deploy" in .gitbay/ci.yml"#)
403    }
404}