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 createdJSON = #"{"protocol_version":1,"data":{"number":7},"exit_code":0}"# private let okJSON = #"{"protocol_version":1,"data":{},"exit_code":0}"# private func argvOf(_ seen: StubProtocol.Seen) throws -> ([String], String?) { let body = try #require(try JSONSerialization.jsonObject(with: seen.body) as? [String: Any]) return (try #require(body["argv"] as? [String]), body["stdin"] as? String) } @MainActor struct IssueCreateViewModelTests { @Test func createSendsTitleInArgvAndBodyOverStdin() async throws { let (client, stub) = try makeClient() stub.enqueue(.init(status: 200, json: createdJSON)) let model = IssueCreateViewModel(client: client, repoPath: "krz/gitbay") let number = await model.create(title: "org READMEs", body: "render them properly") #expect(number == 7) let (argv, stdin) = try argvOf(try #require(stub.seen.first)) #expect(argv == ["issue", "create", "krz/gitbay", "--title", "org READMEs", "--file", "-"]) #expect(stdin == "render them properly") } @Test func anEmptyBodyOmitsStdinAndTheFileFlag() async throws { let (client, stub) = try makeClient() stub.enqueue(.init(status: 200, json: createdJSON)) let model = IssueCreateViewModel(client: client, repoPath: "krz/gitbay") _ = await model.create(title: "just a title", body: "") let (argv, stdin) = try argvOf(try #require(stub.seen.first)) #expect(argv == ["issue", "create", "krz/gitbay", "--title", "just a title"]) #expect(stdin == nil) } @Test func aRefusalSurfacesAndReturnsNil() async throws { let (client, stub) = try makeClient() stub.enqueue(.init(status: 403, json: #"{"protocol_version":1,"error":"krz/gitbay is archived and read-only","exit_code":4}"#)) let model = IssueCreateViewModel(client: client, repoPath: "krz/gitbay") let number = await model.create(title: "t", body: "b") #expect(number == nil) #expect(model.errorMessage == "krz/gitbay is archived and read-only") } } @MainActor struct MRCreateViewModelTests { @Test func targetPrefillsFromRepoShow() async throws { let (client, stub) = try makeClient() stub.enqueue(.init(status: 200, json: """ {"protocol_version":1,"data":{"path":"krz/gitbay","visibility":"public",\ "default_branch":"main"},"exit_code":0} """)) let model = MRCreateViewModel(client: client, repoPath: "krz/gitbay") await model.loadDefaultBranch() #expect(model.defaultBranch == "main") } @Test func createSendsBranchesTitleAndBody() async throws { let (client, stub) = try makeClient() stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"data":{"number":4,"head_sha":"aa"},"exit_code":0}"#)) let model = MRCreateViewModel(client: client, repoPath: "krz/gitbay") let number = await model.create( source: "fix-thing", target: "main", title: "fix the thing", body: "details" ) #expect(number == 4) let (argv, stdin) = try argvOf(try #require(stub.seen.first)) #expect(argv == ["mr", "create", "krz/gitbay", "--source", "fix-thing", "--target", "main", "--title", "fix the thing", "--file", "-"]) #expect(stdin == "details") } } @MainActor struct EditAndMilestoneTests { private let issueShow = """ {"protocol_version":1,"data":{"number":11,"title":"iOS app","state":"open",\ "author":"krz","body":"Build it.","created_at":"2026-08-20T10:00:00.000Z"},"exit_code":0} """ @Test func issueEditSendsTitleAndBodyAlways() async throws { let (client, stub) = try makeClient() stub.enqueue(.init(status: 200, json: issueShow, match: "argv=show")) let model = IssueDetailViewModel(client: client, repoPath: "krz/gitbay", number: 11) await model.load() stub.enqueue(.init(status: 200, json: okJSON, match: "cmd")) stub.enqueue(.init(status: 200, json: issueShow, match: "argv=show")) await model.edit(title: "iOS app (v1)", body: "") let write = try #require(stub.seen.first { $0.method == "POST" }) let (argv, stdin) = try argvOf(write) #expect(argv == ["issue", "edit", "krz/gitbay", "11", "--title", "iOS app (v1)", "--file", "-"]) #expect(stdin == "") // empty body clears, matching the CLI } @Test func milestoneSetAndClearSpellNoneCorrectly() async throws { let (client, stub) = try makeClient() stub.enqueue(.init(status: 200, json: issueShow, match: "argv=show")) let model = IssueDetailViewModel(client: client, repoPath: "krz/gitbay", number: 11) await model.load() for _ in 0..<2 { stub.enqueue(.init(status: 200, json: okJSON, match: "cmd")) stub.enqueue(.init(status: 200, json: issueShow, match: "argv=show")) } await model.setMilestone("v1.0.0") await model.setMilestone(nil) let writes = try stub.seen.filter { $0.method == "POST" }.map { try argvOf($0).0 } #expect(writes[0] == ["issue", "milestone", "krz/gitbay", "11", "v1.0.0"]) #expect(writes[1] == ["issue", "milestone", "krz/gitbay", "11", "none"]) } @Test func milestonesLoadOnceForThePicker() async throws { let (client, stub) = try makeClient() stub.enqueue(.init(status: 200, json: issueShow, match: "argv=show")) let model = IssueDetailViewModel(client: client, repoPath: "krz/gitbay", number: 11) await model.load() stub.enqueue(.init(status: 200, json: """ {"protocol_version":1,"data":[{"title":"v1.0.0","state":"open","open":3,"closed":5}],\ "exit_code":0} """, match: "argv=milestone")) await model.loadMilestones() await model.loadMilestones() // second call must not refetch #expect(model.availableMilestones?.first?.title == "v1.0.0") #expect(stub.seen.count { ($0.url.query() ?? "").contains("argv=milestone") } == 1) } @Test func mrEditSendsTitleAndBody() async throws { let (client, stub) = try makeClient() let mrShow = """ {"protocol_version":1,"data":{"number":7,"title":"old","state":"open","author":"cmc",\ "source":"b","target_ref":"main","head_sha":"aa","created_at":"2026-08-20T10:00:00.000Z"},\ "exit_code":0} """ stub.enqueue(.init(status: 200, json: mrShow, match: "argv=show")) stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"output":"","exit_code":0}"#, match: "argv=diff")) stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"exit_code":0}"#, match: "argv=threads")) let model = MRDetailViewModel(client: client, repoPath: "krz/gitbay", number: 7) await model.load() stub.enqueue(.init(status: 200, json: okJSON, match: "cmd")) stub.enqueue(.init(status: 200, json: mrShow, match: "argv=show")) stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"output":"","exit_code":0}"#, match: "argv=diff")) stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"exit_code":0}"#, match: "argv=threads")) await model.edit(title: "new title", body: "new body") let write = try #require(stub.seen.first { $0.method == "POST" }) let (argv, stdin) = try argvOf(write) #expect(argv == ["mr", "edit", "krz/gitbay", "7", "--title", "new title", "--file", "-"]) #expect(stdin == "new body") } }