| @@ -0,0 +1,279 @@ |
| |
1 | import Foundation |
| |
2 | import Testing |
| |
3 | @testable import gitbay |
| |
4 | |
| |
5 | private 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 | |
| |
15 | private 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 | |
| |
20 | private 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 | |
| |
25 | struct 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 | |
| |
88 | @MainActor |
| |
89 | struct BodyFormatWriteTests { |
| |
90 | |
| |
91 | private let issueJSON = """ |
| |
92 | {"protocol_version":1,"data":{"number":7,"title":"t","state":"open","author":"cmc",\ |
| |
93 | "body":"x","body_format":"org","created_at":"2026-09-01T00:00:00Z"},"exit_code":0} |
| |
94 | """ |
| |
95 | |
| |
96 | @Test func anIssueCommentCarriesItsFormat() async throws { |
| |
97 | let (client, stub) = try makeClient() |
| |
98 | stub.enqueue(.init(status: 200, json: issueJSON)) |
| |
99 | let model = IssueDetailViewModel(client: client, repoPath: "krz/gitbay", number: 7) |
| |
100 | await model.load() |
| |
101 | |
| |
102 | stub.enqueue(.init(status: 200, json: """ |
| |
103 | {"protocol_version":1,"exit_code":0} |
| |
104 | """)) |
| |
105 | stub.enqueue(.init(status: 200, json: issueJSON)) |
| |
106 | await model.comment("hello", format: "org") |
| |
107 | |
| |
108 | let write = try #require(stub.seen.first { $0.method == "POST" }) |
| |
109 | #expect(try argvOf(write) |
| |
110 | == ["issue", "comment", "krz/gitbay", "7", "--file", "-", "--format", "org"]) |
| |
111 | #expect(try stdinOf(write) == "hello") |
| |
112 | } |
| |
113 | |
| |
114 | /// No format given means no flag — the server then defaults. Sending |
| |
115 | /// `--format ""` would be a usage error. |
| |
116 | @Test func noFormatMeansNoFlag() async throws { |
| |
117 | let (client, stub) = try makeClient() |
| |
118 | stub.enqueue(.init(status: 200, json: issueJSON)) |
| |
119 | let model = IssueDetailViewModel(client: client, repoPath: "krz/gitbay", number: 7) |
| |
120 | await model.load() |
| |
121 | |
| |
122 | stub.enqueue(.init(status: 200, json: """ |
| |
123 | {"protocol_version":1,"exit_code":0} |
| |
124 | """)) |
| |
125 | stub.enqueue(.init(status: 200, json: issueJSON)) |
| |
126 | await model.comment("hello", format: nil) |
| |
127 | |
| |
128 | let write = try #require(stub.seen.first { $0.method == "POST" }) |
| |
129 | #expect(try argvOf(write).contains("--format") == false) |
| |
130 | } |
| |
131 | |
| |
132 | @Test func anIssueEditCarriesItsFormat() async throws { |
| |
133 | let (client, stub) = try makeClient() |
| |
134 | stub.enqueue(.init(status: 200, json: issueJSON)) |
| |
135 | let model = IssueDetailViewModel(client: client, repoPath: "krz/gitbay", number: 7) |
| |
136 | await model.load() |
| |
137 | |
| |
138 | stub.enqueue(.init(status: 200, json: """ |
| |
139 | {"protocol_version":1,"exit_code":0} |
| |
140 | """)) |
| |
141 | stub.enqueue(.init(status: 200, json: issueJSON)) |
| |
142 | await model.edit(title: "t2", body: "b2", format: "md") |
| |
143 | |
| |
144 | let argv = try argvOf(try #require(stub.seen.first { $0.method == "POST" })) |
| |
145 | #expect(argv.contains("--format")) |
| |
146 | #expect(argv.contains("md")) |
| |
147 | } |
| |
148 | } |
| |
149 | |
| |
150 | @MainActor |
| |
151 | struct BodyFormatMRWriteTests { |
| |
152 | |
| |
153 | private let plainDetail = """ |
| |
154 | {"number":7,"title":"a change","state":"open","author":"cmc",\ |
| |
155 | "source":"feat","target_ref":"main","head_sha":"abc123",\ |
| |
156 | "created_at":"2026-09-01T00:00:00Z"} |
| |
157 | """ |
| |
158 | |
| |
159 | private func loaded() throws -> (MRDetailViewModel, GitbayClient, StubProtocol.Box) { |
| |
160 | let (client, box) = try makeClient() |
| |
161 | box.enqueue(.init(status: 200, json: """ |
| |
162 | {"protocol_version":1,"data":\(plainDetail),"exit_code":0} |
| |
163 | """)) |
| |
164 | let model = MRDetailViewModel(client: client, repoPath: "krz/gitbay", number: 7) |
| |
165 | return (model, client, box) |
| |
166 | } |
| |
167 | |
| |
168 | /// `mr show` load also fires `mr diff` and `mr threads`; leave both |
| |
169 | /// unstubbed so they fail harmlessly, same as `MRLifecycleTests`. |
| |
170 | @Test func aMergeRequestCommentCarriesItsFormat() async throws { |
| |
171 | let (model, _, box) = try loaded() |
| |
172 | await model.load() |
| |
173 | |
| |
174 | box.enqueue(.init(status: 200, json: """ |
| |
175 | {"protocol_version":1,"exit_code":0} |
| |
176 | """)) |
| |
177 | box.enqueue(.init(status: 200, json: """ |
| |
178 | {"protocol_version":1,"data":\(plainDetail),"exit_code":0} |
| |
179 | """)) |
| |
180 | await model.comment("hello", format: "org") |
| |
181 | |
| |
182 | let write = try #require(box.seen.first { $0.method == "POST" }) |
| |
183 | #expect(try argvOf(write) |
| |
184 | == ["mr", "comment", "krz/gitbay", "7", "--file", "-", "--format", "org"]) |
| |
185 | #expect(try stdinOf(write) == "hello") |
| |
186 | } |
| |
187 | |
| |
188 | @Test func aMergeRequestCommentWithNoFormatOmitsTheFlag() async throws { |
| |
189 | let (model, _, box) = try loaded() |
| |
190 | await model.load() |
| |
191 | |
| |
192 | box.enqueue(.init(status: 200, json: """ |
| |
193 | {"protocol_version":1,"exit_code":0} |
| |
194 | """)) |
| |
195 | box.enqueue(.init(status: 200, json: """ |
| |
196 | {"protocol_version":1,"data":\(plainDetail),"exit_code":0} |
| |
197 | """)) |
| |
198 | await model.comment("hello", format: nil) |
| |
199 | |
| |
200 | let write = try #require(box.seen.first { $0.method == "POST" }) |
| |
201 | #expect(try argvOf(write).contains("--format") == false) |
| |
202 | } |
| |
203 | |
| |
204 | @Test func aMergeRequestEditCarriesItsFormat() async throws { |
| |
205 | let (model, _, box) = try loaded() |
| |
206 | await model.load() |
| |
207 | |
| |
208 | box.enqueue(.init(status: 200, json: """ |
| |
209 | {"protocol_version":1,"exit_code":0} |
| |
210 | """)) |
| |
211 | box.enqueue(.init(status: 200, json: """ |
| |
212 | {"protocol_version":1,"data":\(plainDetail),"exit_code":0} |
| |
213 | """)) |
| |
214 | await model.edit(title: "t2", body: "b2", format: "md") |
| |
215 | |
| |
216 | let write = try #require(box.seen.first { $0.method == "POST" }) |
| |
217 | #expect(try argvOf(write) |
| |
218 | == ["mr", "edit", "krz/gitbay", "7", "--title", "t2", "--file", "-", "--format", "md"]) |
| |
219 | #expect(try stdinOf(write) == "b2") |
| |
220 | } |
| |
221 | } |
| |
222 | |
| |
223 | @MainActor |
| |
224 | struct BodyFormatCreateTests { |
| |
225 | |
| |
226 | @Test func creatingAnIssueWithAFormatAppendsTheFlag() async throws { |
| |
227 | let (client, box) = try makeClient() |
| |
228 | box.enqueue(.init(status: 200, json: """ |
| |
229 | {"protocol_version":1,"data":{"number":7},"exit_code":0} |
| |
230 | """)) |
| |
231 | let model = IssueCreateViewModel(client: client, repoPath: "krz/gitbay") |
| |
232 | model.format = "org" |
| |
233 | _ = await model.create(title: "a title", body: "a body") |
| |
234 | |
| |
235 | let write = try #require(box.seen.first { $0.method == "POST" }) |
| |
236 | #expect(try argvOf(write) |
| |
237 | == ["issue", "create", "krz/gitbay", "--title", "a title", |
| |
238 | "--file", "-", "--format", "org"]) |
| |
239 | } |
| |
240 | |
| |
241 | @Test func creatingAnIssueWithoutAFormatOmitsTheFlag() async throws { |
| |
242 | let (client, box) = try makeClient() |
| |
243 | box.enqueue(.init(status: 200, json: """ |
| |
244 | {"protocol_version":1,"data":{"number":7},"exit_code":0} |
| |
245 | """)) |
| |
246 | let model = IssueCreateViewModel(client: client, repoPath: "krz/gitbay") |
| |
247 | _ = await model.create(title: "a title", body: "a body") |
| |
248 | |
| |
249 | let write = try #require(box.seen.first { $0.method == "POST" }) |
| |
250 | #expect(try argvOf(write).contains("--format") == false) |
| |
251 | } |
| |
252 | |
| |
253 | @Test func creatingAMergeRequestWithAFormatAppendsTheFlag() async throws { |
| |
254 | let (client, box) = try makeClient() |
| |
255 | box.enqueue(.init(status: 200, json: """ |
| |
256 | {"protocol_version":1,"data":{"number":7},"exit_code":0} |
| |
257 | """)) |
| |
258 | let model = MRCreateViewModel(client: client, repoPath: "krz/gitbay") |
| |
259 | model.format = "org" |
| |
260 | _ = await model.create(source: "feat", target: "main", title: "a change", body: "a body") |
| |
261 | |
| |
262 | let write = try #require(box.seen.first { $0.method == "POST" }) |
| |
263 | #expect(try argvOf(write) |
| |
264 | == ["mr", "create", "krz/gitbay", "--source", "feat", "--target", "main", |
| |
265 | "--title", "a change", "--file", "-", "--format", "org"]) |
| |
266 | } |
| |
267 | |
| |
268 | @Test func creatingAMergeRequestWithoutAFormatOmitsTheFlag() async throws { |
| |
269 | let (client, box) = try makeClient() |
| |
270 | box.enqueue(.init(status: 200, json: """ |
| |
271 | {"protocol_version":1,"data":{"number":7},"exit_code":0} |
| |
272 | """)) |
| |
273 | let model = MRCreateViewModel(client: client, repoPath: "krz/gitbay") |
| |
274 | _ = await model.create(source: "feat", target: "main", title: "a change", body: "a body") |
| |
275 | |
| |
276 | let write = try #require(box.seen.first { $0.method == "POST" }) |
| |
277 | #expect(try argvOf(write).contains("--format") == false) |
| |
278 | } |
| |
279 | } |