a native ios client for gitbay

client ios swift

https://gitbay.org

gitbayTests/IssueBuildViewModelTests.swift

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

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}