gitbayTests/RepoManagementTests.swift
269 lines · 11364 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,"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.detail.topics == ["git"])
54 }
55
56 @Test func aNonAdminGetsTheRefusalAsTheScreenState() async throws {
57 let (client, stub) = try makeClient()
58 stub.enqueue(.init(status: 403, json:
59 #"{"protocol_version":1,"error":"admin access required","exit_code":4}"#,
60 match: "argv=settings"))
61 stub.enqueue(.init(status: 200, json: repoShowJSON, match: "argv=show&argv=krz"))
62 let model = RepoSettingsViewModel(client: client, repoPath: "krz/gitbay")
63
64 await model.load()
65
66 guard case .failed(let message) = model.state else {
67 Issue.record("expected .failed, got \(model.state)")
68 return
69 }
70 #expect(message == "admin access required")
71 }
72
73 @Test func everyKnobSendsItsOwnCommand() async throws {
74 let (model, stub) = try await loadedModel()
75 for _ in 0..<12 {
76 stub.enqueue(.init(status: 200, json: okJSON, match: "cmd"))
77 stub.enqueue(.init(status: 200, json: settingsShowJSON, match: "argv=settings"))
78 stub.enqueue(.init(status: 200, json: repoShowJSON, match: "argv=show&argv=krz"))
79 }
80
81 await model.setDescription("new text")
82 await model.setWebsite("https://x.example")
83 await model.setVisibility("private")
84 await model.setGitDaemon(true)
85 await model.protectBranch("release")
86 await model.unprotectBranch("main")
87 await model.setRequireApprovals(2)
88 await model.setRequireResolved(false)
89 await model.setRequireChecks(true)
90 await model.setRequireSigned(true)
91 await model.addTopic("ios")
92 await model.removeTopic("git")
93
94 let writes = try stub.seen.filter { $0.method == "POST" }.map(argvOf)
95 #expect(writes == [
96 ["repo", "settings", "description", "krz/gitbay", "new text"],
97 ["repo", "settings", "website", "krz/gitbay", "https://x.example"],
98 ["repo", "settings", "visibility", "krz/gitbay", "private"],
99 ["repo", "settings", "git-daemon", "krz/gitbay", "on"],
100 ["repo", "settings", "protect", "krz/gitbay", "release"],
101 ["repo", "settings", "unprotect", "krz/gitbay", "main"],
102 ["repo", "settings", "require-approvals", "krz/gitbay", "2"],
103 ["repo", "settings", "require-resolved", "krz/gitbay", "off"],
104 ["repo", "settings", "require-checks", "krz/gitbay", "on"],
105 ["repo", "settings", "require-signed", "krz/gitbay", "on"],
106 ["repo", "topics", "add", "krz/gitbay", "ios"],
107 ["repo", "topics", "remove", "krz/gitbay", "git"],
108 ])
109 }
110
111 @Test func aDeniedKnobSurfacesAndKeepsTheScreen() async throws {
112 let (model, stub) = try await loadedModel()
113 stub.enqueue(.init(status: 403, json:
114 #"{"protocol_version":1,"error":"only admins can change visibility","exit_code":4}"#,
115 match: "cmd"))
116
117 await model.setVisibility("private")
118
119 #expect(model.actionError == "only admins can change visibility")
120 #expect(model.state.value != nil)
121 }
122}
123
124@MainActor
125struct RepoPinArchiveTests {
126
127 private let dashboardPinnedJSON = """
128 {"protocol_version":1,"data":{"pinned":[{"path":"krz/gitbay","visibility":"public"}],\
129 "open_mrs":[],"assigned_issues":[],"builds":[]},"exit_code":0}
130 """
131 private let dashboardEmptyJSON = """
132 {"protocol_version":1,"data":{"pinned":[],"open_mrs":[],"assigned_issues":[],"builds":[]},\
133 "exit_code":0}
134 """
135 private let treeJSON = """
136 {"protocol_version":1,"data":{"path":"krz/gitbay","ref":"main","dir":"","entries":[]},\
137 "exit_code":0}
138 """
139
140 @Test func pinStateComesFromTheDashboardAggregate() async throws {
141 let (client, stub) = try makeClient()
142 stub.enqueue(.init(status: 200, json: repoShowJSON, match: "argv=show"))
143 stub.enqueue(.init(status: 200, json: dashboardPinnedJSON, match: "argv=dashboard"))
144 stub.enqueue(.init(status: 200, json: treeJSON, match: "argv=tree"))
145 let model = RepoDetailViewModel(client: client, path: "krz/gitbay")
146
147 await model.load()
148
149 #expect(model.isPinned == true)
150 }
151
152 @Test func unpinSendsTheCommandAndRefreshesState() async throws {
153 let (client, stub) = try makeClient()
154 stub.enqueue(.init(status: 200, json: repoShowJSON, match: "argv=show"))
155 stub.enqueue(.init(status: 200, json: dashboardPinnedJSON, match: "argv=dashboard"))
156 stub.enqueue(.init(status: 200, json: treeJSON, match: "argv=tree"))
157 let model = RepoDetailViewModel(client: client, path: "krz/gitbay")
158 await model.load()
159
160 stub.enqueue(.init(status: 200, json: okJSON, match: "cmd"))
161 stub.enqueue(.init(status: 200, json: repoShowJSON, match: "argv=show"))
162 stub.enqueue(.init(status: 200, json: dashboardEmptyJSON, match: "argv=dashboard"))
163
164 await model.setPinned(false)
165
166 let write = try #require(stub.seen.first { $0.method == "POST" })
167 #expect(try argvOf(write) == ["repo", "unpin", "krz/gitbay"])
168 #expect(model.isPinned == false)
169 }
170
171 @Test func archiveSendsTheCommand() async throws {
172 let (client, stub) = try makeClient()
173 stub.enqueue(.init(status: 200, json: repoShowJSON, match: "argv=show"))
174 stub.enqueue(.init(status: 200, json: dashboardEmptyJSON, match: "argv=dashboard"))
175 stub.enqueue(.init(status: 200, json: treeJSON, match: "argv=tree"))
176 let model = RepoDetailViewModel(client: client, path: "krz/gitbay")
177 await model.load()
178
179 stub.enqueue(.init(status: 200, json: okJSON, match: "cmd"))
180 stub.enqueue(.init(status: 200, json: repoShowJSON, match: "argv=show"))
181
182 await model.setArchived(true)
183
184 let write = try #require(stub.seen.first { $0.method == "POST" })
185 #expect(try argvOf(write) == ["repo", "archive", "krz/gitbay"])
186 }
187}
188
189@MainActor
190struct RepoCreateViewModelTests {
191
192 @Test func createSendsPathAndPrivateFlag() async throws {
193 let (client, stub) = try makeClient()
194 stub.enqueue(.init(status: 200, json:
195 #"{"protocol_version":1,"data":{"path":"cmc/notes","visibility":"private","ssh_url":"ssh://git@gitbay.org/cmc/notes.git"},"exit_code":0}"#))
196 let model = RepoCreateViewModel(client: client)
197
198 let path = await model.create(path: " cmc/notes ", isPrivate: true)
199
200 #expect(path == "cmc/notes")
201 let write = try #require(stub.seen.first)
202 #expect(try argvOf(write) == ["repo", "create", "cmc/notes", "--private"])
203 }
204
205 @Test func publicCreateOmitsTheFlag() async throws {
206 let (client, stub) = try makeClient()
207 stub.enqueue(.init(status: 200, json:
208 #"{"protocol_version":1,"data":{"path":"cmc/notes","visibility":"public","ssh_url":"x"},"exit_code":0}"#))
209 let model = RepoCreateViewModel(client: client)
210
211 _ = await model.create(path: "cmc/notes", isPrivate: false)
212
213 #expect(try argvOf(try #require(stub.seen.first)) == ["repo", "create", "cmc/notes"])
214 }
215
216 @Test func aRefusalSurfaces() async throws {
217 let (client, stub) = try makeClient()
218 stub.enqueue(.init(status: 403, json:
219 #"{"protocol_version":1,"error":"you cannot create repositories under krz","exit_code":4}"#))
220 let model = RepoCreateViewModel(client: client)
221
222 let path = await model.create(path: "krz/nope", isPrivate: false)
223
224 #expect(path == nil)
225 #expect(model.errorMessage == "you cannot create repositories under krz")
226 }
227}
228
229@MainActor
230struct BuildTriggerTests {
231
232 private let buildListJSON = """
233 {"protocol_version":1,"data":[\
234 {"number":3,"job":"ci","status":"success","sha":"65ba14e0000000000000",\
235 "ref":"refs/heads/main","created_at":"2026-08-20T10:00:00.000Z"}],"exit_code":0}
236 """
237
238 @Test func triggerSendsTheJobAndReloads() async throws {
239 let (client, stub) = try makeClient()
240 stub.enqueue(.init(status: 200, json: buildListJSON, match: "argv=build&argv=list"))
241 let model = BuildListViewModel(client: client, repoPath: "krz/gitbay")
242 await model.load()
243
244 stub.enqueue(.init(status: 200, json:
245 #"{"protocol_version":1,"data":{"build":4,"job":"ci","sha":"aa"},"exit_code":0}"#,
246 match: "cmd"))
247 stub.enqueue(.init(status: 200, json: buildListJSON, match: "argv=build&argv=list"))
248
249 await model.trigger(job: "ci")
250
251 let write = try #require(stub.seen.first { $0.method == "POST" })
252 #expect(try argvOf(write) == ["build", "trigger", "krz/gitbay", "ci"])
253 #expect(model.actionError == nil)
254 }
255
256 @Test func anUnknownJobSurfacesAsAnError() async throws {
257 let (client, stub) = try makeClient()
258 stub.enqueue(.init(status: 200, json: buildListJSON, match: "argv=build&argv=list"))
259 let model = BuildListViewModel(client: client, repoPath: "krz/gitbay")
260 await model.load()
261 stub.enqueue(.init(status: 404, json:
262 #"{"protocol_version":1,"error":"no job \"deploy\" in .gitbay/ci.yml","exit_code":3}"#,
263 match: "cmd"))
264
265 await model.trigger(job: "deploy")
266
267 #expect(model.actionError == #"no job "deploy" in .gitbay/ci.yml"#)
268 }
269}