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