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