gitbayTests/MRViewModelTests.swift

v1.13.1
gitbay-ios/gitbayTests/MRViewModelTests.swift history · blame · raw

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}