gitbayTests/IssueBuildViewModelTests.swift
277 lines · 11272 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 let issueListJSON = """
16 {"protocol_version":1,"data":{"items":[\
17 {"number":11,"title":"iOS app","state":"open","author":"krz",\
18 "labels":["app"],"assignees":["cmc"],"created_at":"2026-08-20T10:00:00.000Z"},\
19 {"number":40,"title":"pagination","state":"open","author":"krz",\
20 "created_at":"2026-08-21T10:00:00.000Z"}\
21 ]},"exit_code":0}
22 """
23
24private let issueShowJSON = """
25 {"protocol_version":1,"data":{"number":11,"title":"iOS app","state":"open",\
26 "author":"krz","labels":["app"],"assignees":["cmc"],"body":"Build it.",\
27 "created_at":"2026-08-20T10:00:00.000Z",\
28 "comments":[{"author":"cmc","body":"started","created_at":"2026-08-22T09:00:00.000Z"}]},\
29 "exit_code":0}
30 """
31
32private let buildListJSON = """
33 {"protocol_version":1,"data":{"items":[\
34 {"number":4,"job":"ci","status":"running","sha":"7953e780000000000000",\
35 "ref":"refs/heads/main","created_at":"2026-08-20T11:00:00.000Z"},\
36 {"number":3,"job":"ci","status":"success","sha":"65ba14e0000000000000",\
37 "ref":"refs/heads/main","created_at":"2026-08-20T10:00:00.000Z",\
38 "finished_at":"2026-08-20T10:05:00.000Z"}\
39 ]},"exit_code":0}
40 """
41
42@MainActor
43struct IssueListViewModelTests {
44
45 @Test func listsInServerOrderWithLabelsAndAssignees() async throws {
46 let (client, stub) = try makeClient()
47 stub.enqueue(.init(status: 200, json: issueListJSON))
48 let model = IssueListViewModel(client: client, repoPath: "krz/gitbay")
49
50 await model.load()
51
52 let issues = try #require(model.state.value)
53 #expect(issues.map(\.number) == [11, 40])
54 #expect(issues[0].labels == ["app"])
55 #expect(issues[0].assignees == ["cmc"])
56 #expect(stub.seen.first?.url.query() ==
57 "argv=issue&argv=list&argv=krz/gitbay&argv=--state&argv=open&argv=--limit&argv=50")
58 }
59}
60
61@MainActor
62struct IssueDetailViewModelTests {
63
64 private func loadedModel() async throws -> (IssueDetailViewModel, StubProtocol.Box) {
65 let (client, stub) = try makeClient()
66 stub.enqueue(.init(status: 200, json: issueShowJSON))
67 let model = IssueDetailViewModel(client: client, repoPath: "krz/gitbay", number: 11)
68 await model.load()
69 return (model, stub)
70 }
71
72 @Test func loadsIssueWithComments() async throws {
73 let (model, _) = try await loadedModel()
74
75 let issue = try #require(model.state.value)
76 #expect(issue.title == "iOS app")
77 #expect(issue.comments?.count == 1)
78 }
79
80 @Test func commentTravelsInStdin() async throws {
81 let (model, stub) = try await loadedModel()
82 stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"data":{},"exit_code":0}"#))
83 stub.enqueue(.init(status: 200, json: issueShowJSON))
84
85 await model.comment("triaged from the phone")
86
87 let write = try #require(stub.seen.first { $0.method == "POST" })
88 #expect(write.method == "POST")
89 let body = try #require(try JSONSerialization.jsonObject(with: write.body) as? [String: Any])
90 #expect(body["argv"] as? [String] == ["issue", "comment", "krz/gitbay", "11", "--file", "-"])
91 #expect(body["stdin"] as? String == "triaged from the phone")
92 }
93
94 @Test func labelAndAssigneeEditsUseAddRemoveFlags() async throws {
95 let (model, stub) = try await loadedModel()
96 for _ in 0..<4 {
97 stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"data":{},"exit_code":0}"#))
98 stub.enqueue(.init(status: 200, json: issueShowJSON))
99 }
100
101 await model.addLabel("bug")
102 await model.removeLabel("app")
103 await model.addAssignee("krz")
104 await model.removeAssignee("cmc")
105
106 let writes = stub.seen.filter { $0.method == "POST" }
107 let argvs = try writes.map {
108 try #require(try JSONSerialization.jsonObject(with: $0.body) as? [String: Any])["argv"] as? [String]
109 }
110 #expect(argvs[0] == ["issue", "label", "krz/gitbay", "11", "--add", "bug"])
111 #expect(argvs[1] == ["issue", "label", "krz/gitbay", "11", "--remove", "app"])
112 #expect(argvs[2] == ["issue", "assign", "krz/gitbay", "11", "--add", "krz"])
113 #expect(argvs[3] == ["issue", "assign", "krz/gitbay", "11", "--remove", "cmc"])
114 }
115
116 @Test func closeAndReopenAreSeparateCommands() async throws {
117 let (model, stub) = try await loadedModel()
118 stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"data":{},"exit_code":0}"#))
119 stub.enqueue(.init(status: 200, json: issueShowJSON))
120 stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"data":{},"exit_code":0}"#))
121 stub.enqueue(.init(status: 200, json: issueShowJSON))
122
123 await model.close()
124 await model.reopen()
125
126 let writes = stub.seen.filter { $0.method == "POST" }
127 let argvs = try writes.map {
128 try #require(try JSONSerialization.jsonObject(with: $0.body) as? [String: Any])["argv"] as? [String]
129 }
130 #expect(argvs[0] == ["issue", "close", "krz/gitbay", "11"])
131 #expect(argvs[1] == ["issue", "reopen", "krz/gitbay", "11"])
132 }
133
134 @Test func aDeniedTriageActionSurfacesTheRule() async throws {
135 let (model, stub) = try await loadedModel()
136 stub.enqueue(.init(status: 403, json:
137 #"{"protocol_version":1,"error":"krz/gitbay is archived and read-only","exit_code":4}"#))
138
139 await model.close()
140
141 #expect(model.actionError == "krz/gitbay is archived and read-only")
142 #expect(model.state.value != nil)
143 }
144}
145
146@MainActor
147struct BuildListViewModelTests {
148
149 @Test func decodesEachRow() async throws {
150 let (client, stub) = try makeClient()
151 stub.enqueue(.init(status: 200, json: buildListJSON))
152 let model = BuildListViewModel(client: client, repoPath: "krz/gitbay")
153
154 await model.load()
155
156 let builds = try #require(model.state.value)
157 #expect(builds.map(\.number) == [4, 3])
158 #expect(builds[0].status == "running")
159 #expect(builds[0].finishedAt == nil)
160 #expect(builds[1].finishedAt != nil)
161 }
162
163 @Test func changingTheFilterReloadsWithItsFlags() async throws {
164 let (client, stub) = try makeClient()
165 stub.enqueue(.init(status: 200, json: buildListJSON))
166 let model = BuildListViewModel(client: client, repoPath: "krz/gitbay")
167 await model.load()
168
169 stub.enqueue(.init(status: 200, json: """
170 {"protocol_version":1,"data":{"items":[]},"exit_code":0}
171 """))
172 var filter = BuildFilter()
173 filter.status = .failure
174 filter.job = "test"
175 model.filter = filter
176 // The reload runs in a Task that has not started when `filter` is
177 // assigned, so a "not loading" check would pass before it finished:
178 // wait for the result itself.
179 await until {
180 if case .empty = model.state { return true }
181 return false
182 }
183
184 let second = try #require(stub.seen.dropFirst().first)
185 #expect(second.url.query()
186 == "argv=build&argv=list&argv=krz/gitbay&argv=--status&argv=failure&argv=--job&argv=test&argv=--limit&argv=30")
187 guard case .empty(let message) = model.state else {
188 Issue.record("expected .empty after filtering, got \(model.state)")
189 return
190 }
191 #expect(message == "No builds match these filters.")
192 }
193}
194
195@MainActor
196struct BuildJobsTests {
197
198 @Test func offersTheJobsTheServerNames() async throws {
199 let (client, stub) = try makeClient()
200 stub.enqueue(.init(status: 200, json: """
201 {"protocol_version":1,"data":[{"name":"build"},\
202 {"name":"nightly","schedule":"0 3 * * *"},\
203 {"name":"release","tags":"v*"}],"exit_code":0}
204 """))
205 let model = BuildListViewModel(client: client, repoPath: "krz/gitbay")
206
207 await model.loadJobs()
208
209 #expect(model.jobs.map(\.name) == ["build", "nightly", "release"])
210 // The picker says why a job is not push-triggered.
211 #expect(model.jobs[0].trigger == "on push")
212 #expect(model.jobs[1].trigger == "schedule 0 3 * * *")
213 #expect(model.jobs[2].trigger == "tags v*")
214 #expect(stub.seen.first?.url.query() == "argv=build&argv=jobs&argv=krz/gitbay")
215 }
216
217 @Test func aRepoWithNoCIConfigHasNoJobsRatherThanAnError() async throws {
218 let (client, stub) = try makeClient()
219 stub.enqueue(.init(status: 404, json:
220 #"{"protocol_version":1,"error":"krz/quiet has no .gitbay/ci.yml on main","exit_code":3}"#))
221 let model = BuildListViewModel(client: client, repoPath: "krz/quiet")
222
223 await model.loadJobs()
224
225 #expect(model.jobs.isEmpty)
226 // The list itself must stay usable; only the trigger is gated.
227 #expect(model.actionError == nil)
228 }
229}
230
231@MainActor
232struct BuildDetailViewModelTests {
233
234 private let showJSON = """
235 {"protocol_version":1,"data":{"number":60,"job":"build","status":"success",\
236 "sha":"ff6271a9d4570cd46f169091637a9d2e40ad5c2a","ref":"cli-coverage",\
237 "created_at":"2026-08-28T04:42:54.000Z","finished_at":"2026-08-28T04:43:06.000Z"},\
238 "exit_code":0}
239 """
240
241 @Test func showsWhatTheBuildWasAndWhatItPrinted() async throws {
242 let (client, stub) = try makeClient()
243 stub.enqueue(.init(status: 200, json: showJSON, match: "argv=build&argv=show"))
244 stub.enqueue(.init(status: 200, json:
245 #"{"protocol_version":1,"output":"step 1 ok\n","exit_code":0}"#,
246 match: "argv=build&argv=log"))
247 let model = BuildDetailViewModel(client: client, repoPath: "krz/gitbay", number: 60)
248
249 await model.load()
250
251 let detail = try #require(model.state.value)
252 #expect(detail.build.status == "success")
253 #expect(detail.build.ref == "cli-coverage")
254 #expect(detail.build.finishedAt != nil)
255 #expect(detail.log == "step 1 ok\n")
256 }
257
258 @Test func aQueuedBuildWithNoLogYetStillShowsItsHeader() async throws {
259 let (client, stub) = try makeClient()
260 stub.enqueue(.init(status: 200, json: """
261 {"protocol_version":1,"data":{"number":61,"job":"build","status":"queued",\
262 "sha":"aa00000000","ref":"main","created_at":"2026-08-28T05:00:00.000Z"},\
263 "exit_code":0}
264 """, match: "argv=build&argv=show"))
265 stub.enqueue(.init(status: 404, json:
266 #"{"protocol_version":1,"error":"no log","exit_code":3}"#,
267 match: "argv=build&argv=log"))
268 let model = BuildDetailViewModel(client: client, repoPath: "krz/gitbay", number: 61)
269
270 await model.load()
271
272 // A missing log must not take the whole screen down.
273 let detail = try #require(model.state.value)
274 #expect(detail.build.status == "queued")
275 #expect(detail.log.isEmpty)
276 }
277}