gitbayTests/MRLifecycleTests.swift
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}