gitbayTests/IssueBuildViewModelTests.swift
246 lines · 9911 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":[\
34 {"number":3,"job":"ci","status":"success","sha":"65ba14e0000000000000",\
35 "ref":"refs/heads/main","created_at":"2026-08-20T10:00:00.000Z",\
36 "finished_at":"2026-08-20T10:05:00.000Z"},\
37 {"number":4,"job":"ci","status":"running","sha":"7953e780000000000000",\
38 "ref":"refs/heads/main","created_at":"2026-08-20T11:00: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 = stub.seen[1]
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 listsNewestFirst() 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
164@MainActor
165struct BuildJobsTests {
166
167 @Test func offersTheJobsTheServerNames() async throws {
168 let (client, stub) = try makeClient()
169 stub.enqueue(.init(status: 200, json: """
170 {"protocol_version":1,"data":[{"name":"build"},\
171 {"name":"nightly","schedule":"0 3 * * *"},\
172 {"name":"release","tags":"v*"}],"exit_code":0}
173 """))
174 let model = BuildListViewModel(client: client, repoPath: "krz/gitbay")
175
176 await model.loadJobs()
177
178 #expect(model.jobs.map(\.name) == ["build", "nightly", "release"])
179 // The picker says why a job is not push-triggered.
180 #expect(model.jobs[0].trigger == "on push")
181 #expect(model.jobs[1].trigger == "schedule 0 3 * * *")
182 #expect(model.jobs[2].trigger == "tags v*")
183 #expect(stub.seen.first?.url.query() == "argv=build&argv=jobs&argv=krz/gitbay")
184 }
185
186 @Test func aRepoWithNoCIConfigHasNoJobsRatherThanAnError() async throws {
187 let (client, stub) = try makeClient()
188 stub.enqueue(.init(status: 404, json:
189 #"{"protocol_version":1,"error":"krz/quiet has no .gitbay/ci.yml on main","exit_code":3}"#))
190 let model = BuildListViewModel(client: client, repoPath: "krz/quiet")
191
192 await model.loadJobs()
193
194 #expect(model.jobs.isEmpty)
195 // The list itself must stay usable; only the trigger is gated.
196 #expect(model.actionError == nil)
197 }
198}
199
200@MainActor
201struct BuildDetailViewModelTests {
202
203 private let showJSON = """
204 {"protocol_version":1,"data":{"number":60,"job":"build","status":"success",\
205 "sha":"ff6271a9d4570cd46f169091637a9d2e40ad5c2a","ref":"cli-coverage",\
206 "created_at":"2026-08-28T04:42:54.000Z","finished_at":"2026-08-28T04:43:06.000Z"},\
207 "exit_code":0}
208 """
209
210 @Test func showsWhatTheBuildWasAndWhatItPrinted() async throws {
211 let (client, stub) = try makeClient()
212 stub.enqueue(.init(status: 200, json: showJSON, match: "argv=build&argv=show"))
213 stub.enqueue(.init(status: 200, json:
214 #"{"protocol_version":1,"output":"step 1 ok\n","exit_code":0}"#,
215 match: "argv=build&argv=log"))
216 let model = BuildDetailViewModel(client: client, repoPath: "krz/gitbay", number: 60)
217
218 await model.load()
219
220 let detail = try #require(model.state.value)
221 #expect(detail.build.status == "success")
222 #expect(detail.build.ref == "cli-coverage")
223 #expect(detail.build.finishedAt != nil)
224 #expect(detail.log == "step 1 ok\n")
225 }
226
227 @Test func aQueuedBuildWithNoLogYetStillShowsItsHeader() async throws {
228 let (client, stub) = try makeClient()
229 stub.enqueue(.init(status: 200, json: """
230 {"protocol_version":1,"data":{"number":61,"job":"build","status":"queued",\
231 "sha":"aa00000000","ref":"main","created_at":"2026-08-28T05:00:00.000Z"},\
232 "exit_code":0}
233 """, match: "argv=build&argv=show"))
234 stub.enqueue(.init(status: 404, json:
235 #"{"protocol_version":1,"error":"no log","exit_code":3}"#,
236 match: "argv=build&argv=log"))
237 let model = BuildDetailViewModel(client: client, repoPath: "krz/gitbay", number: 61)
238
239 await model.load()
240
241 // A missing log must not take the whole screen down.
242 let detail = try #require(model.state.value)
243 #expect(detail.build.status == "queued")
244 #expect(detail.log.isEmpty)
245 }
246}