a native ios client for gitbay

client ios swift

https://gitbay.org

gitbayTests/MRViewModelTests.swift

ui-smoke
gitbay-ios/gitbayTests/MRViewModelTests.swift history · blame · raw

343 lines · 13734 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":"build","state":"success"}],"checks_combined":"success",\
 31    "unresolved_threads":1,\
 32    "commits":[{"sha":"aabbcc00000000000000","subject":"client: envelope"}],\
 33    "comments":[{"author":"krz","body":"looks right","created_at":"2026-08-20T11:00:00.000Z"}],\
 34    "reviews":[{"reviewer":"krz","verdict":"approve","stale":false}]},"exit_code":0}
 35    """
 36
 37private let threadsJSON = """
 38    {"protocol_version":1,"data":[\
 39    {"id":3,"path":"gitbay/Client.swift","side":"new","line":42,"stale":false,\
 40    "comments":[{"id":3,"author":"krz","body":"why retry twice?","created_at":"2026-08-20T10:30:00.000Z"}]},\
 41    {"id":5,"path":"gitbay/Client.swift","side":"new","line":90,"stale":true,"resolved_by":"cmc",\
 42    "comments":[{"id":5,"author":"krz","body":"naming","created_at":"2026-08-20T10:31:00.000Z"},\
 43    {"id":6,"author":"cmc","body":"renamed","created_at":"2026-08-20T10:35:00.000Z"}]}\
 44    ],"exit_code":0}
 45    """
 46
 47private let diffText = """
 48    diff --git a/main.go b/main.go
 49    index 1234567..89abcde 100644
 50    --- a/main.go
 51    +++ b/main.go
 52    @@ -1,4 +1,5 @@
 53     package main
 54    -import "fmt"
 55    +import (
 56    +\t"fmt"
 57    +)
 58     
 59    -func main() {}
 60    @@ -10,2 +11,3 @@ func helper() {
 61     \tx := 1
 62    +\ty := 2
 63     \t_ = x
 64    diff --git a/new.txt b/new.txt
 65    new file mode 100644
 66    --- /dev/null
 67    +++ b/new.txt
 68    @@ -0,0 +1,2 @@
 69    +hello
 70    +world
 71    """
 72
 73private func diffEnvelope() -> String {
 74    let escaped = diffText
 75        .replacingOccurrences(of: "\\", with: "\\\\")
 76        .replacingOccurrences(of: "\"", with: "\\\"")
 77        .replacingOccurrences(of: "\n", with: "\\n")
 78        .replacingOccurrences(of: "\t", with: "\\t")
 79    return "{\"protocol_version\":1,\"output\":\"\(escaped)\",\"exit_code\":0}"
 80}
 81
 82struct UnifiedDiffParserTests {
 83
 84    @Test func parsesFilesHunksAndLineNumbers() {
 85        let diff = UnifiedDiff.parse(diffText)
 86
 87        #expect(diff.files.count == 2)
 88        let first = diff.files[0]
 89        #expect(first.displayPath == "main.go")
 90        #expect(first.hunks.count == 2)
 91        #expect(first.additions == 4)
 92        #expect(first.deletions == 2)
 93
 94        // Line numbering advances per side.
 95        let lines = first.hunks[0].lines
 96        #expect(lines[0].kind == .context)
 97        #expect(lines[0].oldNumber == 1)
 98        #expect(lines[0].newNumber == 1)
 99        #expect(lines[1].kind == .deletion)
100        #expect(lines[1].oldNumber == 2)
101        #expect(lines[1].newNumber == nil)
102        #expect(lines[2].kind == .addition)
103        #expect(lines[2].oldNumber == nil)
104        #expect(lines[2].newNumber == 2)
105
106        // Second hunk restarts numbering from its header.
107        #expect(first.hunks[1].lines[0].oldNumber == 10)
108        #expect(first.hunks[1].lines[0].newNumber == 11)
109    }
110
111    @Test func newFilesAreMarked() {
112        let diff = UnifiedDiff.parse(diffText)
113        let added = diff.files[1]
114        #expect(added.isNew)
115        #expect(!added.isDeleted)
116        #expect(added.displayPath == "new.txt")
117        #expect(added.additions == 2)
118    }
119
120    @Test func totalsSumAcrossFiles() {
121        let diff = UnifiedDiff.parse(diffText)
122        #expect(diff.additions == 6)
123        #expect(diff.deletions == 2)
124    }
125
126    @Test func binaryFilesAreRecognised() {
127        let diff = UnifiedDiff.parse("""
128            diff --git a/logo.png b/logo.png
129            Binary files a/logo.png and b/logo.png differ
130            """)
131        #expect(diff.files.count == 1)
132        #expect(diff.files[0].isBinary)
133    }
134
135    @Test func emptyDiffParsesToNoFiles() {
136        #expect(UnifiedDiff.parse("").files.isEmpty)
137    }
138}
139
140@MainActor
141struct MRListViewModelTests {
142
143    @Test func listsInServerOrderWithPagingFlags() async throws {
144        let (client, stub) = try makeClient()
145        stub.enqueue(.init(status: 200, json: mrListJSON))
146        let model = MRListViewModel(client: client, repoPath: "krz/gitbay")
147
148        await model.load()
149
150        let mrs = try #require(model.state.value)
151        #expect(mrs.map(\.number) == [7, 9])
152        let seen = try #require(stub.seen.first)
153        #expect(seen.url.query() ==
154            "argv=mr&argv=list&argv=krz/gitbay&argv=--state&argv=open&argv=--limit&argv=50")
155    }
156
157    @Test func changingTheFilterReloadsWithThatState() async throws {
158        let (client, stub) = try makeClient()
159        stub.enqueue(.init(status: 200, json: mrListJSON))
160        stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"data":{"items":[]},"exit_code":0}"#))
161        let model = MRListViewModel(client: client, repoPath: "krz/gitbay")
162        await model.load()
163
164        model.filter = .merged
165        // The reload happens in a spawned task; give it a beat.
166        try await Task.sleep(for: .milliseconds(300))
167
168        #expect(stub.seen.count == 2)
169        #expect(stub.seen[1].url.query()?.contains("argv=merged") == true)
170        guard case .empty = model.state else {
171            Issue.record("expected .empty after filtering, got \(model.state)")
172            return
173        }
174    }
175}
176
177@MainActor
178struct MRDetailViewModelTests {
179
180    private func loadedModel() async throws -> (MRDetailViewModel, StubProtocol.Box) {
181        let (client, stub) = try makeClient()
182        stub.enqueue(.init(status: 200, json: mrShowJSON, match: "argv=show"))
183        stub.enqueue(.init(status: 200, json: diffEnvelope(), match: "argv=diff"))
184        stub.enqueue(.init(status: 200, json: threadsJSON, match: "argv=threads"))
185        let model = MRDetailViewModel(client: client, repoPath: "krz/gitbay", number: 7)
186        await model.load()
187        return (model, stub)
188    }
189
190    @Test func loadsHeaderDiffAndThreads() async throws {
191        let (model, _) = try await loadedModel()
192
193        let mr = try #require(model.state.value)
194        #expect(mr.title == "client: envelope decoding")
195        #expect(mr.checksCombined == "success")
196        #expect(mr.reviews?.first?.verdict == "approve")
197        #expect(model.diff?.files.count == 2)
198        #expect(model.threads.count == 2)
199        #expect(model.unresolvedCount == 1)
200    }
201
202    @Test func approveSendsTheWriteThenReloads() async throws {
203        let (model, stub) = try await loadedModel()
204        stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"data":{},"exit_code":0}"#))
205        stub.enqueue(.init(status: 200, json: mrShowJSON, match: "argv=show"))
206        stub.enqueue(.init(status: 200, json: diffEnvelope(), match: "argv=diff"))
207        stub.enqueue(.init(status: 200, json: threadsJSON, match: "argv=threads"))
208
209        await model.review(.approve)
210
211        let write = stub.seen[3]
212        #expect(write.method == "POST")
213        #expect(write.url.path() == "/api/v1/cmd")
214        let body = try #require(try JSONSerialization.jsonObject(with: write.body) as? [String: Any])
215        #expect(body["argv"] as? [String] ==
216            ["mr", "review", "krz/gitbay", "7", "--approve"])
217        #expect(model.actionError == nil)
218    }
219
220    @Test func commentGoesThroughStdinNotArgv() async throws {
221        let (model, stub) = try await loadedModel()
222        stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"data":{},"exit_code":0}"#))
223        stub.enqueue(.init(status: 200, json: mrShowJSON, match: "argv=show"))
224        stub.enqueue(.init(status: 200, json: diffEnvelope(), match: "argv=diff"))
225        stub.enqueue(.init(status: 200, json: threadsJSON, match: "argv=threads"))
226
227        await model.comment("long review text\nwith lines")
228
229        let write = stub.seen[3]
230        let body = try #require(try JSONSerialization.jsonObject(with: write.body) as? [String: Any])
231        #expect(body["argv"] as? [String] == ["mr", "comment", "krz/gitbay", "7", "--file", "-"])
232        #expect(body["stdin"] as? String == "long review text\nwith lines")
233    }
234
235    @Test func aMergeRefusalSurfacesTheServersRuleVerbatim() async throws {
236        let (model, stub) = try await loadedModel()
237        stub.enqueue(.init(status: 403, json:
238            #"{"protocol_version":1,"error":"merge blocked: 1 review thread unresolved","exit_code":4}"#))
239
240        await model.merge()
241
242        #expect(model.actionError == "merge blocked: 1 review thread unresolved")
243        // The refusal did not wipe the loaded screen.
244        #expect(model.state.value != nil)
245        #expect(stub.seen.count == 4)
246    }
247
248    @Test func resolveTargetsTheThreadID() async throws {
249        let (model, stub) = try await loadedModel()
250        stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"data":{},"exit_code":0}"#))
251        stub.enqueue(.init(status: 200, json: mrShowJSON, match: "argv=show"))
252        stub.enqueue(.init(status: 200, json: diffEnvelope(), match: "argv=diff"))
253        stub.enqueue(.init(status: 200, json: threadsJSON, match: "argv=threads"))
254        let thread = try #require(model.threads.first { !$0.isResolved })
255
256        await model.setResolved(thread, true)
257
258        let write = stub.seen[3]
259        let body = try #require(try JSONSerialization.jsonObject(with: write.body) as? [String: Any])
260        #expect(body["argv"] as? [String] == ["mr", "resolve", "krz/gitbay", "7", "3"])
261    }
262
263    @Test func replyUsesDiffCommentWithReplyFlag() async throws {
264        let (model, stub) = try await loadedModel()
265        stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"data":{"id":9,"thread":3},"exit_code":0}"#))
266        stub.enqueue(.init(status: 200, json: mrShowJSON, match: "argv=show"))
267        stub.enqueue(.init(status: 200, json: diffEnvelope(), match: "argv=diff"))
268        stub.enqueue(.init(status: 200, json: threadsJSON, match: "argv=threads"))
269        let thread = try #require(model.threads.first { !$0.isResolved })
270
271        await model.reply(to: thread, "because 5xx is transient")
272
273        let write = stub.seen[3]
274        let body = try #require(try JSONSerialization.jsonObject(with: write.body) as? [String: Any])
275        #expect(body["argv"] as? [String] ==
276            ["mr", "diff-comment", "krz/gitbay", "7", "--reply", "3", "--file", "-"])
277        #expect(body["stdin"] as? String == "because 5xx is transient")
278    }
279}
280
281/// Where a review thread hangs in the diff. Threads the diff has moved
282/// past must not vanish  they render in their own section.
283struct ThreadAnchoringTests {
284
285    private func thread(
286        path: String, line: Int64, side: String = "new", stale: Bool = false
287    ) throws -> ReviewThread {
288        let json = """
289            {"id":1,"path":"\(path)","side":"\(side)","line":\(line),"stale":\(stale),\
290            "comments":[{"id":1,"author":"krz","body":"why?",\
291            "created_at":"2026-08-20T10:30:00.000Z"}]}
292            """
293        let decoder = JSONDecoder()
294        decoder.dateDecodingStrategy = .iso8601
295        return try decoder.decode(ReviewThread.self, from: Data(json.utf8))
296    }
297
298    private let diff = UnifiedDiff.parse("""
299        diff --git a/main.go b/main.go
300        --- a/main.go
301        +++ b/main.go
302        @@ -1,4 +1,5 @@
303         package main
304        -import "fmt"
305        +import (
306        +\t"fmt"
307        +)
308        """)
309
310    @Test func aThreadAnchorsToItsLineOnTheNewSide() throws {
311        // "+import (" is new line 2.
312        let subject = try thread(path: "main.go", line: 2)
313        #expect(diff.anchors(subject))
314
315        let file = try #require(diff.files.first)
316        let line = try #require(file.hunks.first?.lines.first { $0.newNumber == 2 })
317        #expect(line.anchors(subject, in: file))
318    }
319
320    @Test func anOldSideThreadAnchorsToTheDeletedLine() throws {
321        // `-import "fmt"` is old line 2 and has no new number.
322        let subject = try thread(path: "main.go", line: 2, side: "old")
323        #expect(diff.anchors(subject))
324
325        let file = try #require(diff.files.first)
326        let deletion = try #require(file.hunks.first?.lines.first { $0.kind == .deletion })
327        #expect(deletion.anchors(subject, in: file))
328        // The same line number on the other side is a different anchor.
329        let addition = try #require(file.hunks.first?.lines.first { $0.newNumber == 2 })
330        #expect(!addition.anchors(subject, in: file))
331    }
332
333    @Test func aStaleThreadAnchorsNowhere() throws {
334        // Its head is gone, so the line numbers cannot be trusted.
335        let subject = try thread(path: "main.go", line: 2, stale: true)
336        #expect(!diff.anchors(subject))
337    }
338
339    @Test func aThreadOnAnotherFileOrLineDoesNotAnchor() throws {
340        #expect(!diff.anchors(try thread(path: "other.go", line: 2)))
341        #expect(!diff.anchors(try thread(path: "main.go", line: 999)))
342    }
343}