gitbayTests/MRViewModelTests.swift
510 lines · 22366 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 let mrListJSON = """
16 {"protocol_version":1,"data":{"items":[\
17 {"number":7,"title":"client: envelope decoding","state":"open","author":"cmc",\
18 "source":"client-envelope","target_ref":"main","head_sha":"aabbcc",\
19 "created_at":"2026-08-20T10:00:00.000Z"},\
20 {"number":9,"title":"auth: keychain","state":"open","author":"cmc",\
21 "source":"krz/fork:auth","target_ref":"main","head_sha":"ddeeff",\
22 "created_at":"2026-08-21T10:00:00.000Z"}\
23 ]},"exit_code":0}
24 """
25
26private let mrShowJSON = """
27 {"protocol_version":1,"data":{"number":7,"title":"client: envelope decoding",\
28 "state":"open","author":"cmc","source":"client-envelope","target_ref":"main",\
29 "head_sha":"aabbcc","body":"Speaks both surfaces.","created_at":"2026-08-20T10:00:00.000Z",\
30 "checks":[{"context":"ci/build","state":"success","updated_at":"2026-08-20T10:05:00.000Z",\
31 "duration":"1m12s"},{"context":"external/lint","state":"success",\
32 "updated_at":"2026-08-20T10:06:00.000Z"}],"checks_combined":"success",\
33 "unresolved_threads":1,\
34 "commits":[{"sha":"aabbcc00000000000000","subject":"client: envelope"}],\
35 "comments":[{"author":"krz","body":"looks right","created_at":"2026-08-20T11:00:00.000Z"}],\
36 "reviews":[{"reviewer":"krz","verdict":"approve","stale":false,\
37 "created_at":"2026-08-20T10:30:00.000Z"}]},"exit_code":0}
38 """
39
40private let mrMergedJSON = """
41 {"protocol_version":1,"data":{"number":7,"title":"client: envelope decoding",\
42 "state":"merged","author":"cmc","source":"client-envelope","target_ref":"main",\
43 "head_sha":"aabbcc","created_at":"2026-08-20T10:00:00.000Z",\
44 "merged_at":"2026-08-21T09:15:00.000Z","merged_by":"cmc"},"exit_code":0}
45 """
46
47private let threadsJSON = """
48 {"protocol_version":1,"data":[\
49 {"id":3,"path":"gitbay/Client.swift","side":"new","line":42,"stale":false,\
50 "comments":[{"id":3,"author":"krz","body":"why retry twice?","created_at":"2026-08-20T10:30:00.000Z"}]},\
51 {"id":5,"path":"gitbay/Client.swift","side":"new","line":90,"stale":true,"resolved_by":"cmc",\
52 "comments":[{"id":5,"author":"krz","body":"naming","created_at":"2026-08-20T10:31:00.000Z"},\
53 {"id":6,"author":"cmc","body":"renamed","created_at":"2026-08-20T10:35:00.000Z"}]}\
54 ],"exit_code":0}
55 """
56
57private let diffText = """
58 diff --git a/main.go b/main.go
59 index 1234567..89abcde 100644
60 --- a/main.go
61 +++ b/main.go
62 @@ -1,4 +1,5 @@
63 package main
64 -import "fmt"
65 +import (
66 +\t"fmt"
67 +)
68
69 -func main() {}
70 @@ -10,2 +11,3 @@ func helper() {
71 \tx := 1
72 +\ty := 2
73 \t_ = x
74 diff --git a/new.txt b/new.txt
75 new file mode 100644
76 --- /dev/null
77 +++ b/new.txt
78 @@ -0,0 +1,2 @@
79 +hello
80 +world
81 """
82
83private func diffEnvelope() -> String {
84 let escaped = diffText
85 .replacingOccurrences(of: "\\", with: "\\\\")
86 .replacingOccurrences(of: "\"", with: "\\\"")
87 .replacingOccurrences(of: "\n", with: "\\n")
88 .replacingOccurrences(of: "\t", with: "\\t")
89 return "{\"protocol_version\":1,\"output\":\"\(escaped)\",\"exit_code\":0}"
90}
91
92struct UnifiedDiffParserTests {
93
94 @Test func parsesFilesHunksAndLineNumbers() {
95 let diff = UnifiedDiff.parse(diffText)
96
97 #expect(diff.files.count == 2)
98 let first = diff.files[0]
99 #expect(first.displayPath == "main.go")
100 #expect(first.hunks.count == 2)
101 #expect(first.additions == 4)
102 #expect(first.deletions == 2)
103
104 // Line numbering advances per side.
105 let lines = first.hunks[0].lines
106 #expect(lines[0].kind == .context)
107 #expect(lines[0].oldNumber == 1)
108 #expect(lines[0].newNumber == 1)
109 #expect(lines[1].kind == .deletion)
110 #expect(lines[1].oldNumber == 2)
111 #expect(lines[1].newNumber == nil)
112 #expect(lines[2].kind == .addition)
113 #expect(lines[2].oldNumber == nil)
114 #expect(lines[2].newNumber == 2)
115
116 // Second hunk restarts numbering from its header.
117 #expect(first.hunks[1].lines[0].oldNumber == 10)
118 #expect(first.hunks[1].lines[0].newNumber == 11)
119 }
120
121 @Test func newFilesAreMarked() {
122 let diff = UnifiedDiff.parse(diffText)
123 let added = diff.files[1]
124 #expect(added.isNew)
125 #expect(!added.isDeleted)
126 #expect(added.displayPath == "new.txt")
127 #expect(added.additions == 2)
128 }
129
130 @Test func totalsSumAcrossFiles() {
131 let diff = UnifiedDiff.parse(diffText)
132 #expect(diff.additions == 6)
133 #expect(diff.deletions == 2)
134 }
135
136 @Test func binaryFilesAreRecognised() {
137 let diff = UnifiedDiff.parse("""
138 diff --git a/logo.png b/logo.png
139 Binary files a/logo.png and b/logo.png differ
140 """)
141 #expect(diff.files.count == 1)
142 #expect(diff.files[0].isBinary)
143 }
144
145 @Test func emptyDiffParsesToNoFiles() {
146 #expect(UnifiedDiff.parse("").files.isEmpty)
147 }
148}
149
150@MainActor
151struct MRListViewModelTests {
152
153 @Test func listsInServerOrderWithPagingFlags() async throws {
154 let (client, stub) = try makeClient()
155 stub.enqueue(.init(status: 200, json: mrListJSON))
156 let model = MRListViewModel(client: client, repoPath: "krz/gitbay")
157
158 await model.load()
159
160 let mrs = try #require(model.state.value)
161 #expect(mrs.map(\.number) == [7, 9])
162 let seen = try #require(stub.seen.first)
163 #expect(seen.url.query() ==
164 "argv=mr&argv=list&argv=krz/gitbay&argv=--state&argv=open&argv=--limit&argv=50")
165 }
166
167 @Test func changingTheFilterReloadsWithThatState() async throws {
168 let (client, stub) = try makeClient()
169 stub.enqueue(.init(status: 200, json: mrListJSON))
170 stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"data":{"items":[]},"exit_code":0}"#))
171 let model = MRListViewModel(client: client, repoPath: "krz/gitbay")
172 await model.load()
173
174 model.filter.state = .merged
175 // The reload happens in a spawned task; wait for it to settle
176 // rather than for a fixed interval to pass. Checking `model.state`
177 // alone is not enough: right after the assignment, before the
178 // spawned task has even run, the state is still the *previous*
179 // load's non-loading result, which would satisfy a bare
180 // "not loading" check before the reload ever started.
181 await until {
182 guard stub.seen.count == 2 else { return false }
183 if case .loading = model.state { return false }
184 return true
185 }
186
187 #expect(stub.seen.count == 2)
188 let second = try #require(stub.seen.dropFirst().first)
189 #expect(second.url.query()?.contains("argv=merged") == true)
190 guard case .empty = model.state else {
191 Issue.record("expected .empty after filtering, got \(model.state)")
192 return
193 }
194 }
195}
196
197@MainActor
198struct MRDetailViewModelTests {
199
200 private func loadedModel() async throws -> (MRDetailViewModel, StubProtocol.Box) {
201 let (client, stub) = try makeClient()
202 stub.enqueue(.init(status: 200, json: mrShowJSON, match: "argv=show"))
203 stub.enqueue(.init(status: 200, json: diffEnvelope(), match: "argv=diff"))
204 stub.enqueue(.init(status: 200, json: threadsJSON, match: "argv=threads"))
205 let model = MRDetailViewModel(client: client, repoPath: "krz/gitbay", number: 7)
206 await model.load()
207 return (model, stub)
208 }
209
210 @Test func revisionsLoadBesideTheThreads() async throws {
211 let (client, stub) = try makeClient()
212 stub.enqueue(.init(status: 200, json: mrShowJSON, match: "argv=show"))
213 stub.enqueue(.init(status: 200, json: diffEnvelope(), match: "argv=diff"))
214 stub.enqueue(.init(status: 200, json: threadsJSON, match: "argv=threads"))
215 stub.enqueue(.init(status: 200, json: """
216 {"protocol_version":1,"data":[\
217 {"n":1,"sha":"aaaa1111aaaa1111","created_at":"2026-09-01T10:00:00.000Z"},\
218 {"n":2,"sha":"bbbb2222bbbb2222","base_sha":"aaaa1111aaaa1111",\
219 "created_at":"2026-09-02T10:00:00.000Z","current":true}],"exit_code":0}
220 """, match: "argv=revisions"))
221 let model = MRDetailViewModel(client: client, repoPath: "krz/gitbay", number: 7)
222
223 await model.load()
224
225 #expect(model.revisions.map(\.n) == [1, 2])
226 #expect(model.revisions.last?.isCurrent == true)
227 #expect(model.revisions.first?.isCurrent == false)
228 #expect(model.revisions.last?.baseSHA == "aaaa1111aaaa1111")
229 #expect(stub.seen.contains { $0.url.query()?.contains("argv=mr&argv=revisions&argv=krz/gitbay&argv=7") == true })
230 }
231
232 @Test func aFailedRevisionsReadLeavesTheHeaderUsable() async throws {
233 let (client, stub) = try makeClient()
234 stub.enqueue(.init(status: 200, json: mrShowJSON, match: "argv=show"))
235 stub.enqueue(.init(status: 200, json: diffEnvelope(), match: "argv=diff"))
236 stub.enqueue(.init(status: 200, json: threadsJSON, match: "argv=threads"))
237 stub.enqueue(.init(status: 500, json: #"{"protocol_version":1,"error":"boom","exit_code":1}"#, match: "argv=revisions"))
238 let model = MRDetailViewModel(client: client, repoPath: "krz/gitbay", number: 7)
239
240 await model.load()
241
242 #expect(model.state.value != nil)
243 #expect(model.revisions.isEmpty)
244 }
245
246 /// v1.16.0: `mr show` carries the merge gates, and each review says
247 /// whether it counts. Both decode; absent gates decode to nil.
248 @Test func gatesAndReviewCountsDecode() async throws {
249 let (client, stub) = try makeClient()
250 stub.enqueue(.init(status: 200, json: """
251 {"protocol_version":1,"data":{"number":9,"title":"gates","state":"open",\
252 "author":"dave","source":"fix","target_ref":"main","head_sha":"ccdd",\
253 "created_at":"2026-09-08T10:00:00.000Z",\
254 "reviews":[{"reviewer":"carol","verdict":"approve","stale":false,"counts":false,\
255 "created_at":"2026-09-08T10:30:00.000Z"}],\
256 "gates":{"approvals_required":1,"approvals":[],"codeowners_required":true,\
257 "owners_outstanding":[{"files":["widget.go"],"owners":["alice"]}],\
258 "resolved_required":true,"open_threads":2,"checks_required":false,\
259 "fast_forward":false,"unmet":["approvals","codeowners","threads","fast-forward"]}},\
260 "exit_code":0}
261 """, match: "argv=show"))
262 let model = MRDetailViewModel(client: client, repoPath: "krz/gitbay", number: 9)
263
264 await model.load()
265
266 let mr = try #require(model.state.value)
267 let gates = try #require(mr.gates)
268 #expect(gates.approvalsRequired == 1)
269 #expect(gates.approvals.isEmpty)
270 #expect(gates.codeownersRequired)
271 #expect(gates.ownersOutstanding.first?.files == ["widget.go"])
272 #expect(gates.ownersOutstanding.first?.owners == ["alice"])
273 #expect(gates.openThreads == 2)
274 #expect(!gates.fastForward)
275 #expect(gates.unmet == ["approvals", "codeowners", "threads", "fast-forward"])
276 #expect(!gates.isMet)
277 #expect(mr.reviews?.first?.counts == false)
278 }
279
280 @Test func aServerWithoutGatesDecodesToNoGatesAndCountingReviews() async throws {
281 let (model, _) = try await loadedModel()
282
283 let mr = try #require(model.state.value)
284 #expect(mr.gates == nil)
285 // A review that predates `counts` counted, since only writers could review.
286 #expect(mr.reviews?.first?.counts == true)
287 }
288
289 @Test func loadsHeaderDiffAndThreads() async throws {
290 let (model, _) = try await loadedModel()
291
292 let mr = try #require(model.state.value)
293 #expect(mr.title == "client: envelope decoding")
294 #expect(mr.checksCombined == "success")
295 #expect(mr.reviews?.first?.verdict == "approve")
296 #expect(mr.reviews?.first?.createdAt != nil)
297 #expect(model.diff?.files.count == 2)
298 #expect(model.threads.count == 2)
299 #expect(model.unresolvedCount == 1)
300 }
301
302 /// A CI check reports how long its build ran; anything else reports
303 /// only when it last changed. Neither may borrow the other's timing.
304 @Test func checksCarryTheirTiming() async throws {
305 let (model, _) = try await loadedModel()
306
307 let checks = try #require(model.state.value?.checks)
308 let ci = try #require(checks.first { $0.context == "ci/build" })
309 #expect(ci.duration == "1m12s")
310 #expect(ci.updatedAt != nil)
311 let external = try #require(checks.first { $0.context == "external/lint" })
312 #expect(external.duration == nil)
313 #expect(external.updatedAt != nil)
314 }
315
316 /// A merged MR says who merged it and when. An open one has no stamp
317 /// to show, and must not invent one.
318 @Test func resolutionStampsDecode() async throws {
319 let (client, stub) = try makeClient()
320 stub.enqueue(.init(status: 200, json: mrShowJSON, match: "argv=show"))
321 stub.enqueue(.init(status: 200, json: diffEnvelope(), match: "argv=diff"))
322 stub.enqueue(.init(status: 200, json: threadsJSON, match: "argv=threads"))
323 let open = MRDetailViewModel(client: client, repoPath: "krz/gitbay", number: 7)
324 await open.load()
325 #expect(open.state.value?.mergedAt == nil)
326 #expect(open.state.value?.closedAt == nil)
327
328 let (mergedClient, mergedStub) = try makeClient()
329 mergedStub.enqueue(.init(status: 200, json: mrMergedJSON, match: "argv=show"))
330 mergedStub.enqueue(.init(status: 200, json: diffEnvelope(), match: "argv=diff"))
331 mergedStub.enqueue(.init(status: 200, json: threadsJSON, match: "argv=threads"))
332 let merged = MRDetailViewModel(client: mergedClient, repoPath: "krz/gitbay", number: 7)
333 await merged.load()
334 #expect(merged.state.value?.mergedBy == "cmc")
335 #expect(merged.state.value?.mergedAt != nil)
336 }
337
338 @Test func approveSendsTheWriteThenReloads() async throws {
339 let (model, stub) = try await loadedModel()
340 stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"data":{},"exit_code":0}"#))
341 stub.enqueue(.init(status: 200, json: mrShowJSON, match: "argv=show"))
342 stub.enqueue(.init(status: 200, json: diffEnvelope(), match: "argv=diff"))
343 stub.enqueue(.init(status: 200, json: threadsJSON, match: "argv=threads"))
344
345 await model.review(.approve)
346
347 let write = try #require(stub.seen.first { $0.method == "POST" })
348 #expect(write.method == "POST")
349 #expect(write.url.path() == "/api/v1/cmd")
350 let body = try #require(try JSONSerialization.jsonObject(with: write.body) as? [String: Any])
351 #expect(body["argv"] as? [String] ==
352 ["mr", "review", "krz/gitbay", "7", "--approve"])
353 #expect(model.actionError == nil)
354 }
355
356 @Test func commentGoesThroughStdinNotArgv() async throws {
357 let (model, stub) = try await loadedModel()
358 stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"data":{},"exit_code":0}"#))
359 stub.enqueue(.init(status: 200, json: mrShowJSON, match: "argv=show"))
360 stub.enqueue(.init(status: 200, json: diffEnvelope(), match: "argv=diff"))
361 stub.enqueue(.init(status: 200, json: threadsJSON, match: "argv=threads"))
362
363 await model.comment("long review text\nwith lines")
364
365 let write = try #require(stub.seen.first { $0.method == "POST" })
366 let body = try #require(try JSONSerialization.jsonObject(with: write.body) as? [String: Any])
367 #expect(body["argv"] as? [String] == ["mr", "comment", "krz/gitbay", "7", "--file", "-"])
368 #expect(body["stdin"] as? String == "long review text\nwith lines")
369 }
370
371 @Test func aMergeRefusalSurfacesTheServersRuleVerbatim() async throws {
372 let (model, stub) = try await loadedModel()
373 stub.enqueue(.init(status: 403, json:
374 #"{"protocol_version":1,"error":"merge blocked: 1 review thread unresolved","exit_code":4}"#))
375
376 await model.merge()
377
378 #expect(model.actionError == "merge blocked: 1 review thread unresolved")
379 // The refusal did not wipe the loaded screen.
380 #expect(model.state.value != nil)
381 // show, diff, threads, revisions, then the refused write.
382 #expect(stub.seen.count == 5)
383 }
384
385 @Test func resolveTargetsTheThreadID() async throws {
386 let (model, stub) = try await loadedModel()
387 stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"data":{},"exit_code":0}"#))
388 stub.enqueue(.init(status: 200, json: mrShowJSON, match: "argv=show"))
389 stub.enqueue(.init(status: 200, json: diffEnvelope(), match: "argv=diff"))
390 stub.enqueue(.init(status: 200, json: threadsJSON, match: "argv=threads"))
391 let thread = try #require(model.threads.first { !$0.isResolved })
392
393 await model.setResolved(thread, true)
394
395 let write = try #require(stub.seen.first { $0.method == "POST" })
396 let body = try #require(try JSONSerialization.jsonObject(with: write.body) as? [String: Any])
397 #expect(body["argv"] as? [String] == ["mr", "resolve", "krz/gitbay", "7", "3"])
398 }
399
400 @Test func replyUsesDiffCommentWithReplyFlag() async throws {
401 let (model, stub) = try await loadedModel()
402 stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"data":{"id":9,"thread":3},"exit_code":0}"#))
403 stub.enqueue(.init(status: 200, json: mrShowJSON, match: "argv=show"))
404 stub.enqueue(.init(status: 200, json: diffEnvelope(), match: "argv=diff"))
405 stub.enqueue(.init(status: 200, json: threadsJSON, match: "argv=threads"))
406 let thread = try #require(model.threads.first { !$0.isResolved })
407
408 await model.reply(to: thread, "because 5xx is transient")
409
410 let write = try #require(stub.seen.first { $0.method == "POST" })
411 let body = try #require(try JSONSerialization.jsonObject(with: write.body) as? [String: Any])
412 #expect(body["argv"] as? [String] ==
413 ["mr", "diff-comment", "krz/gitbay", "7", "--reply", "3", "--file", "-"])
414 #expect(body["stdin"] as? String == "because 5xx is transient")
415 }
416}
417
418/// Where a review thread hangs in the diff. Threads the diff has moved
419/// past must not vanish — they render in their own section.
420struct ThreadAnchoringTests {
421
422 private func thread(
423 path: String, line: Int64, side: String = "new", stale: Bool = false
424 ) throws -> ReviewThread {
425 let json = """
426 {"id":1,"path":"\(path)","side":"\(side)","line":\(line),"stale":\(stale),\
427 "comments":[{"id":1,"author":"krz","body":"why?",\
428 "created_at":"2026-08-20T10:30:00.000Z"}]}
429 """
430 let decoder = GitbayClient.decoder()
431 return try decoder.decode(ReviewThread.self, from: Data(json.utf8))
432 }
433
434 private let diff = UnifiedDiff.parse("""
435 diff --git a/main.go b/main.go
436 --- a/main.go
437 +++ b/main.go
438 @@ -1,4 +1,5 @@
439 package main
440 -import "fmt"
441 +import (
442 +\t"fmt"
443 +)
444 """)
445
446 @Test func aThreadAnchorsToItsLineOnTheNewSide() throws {
447 // "+import (" is new line 2.
448 let subject = try thread(path: "main.go", line: 2)
449 #expect(diff.anchors(subject))
450
451 let file = try #require(diff.files.first)
452 let line = try #require(file.hunks.first?.lines.first { $0.newNumber == 2 })
453 #expect(line.anchors(subject, in: file))
454 }
455
456 @Test func anOldSideThreadAnchorsToTheDeletedLine() throws {
457 // `-import "fmt"` is old line 2 and has no new number.
458 let subject = try thread(path: "main.go", line: 2, side: "old")
459 #expect(diff.anchors(subject))
460
461 let file = try #require(diff.files.first)
462 let deletion = try #require(file.hunks.first?.lines.first { $0.kind == .deletion })
463 #expect(deletion.anchors(subject, in: file))
464 // The same line number on the other side is a different anchor.
465 let addition = try #require(file.hunks.first?.lines.first { $0.newNumber == 2 })
466 #expect(!addition.anchors(subject, in: file))
467 }
468
469 @Test func aStaleThreadAnchorsNowhere() throws {
470 // Its head is gone, so the line numbers cannot be trusted.
471 let subject = try thread(path: "main.go", line: 2, stale: true)
472 #expect(!diff.anchors(subject))
473 }
474
475 @Test func aThreadOnAnotherFileOrLineDoesNotAnchor() throws {
476 #expect(!diff.anchors(try thread(path: "other.go", line: 2)))
477 #expect(!diff.anchors(try thread(path: "main.go", line: 999)))
478 }
479}
480
481@MainActor
482struct RangeDiffViewModelTests {
483
484 @Test func readsTheRangeDiffBetweenTwoRevisions() async throws {
485 let (client, stub) = try makeClient()
486 stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"output":"1: aaaa111 = 1: bbbb222 client: envelope\n","exit_code":0}"#))
487 let model = RangeDiffViewModel(client: client, repoPath: "krz/gitbay", number: 7,
488 from: "aaaa1111aaaa1111", to: "bbbb2222bbbb2222")
489
490 await model.load()
491
492 #expect(model.state.value == "1: aaaa111 = 1: bbbb222 client: envelope\n")
493 #expect(stub.seen.first?.url.query() ==
494 "argv=mr&argv=range-diff&argv=krz/gitbay&argv=7&argv=--from&argv=aaaa1111aaaa1111&argv=--to&argv=bbbb2222bbbb2222")
495 }
496
497 @Test func anEmptyRangeDiffIsAnEmptyState() async throws {
498 let (client, stub) = try makeClient()
499 stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"output":"","exit_code":0}"#))
500 let model = RangeDiffViewModel(client: client, repoPath: "krz/gitbay", number: 7,
501 from: "a", to: "b")
502
503 await model.load()
504
505 guard case .empty = model.state else {
506 Issue.record("expected .empty, got \(model.state)")
507 return
508 }
509 }
510}