import Foundation import Testing @testable import gitbay private func makeClient() throws -> (GitbayClient, StubProtocol.Box) { let box = StubProtocol.box() let client = GitbayClient( instance: try GitbayInstance(url: "https://gitbay.org"), token: "test-token", session: box.session() ) return (client, box) } private let issueListJSON = """ {"protocol_version":1,"data":{"items":[\ {"number":11,"title":"iOS app","state":"open","author":"krz",\ "labels":["app"],"assignees":["cmc"],"created_at":"2026-08-20T10:00:00.000Z"},\ {"number":40,"title":"pagination","state":"open","author":"krz",\ "created_at":"2026-08-21T10:00:00.000Z"}\ ]},"exit_code":0} """ private let issueShowJSON = """ {"protocol_version":1,"data":{"number":11,"title":"iOS app","state":"open",\ "author":"krz","labels":["app"],"assignees":["cmc"],"body":"Build it.",\ "created_at":"2026-08-20T10:00:00.000Z",\ "comments":[{"author":"cmc","body":"started","created_at":"2026-08-22T09:00:00.000Z"}]},\ "exit_code":0} """ private let buildListJSON = """ {"protocol_version":1,"data":[\ {"number":3,"job":"ci","status":"success","sha":"65ba14e0000000000000",\ "ref":"refs/heads/main","created_at":"2026-08-20T10:00:00.000Z",\ "finished_at":"2026-08-20T10:05:00.000Z"},\ {"number":4,"job":"ci","status":"running","sha":"7953e780000000000000",\ "ref":"refs/heads/main","created_at":"2026-08-20T11:00:00.000Z"}\ ],"exit_code":0} """ @MainActor struct IssueListViewModelTests { @Test func listsInServerOrderWithLabelsAndAssignees() async throws { let (client, stub) = try makeClient() stub.enqueue(.init(status: 200, json: issueListJSON)) let model = IssueListViewModel(client: client, repoPath: "krz/gitbay") await model.load() let issues = try #require(model.state.value) #expect(issues.map(\.number) == [11, 40]) #expect(issues[0].labels == ["app"]) #expect(issues[0].assignees == ["cmc"]) #expect(stub.seen.first?.url.query() == "argv=issue&argv=list&argv=krz/gitbay&argv=--state&argv=open&argv=--limit&argv=50") } } @MainActor struct IssueDetailViewModelTests { private func loadedModel() async throws -> (IssueDetailViewModel, StubProtocol.Box) { let (client, stub) = try makeClient() stub.enqueue(.init(status: 200, json: issueShowJSON)) let model = IssueDetailViewModel(client: client, repoPath: "krz/gitbay", number: 11) await model.load() return (model, stub) } @Test func loadsIssueWithComments() async throws { let (model, _) = try await loadedModel() let issue = try #require(model.state.value) #expect(issue.title == "iOS app") #expect(issue.comments?.count == 1) } @Test func commentTravelsInStdin() async throws { let (model, stub) = try await loadedModel() stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"data":{},"exit_code":0}"#)) stub.enqueue(.init(status: 200, json: issueShowJSON)) await model.comment("triaged from the phone") let write = stub.seen[1] #expect(write.method == "POST") let body = try #require(try JSONSerialization.jsonObject(with: write.body) as? [String: Any]) #expect(body["argv"] as? [String] == ["issue", "comment", "krz/gitbay", "11", "--file", "-"]) #expect(body["stdin"] as? String == "triaged from the phone") } @Test func labelAndAssigneeEditsUseAddRemoveFlags() async throws { let (model, stub) = try await loadedModel() for _ in 0..<4 { stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"data":{},"exit_code":0}"#)) stub.enqueue(.init(status: 200, json: issueShowJSON)) } await model.addLabel("bug") await model.removeLabel("app") await model.addAssignee("krz") await model.removeAssignee("cmc") let writes = stub.seen.filter { $0.method == "POST" } let argvs = try writes.map { try #require(try JSONSerialization.jsonObject(with: $0.body) as? [String: Any])["argv"] as? [String] } #expect(argvs[0] == ["issue", "label", "krz/gitbay", "11", "--add", "bug"]) #expect(argvs[1] == ["issue", "label", "krz/gitbay", "11", "--remove", "app"]) #expect(argvs[2] == ["issue", "assign", "krz/gitbay", "11", "--add", "krz"]) #expect(argvs[3] == ["issue", "assign", "krz/gitbay", "11", "--remove", "cmc"]) } @Test func closeAndReopenAreSeparateCommands() async throws { let (model, stub) = try await loadedModel() stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"data":{},"exit_code":0}"#)) stub.enqueue(.init(status: 200, json: issueShowJSON)) stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"data":{},"exit_code":0}"#)) stub.enqueue(.init(status: 200, json: issueShowJSON)) await model.close() await model.reopen() let writes = stub.seen.filter { $0.method == "POST" } let argvs = try writes.map { try #require(try JSONSerialization.jsonObject(with: $0.body) as? [String: Any])["argv"] as? [String] } #expect(argvs[0] == ["issue", "close", "krz/gitbay", "11"]) #expect(argvs[1] == ["issue", "reopen", "krz/gitbay", "11"]) } @Test func aDeniedTriageActionSurfacesTheRule() async throws { let (model, stub) = try await loadedModel() stub.enqueue(.init(status: 403, json: #"{"protocol_version":1,"error":"krz/gitbay is archived and read-only","exit_code":4}"#)) await model.close() #expect(model.actionError == "krz/gitbay is archived and read-only") #expect(model.state.value != nil) } } @MainActor struct BuildListViewModelTests { @Test func listsNewestFirst() async throws { let (client, stub) = try makeClient() stub.enqueue(.init(status: 200, json: buildListJSON)) let model = BuildListViewModel(client: client, repoPath: "krz/gitbay") await model.load() let builds = try #require(model.state.value) #expect(builds.map(\.number) == [4, 3]) #expect(builds[0].status == "running") #expect(builds[0].finishedAt == nil) #expect(builds[1].finishedAt != nil) } } @MainActor struct BuildJobsTests { @Test func offersTheJobsTheServerNames() async throws { let (client, stub) = try makeClient() stub.enqueue(.init(status: 200, json: """ {"protocol_version":1,"data":[{"name":"build"},\ {"name":"nightly","schedule":"0 3 * * *"},\ {"name":"release","tags":"v*"}],"exit_code":0} """)) let model = BuildListViewModel(client: client, repoPath: "krz/gitbay") await model.loadJobs() #expect(model.jobs.map(\.name) == ["build", "nightly", "release"]) // The picker says why a job is not push-triggered. #expect(model.jobs[0].trigger == "on push") #expect(model.jobs[1].trigger == "schedule 0 3 * * *") #expect(model.jobs[2].trigger == "tags v*") #expect(stub.seen.first?.url.query() == "argv=build&argv=jobs&argv=krz/gitbay") } @Test func aRepoWithNoCIConfigHasNoJobsRatherThanAnError() async throws { let (client, stub) = try makeClient() stub.enqueue(.init(status: 404, json: #"{"protocol_version":1,"error":"krz/quiet has no .gitbay/ci.yml on main","exit_code":3}"#)) let model = BuildListViewModel(client: client, repoPath: "krz/quiet") await model.loadJobs() #expect(model.jobs.isEmpty) // The list itself must stay usable; only the trigger is gated. #expect(model.actionError == nil) } } @MainActor struct BuildDetailViewModelTests { private let showJSON = """ {"protocol_version":1,"data":{"number":60,"job":"build","status":"success",\ "sha":"ff6271a9d4570cd46f169091637a9d2e40ad5c2a","ref":"cli-coverage",\ "created_at":"2026-08-28T04:42:54.000Z","finished_at":"2026-08-28T04:43:06.000Z"},\ "exit_code":0} """ @Test func showsWhatTheBuildWasAndWhatItPrinted() async throws { let (client, stub) = try makeClient() stub.enqueue(.init(status: 200, json: showJSON, match: "argv=build&argv=show")) stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"output":"step 1 ok\n","exit_code":0}"#, match: "argv=build&argv=log")) let model = BuildDetailViewModel(client: client, repoPath: "krz/gitbay", number: 60) await model.load() let detail = try #require(model.state.value) #expect(detail.build.status == "success") #expect(detail.build.ref == "cli-coverage") #expect(detail.build.finishedAt != nil) #expect(detail.log == "step 1 ok\n") } @Test func aQueuedBuildWithNoLogYetStillShowsItsHeader() async throws { let (client, stub) = try makeClient() stub.enqueue(.init(status: 200, json: """ {"protocol_version":1,"data":{"number":61,"job":"build","status":"queued",\ "sha":"aa00000000","ref":"main","created_at":"2026-08-28T05:00:00.000Z"},\ "exit_code":0} """, match: "argv=build&argv=show")) stub.enqueue(.init(status: 404, json: #"{"protocol_version":1,"error":"no log","exit_code":3}"#, match: "argv=build&argv=log")) let model = BuildDetailViewModel(client: client, repoPath: "krz/gitbay", number: 61) await model.load() // A missing log must not take the whole screen down. let detail = try #require(model.state.value) #expect(detail.build.status == "queued") #expect(detail.log.isEmpty) } }