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