import Foundation import Testing @testable import gitbay private func decodeDetail(_ json: String) throws -> MRDetail { let decoder = JSONDecoder() decoder.dateDecodingStrategy = .iso8601 return try decoder.decode(MRDetail.self, from: Data(json.utf8)) } private func decodeRow(_ json: String) throws -> MergeRequest { let decoder = JSONDecoder() decoder.dateDecodingStrategy = .iso8601 return try decoder.decode(MergeRequest.self, from: Data(json.utf8)) } 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"} """ struct MRLifecycleDecodingTests { /// Every one of these keys is omitempty. The ordinary merge request /// carries none of them, so absence must decode, not throw. @Test func anOrdinaryMergeRequestHasNoneOfTheNewKeys() throws { let mr = try decodeDetail(plainDetail) #expect(mr.draft == false) #expect(mr.reviewRequests.isEmpty) #expect(mr.stackedOn == nil) #expect(mr.stacked.isEmpty) } @Test func aDraftDecodesAndStaysOpen() throws { let mr = try decodeDetail(""" {"number":7,"title":"a change","state":"open","draft":true,"author":"cmc",\ "source":"feat","target_ref":"main","head_sha":"abc123",\ "created_at":"2026-09-01T00:00:00Z"} """) #expect(mr.draft) // Draft is a flag, not a fifth state. #expect(mr.state == "open") #expect(mr.isOpen) } @Test func reviewRequestsDecode() throws { let mr = try decodeDetail(""" {"number":7,"title":"a change","state":"open","author":"cmc",\ "review_requests":["rae","sam"],\ "source":"feat","target_ref":"main","head_sha":"abc123",\ "created_at":"2026-09-01T00:00:00Z"} """) #expect(mr.reviewRequests == ["rae", "sam"]) } @Test func bothHalvesOfAStackDecode() throws { let mr = try decodeDetail(""" {"number":7,"title":"a change","state":"open","author":"cmc",\ "stacked_on":{"number":6,"title":"the one below"},\ "stacked":[{"number":8,"title":"one above"},{"number":9,"title":"another"}],\ "source":"feat","target_ref":"main","head_sha":"abc123",\ "created_at":"2026-09-01T00:00:00Z"} """) #expect(mr.stackedOn?.number == 6) #expect(mr.stackedOn?.title == "the one below") #expect(mr.stacked.map(\.number) == [8, 9]) } @Test func aListRowCarriesDraftAndStackedOn() throws { let row = try decodeRow(""" {"number":7,"title":"a change","state":"open","draft":true,"author":"cmc",\ "stacked_on":{"number":6,"title":"below"},\ "source":"feat","target_ref":"main","head_sha":"abc123",\ "created_at":"2026-09-01T00:00:00Z"} """) #expect(row.draft) #expect(row.stackedOn?.number == 6) } @Test func aPlainListRowHasNeither() throws { let row = try decodeRow(""" {"number":7,"title":"a change","state":"open","author":"cmc",\ "source":"feat","target_ref":"main","head_sha":"abc123",\ "created_at":"2026-09-01T00:00:00Z"} """) #expect(row.draft == false) #expect(row.stackedOn == nil) } } @MainActor struct MRLifecycleActionTests { private func loaded() async throws -> (MRDetailViewModel, StubProtocol.Box) { let box = StubProtocol.box() let client = GitbayClient( instance: try GitbayInstance(url: "https://gitbay.org"), token: "test-token", session: box.session() ) box.enqueue(.init(status: 200, json: """ {"protocol_version":1,"data":\(plainDetail),"exit_code":0} """)) let model = MRDetailViewModel(client: client, repoPath: "krz/gitbay", number: 7) await model.load() return (model, 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 ok(_ box: StubProtocol.Box) { 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} """)) } /// `load()` issues three GETs: `mr show`, `mr diff`, `mr threads`. /// `loaded()` runs it once (3 GETs, the last two unstubbed and failed), /// so a reload after a write brings the total to 6. private func gets(_ box: StubProtocol.Box) -> Int { box.seen.count { $0.method == "GET" } } @Test func markingDraftAndReadyAreDifferentCommands() async throws { let (model, box) = try await loaded() ok(box) await model.setDraft(true) var write = try #require(box.seen.first { $0.method == "POST" }) #expect(try argvOf(write) == ["mr", "draft", "krz/gitbay", "7"]) #expect(gets(box) == 6) let (model2, box2) = try await loaded() ok(box2) await model2.setDraft(false) write = try #require(box2.seen.first { $0.method == "POST" }) #expect(try argvOf(write) == ["mr", "ready", "krz/gitbay", "7"]) #expect(gets(box2) == 6) } @Test func retargetPassesTheBranchPositionally() async throws { let (model, box) = try await loaded() ok(box) await model.retarget(to: "release") let write = try #require(box.seen.first { $0.method == "POST" }) #expect(try argvOf(write) == ["mr", "retarget", "krz/gitbay", "7", "release"]) #expect(gets(box) == 6) } /// `mr review request` is a THREE-word path. Two words posts a /// review instead — silently, with a 200. @Test func requestingAReviewUsesTheThreeWordPath() async throws { let (model, box) = try await loaded() ok(box) await model.requestReview(from: "rae") let write = try #require(box.seen.first { $0.method == "POST" }) #expect(try argvOf(write) == ["mr", "review", "request", "krz/gitbay", "7", "--add", "rae"]) #expect(gets(box) == 6) } @Test func removingAReviewRequestUsesRemoveNotAdd() async throws { let (model, box) = try await loaded() ok(box) await model.removeReviewRequest("rae") let write = try #require(box.seen.first { $0.method == "POST" }) #expect(try argvOf(write) == ["mr", "review", "request", "krz/gitbay", "7", "--remove", "rae"]) #expect(gets(box) == 6) } @Test func branchesComeFromRepoRefs() async throws { let (model, box) = try await loaded() box.enqueue(.init(status: 200, json: """ {"protocol_version":1,"data":{"branches":[{"name":"main","sha":"a"},\ {"name":"release","sha":"b"}],"tags":[]},"exit_code":0} """)) await model.loadBranches() #expect(model.branches == ["main", "release"]) } @Test func aRefusalSurfacesAndDoesNotReload() async throws { let (model, box) = try await loaded() box.enqueue(.init(status: 200, json: """ {"protocol_version":1,"error":"cannot retarget a merged merge request",\ "exit_code":1} """)) await model.retarget(to: "release") #expect(model.actionError?.isEmpty == false) #expect(model.working == false) // A refusal throws before `perform` reaches `load()`: still the // 3 GETs from `loaded()`, none from a reload. #expect(gets(box) == 3) } } @MainActor struct MRCreateDraftTests { @Test func creatingAsADraftAppendsTheFlag() async throws { let box = StubProtocol.box() let client = GitbayClient( instance: try GitbayInstance(url: "https://gitbay.org"), token: "test-token", session: box.session() ) box.enqueue(.init(status: 200, json: """ {"protocol_version":1,"data":{"number":7},"exit_code":0} """)) let model = MRCreateViewModel(client: client, repoPath: "krz/gitbay") model.draft = true _ = await model.create(source: "feat", target: "main", title: "a change", body: "") let write = try #require(box.seen.first { $0.method == "POST" }) let body = try #require( try JSONSerialization.jsonObject(with: write.body) as? [String: Any] ) let argv = try #require(body["argv"] as? [String]) #expect(argv.contains("--draft")) } @Test func creatingWithoutDraftOmitsTheFlag() async throws { let box = StubProtocol.box() let client = GitbayClient( instance: try GitbayInstance(url: "https://gitbay.org"), token: "test-token", session: box.session() ) 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: "") let write = try #require(box.seen.first { $0.method == "POST" }) let body = try #require( try JSONSerialization.jsonObject(with: write.body) as? [String: Any] ) #expect((body["argv"] as? [String])?.contains("--draft") == false) } }