gitbayTests/MRLifecycleTests.swift

v1.1.1
gitbay-ios/gitbayTests/MRLifecycleTests.swift history · blame · raw

246 lines · 9670 bytes

  1import Foundation
  2import Testing
  3@testable import gitbay
  4
  5private func decodeDetail(_ json: String) throws -> MRDetail {
  6    let decoder = GitbayClient.decoder()
  7    return try decoder.decode(MRDetail.self, from: Data(json.utf8))
  8}
  9
 10private func decodeRow(_ json: String) throws -> MergeRequest {
 11    let decoder = GitbayClient.decoder()
 12    return try decoder.decode(MergeRequest.self, from: Data(json.utf8))
 13}
 14
 15private let plainDetail = """
 16    {"number":7,"title":"a change","state":"open","author":"cmc",\
 17    "source":"feat","target_ref":"main","head_sha":"abc123",\
 18    "created_at":"2026-09-01T00:00:00Z"}
 19    """
 20
 21struct MRLifecycleDecodingTests {
 22
 23    /// Every one of these keys is omitempty. The ordinary merge request
 24    /// carries none of them, so absence must decode, not throw.
 25    @Test func anOrdinaryMergeRequestHasNoneOfTheNewKeys() throws {
 26        let mr = try decodeDetail(plainDetail)
 27        #expect(mr.draft == false)
 28        #expect(mr.reviewRequests.isEmpty)
 29        #expect(mr.stackedOn == nil)
 30        #expect(mr.stacked.isEmpty)
 31    }
 32
 33    @Test func aDraftDecodesAndStaysOpen() throws {
 34        let mr = try decodeDetail("""
 35            {"number":7,"title":"a change","state":"open","draft":true,"author":"cmc",\
 36            "source":"feat","target_ref":"main","head_sha":"abc123",\
 37            "created_at":"2026-09-01T00:00:00Z"}
 38            """)
 39        #expect(mr.draft)
 40        // Draft is a flag, not a fifth state.
 41        #expect(mr.state == "open")
 42        #expect(mr.isOpen)
 43    }
 44
 45    @Test func reviewRequestsDecode() throws {
 46        let mr = try decodeDetail("""
 47            {"number":7,"title":"a change","state":"open","author":"cmc",\
 48            "review_requests":["rae","sam"],\
 49            "source":"feat","target_ref":"main","head_sha":"abc123",\
 50            "created_at":"2026-09-01T00:00:00Z"}
 51            """)
 52        #expect(mr.reviewRequests == ["rae", "sam"])
 53    }
 54
 55    @Test func bothHalvesOfAStackDecode() throws {
 56        let mr = try decodeDetail("""
 57            {"number":7,"title":"a change","state":"open","author":"cmc",\
 58            "stacked_on":{"number":6,"title":"the one below"},\
 59            "stacked":[{"number":8,"title":"one above"},{"number":9,"title":"another"}],\
 60            "source":"feat","target_ref":"main","head_sha":"abc123",\
 61            "created_at":"2026-09-01T00:00:00Z"}
 62            """)
 63        #expect(mr.stackedOn?.number == 6)
 64        #expect(mr.stackedOn?.title == "the one below")
 65        #expect(mr.stacked.map(\.number) == [8, 9])
 66    }
 67
 68    @Test func aListRowCarriesDraftAndStackedOn() throws {
 69        let row = try decodeRow("""
 70            {"number":7,"title":"a change","state":"open","draft":true,"author":"cmc",\
 71            "stacked_on":{"number":6,"title":"below"},\
 72            "source":"feat","target_ref":"main","head_sha":"abc123",\
 73            "created_at":"2026-09-01T00:00:00Z"}
 74            """)
 75        #expect(row.draft)
 76        #expect(row.stackedOn?.number == 6)
 77    }
 78
 79    @Test func aPlainListRowHasNeither() throws {
 80        let row = try decodeRow("""
 81            {"number":7,"title":"a change","state":"open","author":"cmc",\
 82            "source":"feat","target_ref":"main","head_sha":"abc123",\
 83            "created_at":"2026-09-01T00:00:00Z"}
 84            """)
 85        #expect(row.draft == false)
 86        #expect(row.stackedOn == nil)
 87    }
 88}
 89
 90@MainActor
 91struct MRLifecycleActionTests {
 92
 93    private func loaded() async throws -> (MRDetailViewModel, StubProtocol.Box) {
 94        let box = StubProtocol.box()
 95        let client = GitbayClient(
 96            instance: try GitbayInstance(url: "https://gitbay.org"),
 97            token: "test-token",
 98            session: box.session()
 99        )
100        box.enqueue(.init(status: 200, json: """
101            {"protocol_version":1,"data":\(plainDetail),"exit_code":0}
102            """))
103        let model = MRDetailViewModel(client: client, repoPath: "krz/gitbay", number: 7)
104        await model.load()
105        return (model, box)
106    }
107
108    private func argvOf(_ seen: StubProtocol.Seen) throws -> [String] {
109        let body = try #require(try JSONSerialization.jsonObject(with: seen.body) as? [String: Any])
110        return try #require(body["argv"] as? [String])
111    }
112
113    private func ok(_ box: StubProtocol.Box) {
114        box.enqueue(.init(status: 200, json: """
115            {"protocol_version":1,"exit_code":0}
116            """))
117        box.enqueue(.init(status: 200, json: """
118            {"protocol_version":1,"data":\(plainDetail),"exit_code":0}
119            """))
120    }
121
122    /// `load()` issues four GETs: `mr show`, `mr diff`, `mr threads`,
123    /// `mr revisions`. `loaded()` runs it once (4 GETs, the last three
124    /// unstubbed and failed), so a reload after a write brings the total
125    /// to 8.
126    private func gets(_ box: StubProtocol.Box) -> Int {
127        box.seen.count { $0.method == "GET" }
128    }
129
130    @Test func markingDraftAndReadyAreDifferentCommands() async throws {
131        let (model, box) = try await loaded()
132        ok(box)
133        await model.setDraft(true)
134        var write = try #require(box.seen.first { $0.method == "POST" })
135        #expect(try argvOf(write) == ["mr", "draft", "krz/gitbay", "7"])
136        #expect(gets(box) == 8)
137
138        let (model2, box2) = try await loaded()
139        ok(box2)
140        await model2.setDraft(false)
141        write = try #require(box2.seen.first { $0.method == "POST" })
142        #expect(try argvOf(write) == ["mr", "ready", "krz/gitbay", "7"])
143        #expect(gets(box2) == 8)
144    }
145
146    @Test func retargetPassesTheBranchPositionally() async throws {
147        let (model, box) = try await loaded()
148        ok(box)
149        await model.retarget(to: "release")
150        let write = try #require(box.seen.first { $0.method == "POST" })
151        #expect(try argvOf(write) == ["mr", "retarget", "krz/gitbay", "7", "release"])
152        #expect(gets(box) == 8)
153    }
154
155    /// `mr review request` is a THREE-word path. Two words posts a
156    /// review instead — silently, with a 200.
157    @Test func requestingAReviewUsesTheThreeWordPath() async throws {
158        let (model, box) = try await loaded()
159        ok(box)
160        await model.requestReview(from: "rae")
161        let write = try #require(box.seen.first { $0.method == "POST" })
162        #expect(try argvOf(write)
163            == ["mr", "review", "request", "krz/gitbay", "7", "--add", "rae"])
164        #expect(gets(box) == 8)
165    }
166
167    @Test func removingAReviewRequestUsesRemoveNotAdd() async throws {
168        let (model, box) = try await loaded()
169        ok(box)
170        await model.removeReviewRequest("rae")
171        let write = try #require(box.seen.first { $0.method == "POST" })
172        #expect(try argvOf(write)
173            == ["mr", "review", "request", "krz/gitbay", "7", "--remove", "rae"])
174        #expect(gets(box) == 8)
175    }
176
177    @Test func branchesComeFromRepoRefs() async throws {
178        let (model, box) = try await loaded()
179        box.enqueue(.init(status: 200, json: """
180            {"protocol_version":1,"data":{"branches":[{"name":"main","sha":"a"},\
181            {"name":"release","sha":"b"}],"tags":[]},"exit_code":0}
182            """))
183        await model.loadBranches()
184        #expect(model.branches == ["main", "release"])
185    }
186
187    @Test func aRefusalSurfacesAndDoesNotReload() async throws {
188        let (model, box) = try await loaded()
189        box.enqueue(.init(status: 200, json: """
190            {"protocol_version":1,"error":"cannot retarget a merged merge request",\
191            "exit_code":1}
192            """))
193        await model.retarget(to: "release")
194        #expect(model.actionError?.isEmpty == false)
195        #expect(model.working == false)
196        // A refusal throws before `perform` reaches `load()`: still the
197        // 4 GETs from `loaded()`, none from a reload.
198        #expect(gets(box) == 4)
199    }
200}
201
202@MainActor
203struct MRCreateDraftTests {
204
205    @Test func creatingAsADraftAppendsTheFlag() async throws {
206        let box = StubProtocol.box()
207        let client = GitbayClient(
208            instance: try GitbayInstance(url: "https://gitbay.org"),
209            token: "test-token",
210            session: box.session()
211        )
212        box.enqueue(.init(status: 200, json: """
213            {"protocol_version":1,"data":{"number":7},"exit_code":0}
214            """))
215        let model = MRCreateViewModel(client: client, repoPath: "krz/gitbay")
216        model.draft = true
217        _ = await model.create(source: "feat", target: "main", title: "a change", body: "")
218
219        let write = try #require(box.seen.first { $0.method == "POST" })
220        let body = try #require(
221            try JSONSerialization.jsonObject(with: write.body) as? [String: Any]
222        )
223        let argv = try #require(body["argv"] as? [String])
224        #expect(argv.contains("--draft"))
225    }
226
227    @Test func creatingWithoutDraftOmitsTheFlag() async throws {
228        let box = StubProtocol.box()
229        let client = GitbayClient(
230            instance: try GitbayInstance(url: "https://gitbay.org"),
231            token: "test-token",
232            session: box.session()
233        )
234        box.enqueue(.init(status: 200, json: """
235            {"protocol_version":1,"data":{"number":7},"exit_code":0}
236            """))
237        let model = MRCreateViewModel(client: client, repoPath: "krz/gitbay")
238        _ = await model.create(source: "feat", target: "main", title: "a change", body: "")
239
240        let write = try #require(box.seen.first { $0.method == "POST" })
241        let body = try #require(
242            try JSONSerialization.jsonObject(with: write.body) as? [String: Any]
243        )
244        #expect((body["argv"] as? [String])?.contains("--draft") == false)
245    }
246}