gitbayTests/IssueBuildViewModelTests.swift

v1.13.0
gitbay-ios/gitbayTests/IssueBuildViewModelTests.swift history · blame · raw

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}