gitbayTests/BodyFormatTests.swift
317 lines · 13155 bytes
1import Foundation
2import Testing
3@testable import gitbay
4
5private func makeClient() throws -> (GitbayClient, StubProtocol.Box) {
6 let box = StubProtocol.box()
7 let client = GitbayClient(
8 instance: try GitbayInstance(url: "https://gitbay.org"),
9 token: "test-token",
10 session: box.session()
11 )
12 return (client, box)
13}
14
15private func argvOf(_ seen: StubProtocol.Seen) throws -> [String] {
16 let body = try #require(try JSONSerialization.jsonObject(with: seen.body) as? [String: Any])
17 return try #require(body["argv"] as? [String])
18}
19
20private func stdinOf(_ seen: StubProtocol.Seen) throws -> String? {
21 let body = try #require(try JSONSerialization.jsonObject(with: seen.body) as? [String: Any])
22 return body["stdin"] as? String
23}
24
25struct BodyFormatDecodingTests {
26
27 private func decodeIssue(_ json: String) throws -> IssueDetail {
28 let decoder = GitbayClient.decoder()
29 return try decoder.decode(IssueDetail.self, from: Data(json.utf8))
30 }
31
32 private func decodeMR(_ json: String) throws -> MRDetail {
33 let decoder = GitbayClient.decoder()
34 return try decoder.decode(MRDetail.self, from: Data(json.utf8))
35 }
36
37 /// body_format is omitempty; an ordinary body carries no key.
38 @Test func anIssueWithoutAStoredFormatDecodes() throws {
39 let issue = try decodeIssue("""
40 {"number":7,"title":"t","state":"open","author":"cmc",\
41 "body":"x","created_at":"2026-09-01T00:00:00Z"}
42 """)
43 #expect(issue.bodyFormat == nil)
44 }
45
46 @Test func anIssueStoredAsOrgSaysSo() throws {
47 let issue = try decodeIssue("""
48 {"number":7,"title":"t","state":"open","author":"cmc",\
49 "body":"x","body_format":"org","created_at":"2026-09-01T00:00:00Z"}
50 """)
51 #expect(issue.bodyFormat == "org")
52 }
53
54 @Test func aMergeRequestWithoutAStoredFormatDecodes() throws {
55 let mr = try decodeMR("""
56 {"number":7,"title":"t","state":"open","author":"cmc","source":"f",\
57 "target_ref":"main","head_sha":"a","created_at":"2026-09-01T00:00:00Z"}
58 """)
59 #expect(mr.bodyFormat == nil)
60 }
61
62 /// MRDetail's hand-written initialiser must still decode everything
63 /// it did before the new field was added.
64 @Test func addingTheFormatDidNotDropAnyMergeRequestField() throws {
65 let mr = try decodeMR("""
66 {"number":7,"title":"t","state":"open","draft":true,"author":"cmc","source":"f",\
67 "target_ref":"main","head_sha":"abc","body":"b","body_format":"org",\
68 "milestone":"v1","review_requests":["rae"],\
69 "stacked_on":{"number":6,"title":"below"},\
70 "stacked":[{"number":8,"title":"above"}],\
71 "created_at":"2026-09-01T00:00:00Z","merged_at":"2026-09-02T00:00:00Z",\
72 "merged_by":"cmc","checks_combined":"success","unresolved_threads":2}
73 """)
74 #expect(mr.bodyFormat == "org")
75 #expect(mr.draft)
76 #expect(mr.milestone == "v1")
77 #expect(mr.reviewRequests == ["rae"])
78 #expect(mr.stackedOn?.number == 6)
79 #expect(mr.stacked.map(\.number) == [8])
80 #expect(mr.mergedBy == "cmc")
81 #expect(mr.checksCombined == "success")
82 #expect(mr.unresolvedThreads == 2)
83 }
84
85 /// body_format is omitempty on a comment too; an ordinary one carries no key.
86 @Test func anIssueCommentWithoutAStoredFormatDecodesNil() throws {
87 let issue = try decodeIssue("""
88 {"number":7,"title":"t","state":"open","author":"cmc",\
89 "created_at":"2026-09-01T00:00:00Z","comments":[{"author":"rae",\
90 "body":"hi","created_at":"2026-09-01T00:00:00Z"}]}
91 """)
92 #expect(issue.comments?.first?.bodyFormat == nil)
93 }
94
95 @Test func anIssueCommentStoredAsOrgSaysSo() throws {
96 let issue = try decodeIssue("""
97 {"number":7,"title":"t","state":"open","author":"cmc",\
98 "created_at":"2026-09-01T00:00:00Z","comments":[{"author":"rae",\
99 "body":"hi","body_format":"org","created_at":"2026-09-01T00:00:00Z"}]}
100 """)
101 #expect(issue.comments?.first?.bodyFormat == "org")
102 }
103
104 @Test func aMergeRequestCommentWithoutAStoredFormatDecodesNil() throws {
105 let mr = try decodeMR("""
106 {"number":7,"title":"t","state":"open","author":"cmc","source":"f",\
107 "target_ref":"main","head_sha":"a","created_at":"2026-09-01T00:00:00Z",\
108 "comments":[{"author":"rae","body":"hi",\
109 "created_at":"2026-09-01T00:00:00Z"}]}
110 """)
111 #expect(mr.comments?.first?.bodyFormat == nil)
112 }
113
114 @Test func aMergeRequestCommentStoredAsOrgSaysSo() throws {
115 let mr = try decodeMR("""
116 {"number":7,"title":"t","state":"open","author":"cmc","source":"f",\
117 "target_ref":"main","head_sha":"a","created_at":"2026-09-01T00:00:00Z",\
118 "comments":[{"author":"rae","body":"hi","body_format":"org",\
119 "created_at":"2026-09-01T00:00:00Z"}]}
120 """)
121 #expect(mr.comments?.first?.bodyFormat == "org")
122 }
123}
124
125@MainActor
126struct BodyFormatWriteTests {
127
128 private let issueJSON = """
129 {"protocol_version":1,"data":{"number":7,"title":"t","state":"open","author":"cmc",\
130 "body":"x","body_format":"org","created_at":"2026-09-01T00:00:00Z"},"exit_code":0}
131 """
132
133 @Test func anIssueCommentCarriesItsFormat() async throws {
134 let (client, stub) = try makeClient()
135 stub.enqueue(.init(status: 200, json: issueJSON))
136 let model = IssueDetailViewModel(client: client, repoPath: "krz/gitbay", number: 7)
137 await model.load()
138
139 stub.enqueue(.init(status: 200, json: """
140 {"protocol_version":1,"exit_code":0}
141 """))
142 stub.enqueue(.init(status: 200, json: issueJSON))
143 await model.comment("hello", format: "org")
144
145 let write = try #require(stub.seen.first { $0.method == "POST" })
146 #expect(try argvOf(write)
147 == ["issue", "comment", "krz/gitbay", "7", "--file", "-", "--format", "org"])
148 #expect(try stdinOf(write) == "hello")
149 }
150
151 /// No format given means no flag — the server then defaults. Sending
152 /// `--format ""` would be a usage error.
153 @Test func noFormatMeansNoFlag() async throws {
154 let (client, stub) = try makeClient()
155 stub.enqueue(.init(status: 200, json: issueJSON))
156 let model = IssueDetailViewModel(client: client, repoPath: "krz/gitbay", number: 7)
157 await model.load()
158
159 stub.enqueue(.init(status: 200, json: """
160 {"protocol_version":1,"exit_code":0}
161 """))
162 stub.enqueue(.init(status: 200, json: issueJSON))
163 await model.comment("hello", format: nil)
164
165 let write = try #require(stub.seen.first { $0.method == "POST" })
166 #expect(try argvOf(write).contains("--format") == false)
167 }
168
169 @Test func anIssueEditCarriesItsFormat() async throws {
170 let (client, stub) = try makeClient()
171 stub.enqueue(.init(status: 200, json: issueJSON))
172 let model = IssueDetailViewModel(client: client, repoPath: "krz/gitbay", number: 7)
173 await model.load()
174
175 stub.enqueue(.init(status: 200, json: """
176 {"protocol_version":1,"exit_code":0}
177 """))
178 stub.enqueue(.init(status: 200, json: issueJSON))
179 await model.edit(title: "t2", body: "b2", format: "md")
180
181 let write = try #require(stub.seen.first { $0.method == "POST" })
182 #expect(try argvOf(write)
183 == ["issue", "edit", "krz/gitbay", "7", "--title", "t2", "--file", "-", "--format", "md"])
184 #expect(try stdinOf(write) == "b2")
185 }
186}
187
188@MainActor
189struct BodyFormatMRWriteTests {
190
191 private let plainDetail = """
192 {"number":7,"title":"a change","state":"open","author":"cmc",\
193 "source":"feat","target_ref":"main","head_sha":"abc123",\
194 "created_at":"2026-09-01T00:00:00Z"}
195 """
196
197 private func loaded() throws -> (MRDetailViewModel, GitbayClient, StubProtocol.Box) {
198 let (client, box) = try makeClient()
199 box.enqueue(.init(status: 200, json: """
200 {"protocol_version":1,"data":\(plainDetail),"exit_code":0}
201 """))
202 let model = MRDetailViewModel(client: client, repoPath: "krz/gitbay", number: 7)
203 return (model, client, box)
204 }
205
206 /// `mr show` load also fires `mr diff` and `mr threads`; leave both
207 /// unstubbed so they fail harmlessly, same as `MRLifecycleTests`.
208 @Test func aMergeRequestCommentCarriesItsFormat() async throws {
209 let (model, _, box) = try loaded()
210 await model.load()
211
212 box.enqueue(.init(status: 200, json: """
213 {"protocol_version":1,"exit_code":0}
214 """))
215 box.enqueue(.init(status: 200, json: """
216 {"protocol_version":1,"data":\(plainDetail),"exit_code":0}
217 """))
218 await model.comment("hello", format: "org")
219
220 let write = try #require(box.seen.first { $0.method == "POST" })
221 #expect(try argvOf(write)
222 == ["mr", "comment", "krz/gitbay", "7", "--file", "-", "--format", "org"])
223 #expect(try stdinOf(write) == "hello")
224 }
225
226 @Test func aMergeRequestCommentWithNoFormatOmitsTheFlag() async throws {
227 let (model, _, box) = try loaded()
228 await model.load()
229
230 box.enqueue(.init(status: 200, json: """
231 {"protocol_version":1,"exit_code":0}
232 """))
233 box.enqueue(.init(status: 200, json: """
234 {"protocol_version":1,"data":\(plainDetail),"exit_code":0}
235 """))
236 await model.comment("hello", format: nil)
237
238 let write = try #require(box.seen.first { $0.method == "POST" })
239 #expect(try argvOf(write).contains("--format") == false)
240 }
241
242 @Test func aMergeRequestEditCarriesItsFormat() async throws {
243 let (model, _, box) = try loaded()
244 await model.load()
245
246 box.enqueue(.init(status: 200, json: """
247 {"protocol_version":1,"exit_code":0}
248 """))
249 box.enqueue(.init(status: 200, json: """
250 {"protocol_version":1,"data":\(plainDetail),"exit_code":0}
251 """))
252 await model.edit(title: "t2", body: "b2", format: "md")
253
254 let write = try #require(box.seen.first { $0.method == "POST" })
255 #expect(try argvOf(write)
256 == ["mr", "edit", "krz/gitbay", "7", "--title", "t2", "--file", "-", "--format", "md"])
257 #expect(try stdinOf(write) == "b2")
258 }
259}
260
261@MainActor
262struct BodyFormatCreateTests {
263
264 @Test func creatingAnIssueWithAFormatAppendsTheFlag() async throws {
265 let (client, box) = try makeClient()
266 box.enqueue(.init(status: 200, json: """
267 {"protocol_version":1,"data":{"number":7},"exit_code":0}
268 """))
269 let model = IssueCreateViewModel(client: client, repoPath: "krz/gitbay")
270 model.format = "org"
271 _ = await model.create(title: "a title", body: "a body")
272
273 let write = try #require(box.seen.first { $0.method == "POST" })
274 #expect(try argvOf(write)
275 == ["issue", "create", "krz/gitbay", "--title", "a title",
276 "--file", "-", "--format", "org"])
277 }
278
279 @Test func creatingAnIssueWithoutAFormatOmitsTheFlag() async throws {
280 let (client, box) = try makeClient()
281 box.enqueue(.init(status: 200, json: """
282 {"protocol_version":1,"data":{"number":7},"exit_code":0}
283 """))
284 let model = IssueCreateViewModel(client: client, repoPath: "krz/gitbay")
285 _ = await model.create(title: "a title", body: "a body")
286
287 let write = try #require(box.seen.first { $0.method == "POST" })
288 #expect(try argvOf(write).contains("--format") == false)
289 }
290
291 @Test func creatingAMergeRequestWithAFormatAppendsTheFlag() async throws {
292 let (client, box) = try makeClient()
293 box.enqueue(.init(status: 200, json: """
294 {"protocol_version":1,"data":{"number":7},"exit_code":0}
295 """))
296 let model = MRCreateViewModel(client: client, repoPath: "krz/gitbay")
297 model.format = "org"
298 _ = await model.create(source: "feat", target: "main", title: "a change", body: "a body")
299
300 let write = try #require(box.seen.first { $0.method == "POST" })
301 #expect(try argvOf(write)
302 == ["mr", "create", "krz/gitbay", "--source", "feat", "--target", "main",
303 "--title", "a change", "--file", "-", "--format", "org"])
304 }
305
306 @Test func creatingAMergeRequestWithoutAFormatOmitsTheFlag() async throws {
307 let (client, box) = try makeClient()
308 box.enqueue(.init(status: 200, json: """
309 {"protocol_version":1,"data":{"number":7},"exit_code":0}
310 """))
311 let model = MRCreateViewModel(client: client, repoPath: "krz/gitbay")
312 _ = await model.create(source: "feat", target: "main", title: "a change", body: "a body")
313
314 let write = try #require(box.seen.first { $0.method == "POST" })
315 #expect(try argvOf(write).contains("--format") == false)
316 }
317}