gitbayTests/MRLifecycleTests.swift
295 lines · 12078 bytes
27 symbols in this file
decodeDetaildecodeRowMRLifecycleDecodingTestsanOrdinaryMergeRequestHasNoneOfTheNewKeysaDraftDecodesAndStaysOpenreviewRequestsDecodebothHalvesOfAStackDecodeaListRowCarriesDraftAndStackedOnaPlainListRowHasNeithersupersededByDecodesWhenPresentMRLifecycleActionTestsloadedargvOfokgetsmarkingDraftAndReadyAreDifferentCommandsretargetPassesTheBranchPositionallyrequestingAReviewUsesTheThreeWordPathremovingAReviewRequestUsesRemoveNotAddbranchesComeFromRepoRefsaRefusalSurfacesAndDoesNotReloadclosingWithoutASuccessorSendsNoByclosingInFavourOfAnotherPassesItsNumberAsByopenMergeRequestsForThePickerExcludeThisOneMRCreateDraftTestscreatingAsADraftAppendsTheFlagcreatingWithoutDraftOmitsTheFlag
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}