gitbayTests/RepoManagementTests.swift

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

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