gitbayTests/MRLifecycleTests.swift

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

295 lines · 12078 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    /// `superseded_by` is omitempty and 0 means none, so the ordinary row
 90    /// carries no key at all.
 91    @Test func supersededByDecodesWhenPresent() throws {
 92        #expect(try decodeDetail(plainDetail).supersededBy == nil)
 93        let mr = try decodeDetail("""
 94            {"number":7,"title":"a change","state":"closed","author":"cmc",\
 95            "closed_at":"2026-09-02T00:00:00Z","closed_by":"cmc","superseded_by":9,\
 96            "source":"feat","target_ref":"main","head_sha":"abc123",\
 97            "created_at":"2026-09-01T00:00:00Z"}
 98            """)
 99        #expect(mr.supersededBy == 9)
100    }
101}
102
103@MainActor
104struct MRLifecycleActionTests {
105
106    private func loaded() async throws -> (MRDetailViewModel, StubProtocol.Box) {
107        let box = StubProtocol.box()
108        let client = GitbayClient(
109            instance: try GitbayInstance(url: "https://gitbay.org"),
110            token: "test-token",
111            session: box.session()
112        )
113        box.enqueue(.init(status: 200, json: """
114            {"protocol_version":1,"data":\(plainDetail),"exit_code":0}
115            """))
116        let model = MRDetailViewModel(client: client, repoPath: "krz/gitbay", number: 7)
117        await model.load()
118        return (model, box)
119    }
120
121    private func argvOf(_ seen: StubProtocol.Seen) throws -> [String] {
122        let body = try #require(try JSONSerialization.jsonObject(with: seen.body) as? [String: Any])
123        return try #require(body["argv"] as? [String])
124    }
125
126    private func ok(_ box: StubProtocol.Box) {
127        box.enqueue(.init(status: 200, json: """
128            {"protocol_version":1,"exit_code":0}
129            """))
130        box.enqueue(.init(status: 200, json: """
131            {"protocol_version":1,"data":\(plainDetail),"exit_code":0}
132            """))
133    }
134
135    /// `load()` issues four GETs: `mr show`, `mr diff`, `mr threads`,
136    /// `mr revisions`. `loaded()` runs it once (4 GETs, the last three
137    /// unstubbed and failed), so a reload after a write brings the total
138    /// to 8.
139    private func gets(_ box: StubProtocol.Box) -> Int {
140        box.seen.count { $0.method == "GET" }
141    }
142
143    @Test func markingDraftAndReadyAreDifferentCommands() async throws {
144        let (model, box) = try await loaded()
145        ok(box)
146        await model.setDraft(true)
147        var write = try #require(box.seen.first { $0.method == "POST" })
148        #expect(try argvOf(write) == ["mr", "draft", "krz/gitbay", "7"])
149        #expect(gets(box) == 8)
150
151        let (model2, box2) = try await loaded()
152        ok(box2)
153        await model2.setDraft(false)
154        write = try #require(box2.seen.first { $0.method == "POST" })
155        #expect(try argvOf(write) == ["mr", "ready", "krz/gitbay", "7"])
156        #expect(gets(box2) == 8)
157    }
158
159    @Test func retargetPassesTheBranchPositionally() async throws {
160        let (model, box) = try await loaded()
161        ok(box)
162        await model.retarget(to: "release")
163        let write = try #require(box.seen.first { $0.method == "POST" })
164        #expect(try argvOf(write) == ["mr", "retarget", "krz/gitbay", "7", "release"])
165        #expect(gets(box) == 8)
166    }
167
168    /// `mr review request` is a THREE-word path. Two words posts a
169    /// review instead — silently, with a 200.
170    @Test func requestingAReviewUsesTheThreeWordPath() async throws {
171        let (model, box) = try await loaded()
172        ok(box)
173        await model.requestReview(from: "rae")
174        let write = try #require(box.seen.first { $0.method == "POST" })
175        #expect(try argvOf(write)
176            == ["mr", "review", "request", "krz/gitbay", "7", "--add", "rae"])
177        #expect(gets(box) == 8)
178    }
179
180    @Test func removingAReviewRequestUsesRemoveNotAdd() async throws {
181        let (model, box) = try await loaded()
182        ok(box)
183        await model.removeReviewRequest("rae")
184        let write = try #require(box.seen.first { $0.method == "POST" })
185        #expect(try argvOf(write)
186            == ["mr", "review", "request", "krz/gitbay", "7", "--remove", "rae"])
187        #expect(gets(box) == 8)
188    }
189
190    @Test func branchesComeFromRepoRefs() async throws {
191        let (model, box) = try await loaded()
192        box.enqueue(.init(status: 200, json: """
193            {"protocol_version":1,"data":{"branches":[{"name":"main","sha":"a"},\
194            {"name":"release","sha":"b"}],"tags":[]},"exit_code":0}
195            """))
196        await model.loadBranches()
197        #expect(model.branches == ["main", "release"])
198    }
199
200    @Test func aRefusalSurfacesAndDoesNotReload() async throws {
201        let (model, box) = try await loaded()
202        box.enqueue(.init(status: 200, json: """
203            {"protocol_version":1,"error":"cannot retarget a merged merge request",\
204            "exit_code":1}
205            """))
206        await model.retarget(to: "release")
207        #expect(model.actionError?.isEmpty == false)
208        #expect(model.working == false)
209        // A refusal throws before `perform` reaches `load()`: still the
210        // 4 GETs from `loaded()`, none from a reload.
211        #expect(gets(box) == 4)
212    }
213
214    @Test func closingWithoutASuccessorSendsNoBy() async throws {
215        let (model, box) = try await loaded()
216        ok(box)
217        await model.close()
218        let write = try #require(box.seen.first { $0.method == "POST" })
219        #expect(try argvOf(write) == ["mr", "close", "krz/gitbay", "7"])
220    }
221
222    @Test func closingInFavourOfAnotherPassesItsNumberAsBy() async throws {
223        let (model, box) = try await loaded()
224        ok(box)
225        await model.close(inFavourOf: 9)
226        let write = try #require(box.seen.first { $0.method == "POST" })
227        #expect(try argvOf(write) == ["mr", "close", "krz/gitbay", "7", "--by", "9"])
228        #expect(gets(box) == 8)
229    }
230
231    /// The picker offers the repository's other open merge requests. This
232    /// one cannot supersede itself, so it is dropped before the list is
233    /// shown rather than left for the server to refuse.
234    @Test func openMergeRequestsForThePickerExcludeThisOne() async throws {
235        let (model, box) = try await loaded()
236        box.enqueue(.init(status: 200, json: """
237            {"protocol_version":1,"data":[\
238            {"number":7,"title":"a change","state":"open","author":"cmc",\
239            "source":"feat","target_ref":"main","head_sha":"abc123","created_at":"2026-09-01T00:00:00Z"},\
240            {"number":9,"title":"the redo","state":"open","author":"cmc",\
241            "source":"feat2","target_ref":"main","head_sha":"def456","created_at":"2026-09-02T00:00:00Z"}],\
242            "exit_code":0}
243            """))
244        await model.loadOpenMergeRequests()
245        let read = try #require(box.seen.last { $0.method == "GET" })
246        #expect(read.url.query() == "argv=mr&argv=list&argv=krz/gitbay&argv=--state&argv=open")
247        #expect(model.openMergeRequests?.map(\.number) == [9])
248    }
249}
250
251@MainActor
252struct MRCreateDraftTests {
253
254    @Test func creatingAsADraftAppendsTheFlag() async throws {
255        let box = StubProtocol.box()
256        let client = GitbayClient(
257            instance: try GitbayInstance(url: "https://gitbay.org"),
258            token: "test-token",
259            session: box.session()
260        )
261        box.enqueue(.init(status: 200, json: """
262            {"protocol_version":1,"data":{"number":7},"exit_code":0}
263            """))
264        let model = MRCreateViewModel(client: client, repoPath: "krz/gitbay")
265        model.draft = true
266        _ = await model.create(source: "feat", target: "main", title: "a change", body: "")
267
268        let write = try #require(box.seen.first { $0.method == "POST" })
269        let body = try #require(
270            try JSONSerialization.jsonObject(with: write.body) as? [String: Any]
271        )
272        let argv = try #require(body["argv"] as? [String])
273        #expect(argv.contains("--draft"))
274    }
275
276    @Test func creatingWithoutDraftOmitsTheFlag() async throws {
277        let box = StubProtocol.box()
278        let client = GitbayClient(
279            instance: try GitbayInstance(url: "https://gitbay.org"),
280            token: "test-token",
281            session: box.session()
282        )
283        box.enqueue(.init(status: 200, json: """
284            {"protocol_version":1,"data":{"number":7},"exit_code":0}
285            """))
286        let model = MRCreateViewModel(client: client, repoPath: "krz/gitbay")
287        _ = await model.create(source: "feat", target: "main", title: "a change", body: "")
288
289        let write = try #require(box.seen.first { $0.method == "POST" })
290        let body = try #require(
291            try JSONSerialization.jsonObject(with: write.body) as? [String: Any]
292        )
293        #expect((body["argv"] as? [String])?.contains("--draft") == false)
294    }
295}