gitbayTests/RepoManagementTests.swift
404 lines · 17977 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":[\
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}