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) } }