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 func argvOf(_ seen: StubProtocol.Seen) throws -> [String] { let body = try #require(try JSONSerialization.jsonObject(with: seen.body) as? [String: Any]) return try #require(body["argv"] as? [String]) } private func stdinOf(_ seen: StubProtocol.Seen) throws -> String? { let body = try #require(try JSONSerialization.jsonObject(with: seen.body) as? [String: Any]) return body["stdin"] as? String } struct BodyFormatDecodingTests { private func decodeIssue(_ json: String) throws -> IssueDetail { let decoder = JSONDecoder() decoder.dateDecodingStrategy = .iso8601 return try decoder.decode(IssueDetail.self, from: Data(json.utf8)) } private func decodeMR(_ json: String) throws -> MRDetail { let decoder = JSONDecoder() decoder.dateDecodingStrategy = .iso8601 return try decoder.decode(MRDetail.self, from: Data(json.utf8)) } /// body_format is omitempty; an ordinary body carries no key. @Test func anIssueWithoutAStoredFormatDecodes() throws { let issue = try decodeIssue(""" {"number":7,"title":"t","state":"open","author":"cmc",\ "body":"x","created_at":"2026-09-01T00:00:00Z"} """) #expect(issue.bodyFormat == nil) } @Test func anIssueStoredAsOrgSaysSo() throws { let issue = try decodeIssue(""" {"number":7,"title":"t","state":"open","author":"cmc",\ "body":"x","body_format":"org","created_at":"2026-09-01T00:00:00Z"} """) #expect(issue.bodyFormat == "org") } @Test func aMergeRequestWithoutAStoredFormatDecodes() throws { let mr = try decodeMR(""" {"number":7,"title":"t","state":"open","author":"cmc","source":"f",\ "target_ref":"main","head_sha":"a","created_at":"2026-09-01T00:00:00Z"} """) #expect(mr.bodyFormat == nil) } /// MRDetail's hand-written initialiser must still decode everything /// it did before the new field was added. @Test func addingTheFormatDidNotDropAnyMergeRequestField() throws { let mr = try decodeMR(""" {"number":7,"title":"t","state":"open","draft":true,"author":"cmc","source":"f",\ "target_ref":"main","head_sha":"abc","body":"b","body_format":"org",\ "milestone":"v1","review_requests":["rae"],\ "stacked_on":{"number":6,"title":"below"},\ "stacked":[{"number":8,"title":"above"}],\ "created_at":"2026-09-01T00:00:00Z","merged_at":"2026-09-02T00:00:00Z",\ "merged_by":"cmc","checks_combined":"success","unresolved_threads":2} """) #expect(mr.bodyFormat == "org") #expect(mr.draft) #expect(mr.milestone == "v1") #expect(mr.reviewRequests == ["rae"]) #expect(mr.stackedOn?.number == 6) #expect(mr.stacked.map(\.number) == [8]) #expect(mr.mergedBy == "cmc") #expect(mr.checksCombined == "success") #expect(mr.unresolvedThreads == 2) } /// body_format is omitempty on a comment too; an ordinary one carries no key. @Test func anIssueCommentWithoutAStoredFormatDecodesNil() throws { let issue = try decodeIssue(""" {"number":7,"title":"t","state":"open","author":"cmc",\ "created_at":"2026-09-01T00:00:00Z","comments":[{"author":"rae",\ "body":"hi","created_at":"2026-09-01T00:00:00Z"}]} """) #expect(issue.comments?.first?.bodyFormat == nil) } @Test func anIssueCommentStoredAsOrgSaysSo() throws { let issue = try decodeIssue(""" {"number":7,"title":"t","state":"open","author":"cmc",\ "created_at":"2026-09-01T00:00:00Z","comments":[{"author":"rae",\ "body":"hi","body_format":"org","created_at":"2026-09-01T00:00:00Z"}]} """) #expect(issue.comments?.first?.bodyFormat == "org") } @Test func aMergeRequestCommentWithoutAStoredFormatDecodesNil() throws { let mr = try decodeMR(""" {"number":7,"title":"t","state":"open","author":"cmc","source":"f",\ "target_ref":"main","head_sha":"a","created_at":"2026-09-01T00:00:00Z",\ "comments":[{"author":"rae","body":"hi",\ "created_at":"2026-09-01T00:00:00Z"}]} """) #expect(mr.comments?.first?.bodyFormat == nil) } @Test func aMergeRequestCommentStoredAsOrgSaysSo() throws { let mr = try decodeMR(""" {"number":7,"title":"t","state":"open","author":"cmc","source":"f",\ "target_ref":"main","head_sha":"a","created_at":"2026-09-01T00:00:00Z",\ "comments":[{"author":"rae","body":"hi","body_format":"org",\ "created_at":"2026-09-01T00:00:00Z"}]} """) #expect(mr.comments?.first?.bodyFormat == "org") } } @MainActor struct BodyFormatWriteTests { private let issueJSON = """ {"protocol_version":1,"data":{"number":7,"title":"t","state":"open","author":"cmc",\ "body":"x","body_format":"org","created_at":"2026-09-01T00:00:00Z"},"exit_code":0} """ @Test func anIssueCommentCarriesItsFormat() async throws { let (client, stub) = try makeClient() stub.enqueue(.init(status: 200, json: issueJSON)) let model = IssueDetailViewModel(client: client, repoPath: "krz/gitbay", number: 7) await model.load() stub.enqueue(.init(status: 200, json: """ {"protocol_version":1,"exit_code":0} """)) stub.enqueue(.init(status: 200, json: issueJSON)) await model.comment("hello", format: "org") let write = try #require(stub.seen.first { $0.method == "POST" }) #expect(try argvOf(write) == ["issue", "comment", "krz/gitbay", "7", "--file", "-", "--format", "org"]) #expect(try stdinOf(write) == "hello") } /// No format given means no flag — the server then defaults. Sending /// `--format ""` would be a usage error. @Test func noFormatMeansNoFlag() async throws { let (client, stub) = try makeClient() stub.enqueue(.init(status: 200, json: issueJSON)) let model = IssueDetailViewModel(client: client, repoPath: "krz/gitbay", number: 7) await model.load() stub.enqueue(.init(status: 200, json: """ {"protocol_version":1,"exit_code":0} """)) stub.enqueue(.init(status: 200, json: issueJSON)) await model.comment("hello", format: nil) let write = try #require(stub.seen.first { $0.method == "POST" }) #expect(try argvOf(write).contains("--format") == false) } @Test func anIssueEditCarriesItsFormat() async throws { let (client, stub) = try makeClient() stub.enqueue(.init(status: 200, json: issueJSON)) let model = IssueDetailViewModel(client: client, repoPath: "krz/gitbay", number: 7) await model.load() stub.enqueue(.init(status: 200, json: """ {"protocol_version":1,"exit_code":0} """)) stub.enqueue(.init(status: 200, json: issueJSON)) await model.edit(title: "t2", body: "b2", format: "md") let write = try #require(stub.seen.first { $0.method == "POST" }) #expect(try argvOf(write) == ["issue", "edit", "krz/gitbay", "7", "--title", "t2", "--file", "-", "--format", "md"]) #expect(try stdinOf(write) == "b2") } } @MainActor struct BodyFormatMRWriteTests { private let plainDetail = """ {"number":7,"title":"a change","state":"open","author":"cmc",\ "source":"feat","target_ref":"main","head_sha":"abc123",\ "created_at":"2026-09-01T00:00:00Z"} """ private func loaded() throws -> (MRDetailViewModel, GitbayClient, StubProtocol.Box) { let (client, box) = try makeClient() box.enqueue(.init(status: 200, json: """ {"protocol_version":1,"data":\(plainDetail),"exit_code":0} """)) let model = MRDetailViewModel(client: client, repoPath: "krz/gitbay", number: 7) return (model, client, box) } /// `mr show` load also fires `mr diff` and `mr threads`; leave both /// unstubbed so they fail harmlessly, same as `MRLifecycleTests`. @Test func aMergeRequestCommentCarriesItsFormat() async throws { let (model, _, box) = try loaded() await model.load() box.enqueue(.init(status: 200, json: """ {"protocol_version":1,"exit_code":0} """)) box.enqueue(.init(status: 200, json: """ {"protocol_version":1,"data":\(plainDetail),"exit_code":0} """)) await model.comment("hello", format: "org") let write = try #require(box.seen.first { $0.method == "POST" }) #expect(try argvOf(write) == ["mr", "comment", "krz/gitbay", "7", "--file", "-", "--format", "org"]) #expect(try stdinOf(write) == "hello") } @Test func aMergeRequestCommentWithNoFormatOmitsTheFlag() async throws { let (model, _, box) = try loaded() await model.load() box.enqueue(.init(status: 200, json: """ {"protocol_version":1,"exit_code":0} """)) box.enqueue(.init(status: 200, json: """ {"protocol_version":1,"data":\(plainDetail),"exit_code":0} """)) await model.comment("hello", format: nil) let write = try #require(box.seen.first { $0.method == "POST" }) #expect(try argvOf(write).contains("--format") == false) } @Test func aMergeRequestEditCarriesItsFormat() async throws { let (model, _, box) = try loaded() await model.load() box.enqueue(.init(status: 200, json: """ {"protocol_version":1,"exit_code":0} """)) box.enqueue(.init(status: 200, json: """ {"protocol_version":1,"data":\(plainDetail),"exit_code":0} """)) await model.edit(title: "t2", body: "b2", format: "md") let write = try #require(box.seen.first { $0.method == "POST" }) #expect(try argvOf(write) == ["mr", "edit", "krz/gitbay", "7", "--title", "t2", "--file", "-", "--format", "md"]) #expect(try stdinOf(write) == "b2") } } @MainActor struct BodyFormatCreateTests { @Test func creatingAnIssueWithAFormatAppendsTheFlag() async throws { let (client, box) = try makeClient() box.enqueue(.init(status: 200, json: """ {"protocol_version":1,"data":{"number":7},"exit_code":0} """)) let model = IssueCreateViewModel(client: client, repoPath: "krz/gitbay") model.format = "org" _ = await model.create(title: "a title", body: "a body") let write = try #require(box.seen.first { $0.method == "POST" }) #expect(try argvOf(write) == ["issue", "create", "krz/gitbay", "--title", "a title", "--file", "-", "--format", "org"]) } @Test func creatingAnIssueWithoutAFormatOmitsTheFlag() async throws { let (client, box) = try makeClient() box.enqueue(.init(status: 200, json: """ {"protocol_version":1,"data":{"number":7},"exit_code":0} """)) let model = IssueCreateViewModel(client: client, repoPath: "krz/gitbay") _ = await model.create(title: "a title", body: "a body") let write = try #require(box.seen.first { $0.method == "POST" }) #expect(try argvOf(write).contains("--format") == false) } @Test func creatingAMergeRequestWithAFormatAppendsTheFlag() async throws { let (client, box) = try makeClient() box.enqueue(.init(status: 200, json: """ {"protocol_version":1,"data":{"number":7},"exit_code":0} """)) let model = MRCreateViewModel(client: client, repoPath: "krz/gitbay") model.format = "org" _ = await model.create(source: "feat", target: "main", title: "a change", body: "a body") let write = try #require(box.seen.first { $0.method == "POST" }) #expect(try argvOf(write) == ["mr", "create", "krz/gitbay", "--source", "feat", "--target", "main", "--title", "a change", "--file", "-", "--format", "org"]) } @Test func creatingAMergeRequestWithoutAFormatOmitsTheFlag() async throws { let (client, box) = try makeClient() box.enqueue(.init(status: 200, json: """ {"protocol_version":1,"data":{"number":7},"exit_code":0} """)) let model = MRCreateViewModel(client: client, repoPath: "krz/gitbay") _ = await model.create(source: "feat", target: "main", title: "a change", body: "a body") let write = try #require(box.seen.first { $0.method == "POST" }) #expect(try argvOf(write).contains("--format") == false) } }