gitbayTests/CompareTests.swift

main
gitbay-ios/gitbayTests/CompareTests.swift history · blame · raw

355 lines · 13948 bytes

26 symbols in this file
  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 argvFrom(_ url: URL) -> [String] {
 16    URLComponents(url: url, resolvingAgainstBaseURL: false)?
 17        .queryItems?.filter { $0.name == "argv" }.compactMap(\.value) ?? []
 18}
 19
 20nonisolated private let patchJSON = """
 21    {"protocol_version":1,"data":{"base":"aaa111","head":"bbb222","merge_base":"ccc333",\
 22    "patch":"diff --git a/x.txt b/x.txt\\n--- a/x.txt\\n+++ b/x.txt\\n@@ -1 +1 @@\\n-old\\n+new\\n",\
 23    "truncated":false},"exit_code":0}
 24    """
 25
 26/// `StubProtocol` answers a request as soon as it arrives, and the actual
 27/// delivery back onto the main actor still crosses a real thread — so
 28/// polling its request log cannot promise "the response has not landed
 29/// yet" at the instant a test checks it. `GatedProtocol` pins that instant
 30/// down: `startLoading` blocks the request until the test explicitly
 31/// releases it, so a ref mutation made between arrival and release is
 32/// provably made while the request is still outstanding. Scoped per test
 33/// the same way `StubProtocol` is, via a marker header, so parallel tests
 34/// don't share a gate.
 35nonisolated private final class GatedProtocol: URLProtocol, @unchecked Sendable {
 36
 37    final class Box: Sendable {
 38        fileprivate let id = UUID().uuidString
 39        private let gate = Mutex<(continuation: CheckedContinuation<Void, Never>?, release: DispatchSemaphore?)>((nil, nil))
 40
 41        func session() -> URLSession {
 42            let configuration = URLSessionConfiguration.ephemeral
 43            configuration.protocolClasses = [GatedProtocol.self]
 44            configuration.httpAdditionalHeaders = [GatedProtocol.marker: id]
 45            return URLSession(configuration: configuration)
 46        }
 47
 48        /// Suspends until the in-flight request reaches `startLoading`,
 49        /// then returns the semaphore that must be signaled to let its
 50        /// (canned, successful) response through.
 51        func awaitArrival() async -> DispatchSemaphore {
 52            let release = DispatchSemaphore(value: 0)
 53            await withCheckedContinuation { (continuation: CheckedContinuation<Void, Never>) in
 54                gate.withLock { $0 = (continuation, release) }
 55            }
 56            return release
 57        }
 58
 59        fileprivate func arrived() -> (CheckedContinuation<Void, Never>?, DispatchSemaphore?) {
 60            gate.withLock { state in
 61                let result = state
 62                state.continuation = nil
 63                return result
 64            }
 65        }
 66    }
 67
 68    private static let marker = "X-Gated-Box"
 69    private static let boxes = Mutex<[String: Box]>([:])
 70
 71    static func box() -> Box {
 72        let box = Box()
 73        boxes.withLock { $0[box.id] = box }
 74        return box
 75    }
 76
 77    override static func canInit(with _: URLRequest) -> Bool { true }
 78    override static func canonicalRequest(for request: URLRequest) -> URLRequest { request }
 79
 80    override func startLoading() {
 81        guard let id = request.value(forHTTPHeaderField: Self.marker),
 82              let box = Self.boxes.withLock({ $0[id] }) else {
 83            client?.urlProtocol(self, didFailWithError: URLError(.resourceUnavailable))
 84            return
 85        }
 86        let (continuation, release) = box.arrived()
 87        continuation?.resume()
 88        release?.wait() // blocks this background thread until the test releases it
 89
 90        let response = HTTPURLResponse(
 91            url: request.url!,
 92            statusCode: 200,
 93            httpVersion: "HTTP/1.1",
 94            headerFields: ["Content-Type": "application/json"]
 95        )!
 96        client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
 97        client?.urlProtocol(self, didLoad: Data(patchJSON.utf8))
 98        client?.urlProtocolDidFinishLoading(self)
 99    }
100
101    override func stopLoading() {}
102}
103
104struct RepoDiffDecodingTests {
105
106    @Test func decodesEveryField() throws {
107        let decoder = JSONDecoder()
108        let json = """
109            {"base":"aaa111","head":"bbb222","merge_base":"ccc333","patch":"x","truncated":true}
110            """
111        let diff = try decoder.decode(RepoDiff.self, from: Data(json.utf8))
112        #expect(diff.base == "aaa111")
113        #expect(diff.head == "bbb222")
114        #expect(diff.mergeBase == "ccc333")
115        #expect(diff.patch == "x")
116        #expect(diff.truncated)
117    }
118}
119
120@MainActor
121struct CompareViewModelTests {
122
123    @Test func bothRefsArePositionalAndBaseComesFirst() async throws {
124        let (client, stub) = try makeClient()
125        stub.enqueue(.init(status: 200, json: patchJSON))
126        let model = CompareViewModel(client: client, repoPath: "krz/gitbay")
127        model.base = "main"
128        model.head = "feature"
129        await model.compare()
130
131        #expect(argvFrom(try #require(stub.seen.last).url)
132            == ["repo", "diff", "krz/gitbay", "main", "feature"])
133    }
134
135    @Test func theParsedPatchReachesTheScreen() async throws {
136        let (client, stub) = try makeClient()
137        stub.enqueue(.init(status: 200, json: patchJSON))
138        let model = CompareViewModel(client: client, repoPath: "krz/gitbay")
139        model.base = "main"
140        model.head = "feature"
141        await model.compare()
142
143        #expect(model.state.value?.files.count == 1)
144        #expect(model.mergeBase == "ccc333")
145        #expect(model.truncated == false)
146    }
147
148    /// A partial patch rendered as though whole is a bug the user cannot
149    /// see, so the flag must survive to the screen.
150    @Test func truncationIsCarriedThrough() async throws {
151        let (client, stub) = try makeClient()
152        stub.enqueue(.init(status: 200, json: """
153            {"protocol_version":1,"data":{"base":"a","head":"b","merge_base":"c",\
154            "patch":"diff --git a/x b/x\\n--- a/x\\n+++ b/x\\n@@ -1 +1 @@\\n-a\\n+b\\n",\
155            "truncated":true},"exit_code":0}
156            """))
157        let model = CompareViewModel(client: client, repoPath: "krz/gitbay")
158        model.base = "main"
159        model.head = "feature"
160        await model.compare()
161
162        #expect(model.truncated)
163    }
164
165    @Test func comparingARefWithItselfIsEmptyNotFailed() async throws {
166        let (client, stub) = try makeClient()
167        stub.enqueue(.init(status: 200, json: """
168            {"protocol_version":1,"data":{"base":"a","head":"a","merge_base":"a",\
169            "patch":"","truncated":false},"exit_code":0}
170            """))
171        let model = CompareViewModel(client: client, repoPath: "krz/gitbay")
172        model.base = "main"
173        model.head = "main"
174        await model.compare()
175
176        guard case .empty = model.state else {
177            Testing.Issue.record("expected empty, got \(model.state)")
178            return
179        }
180    }
181
182    @Test func aBlankRefComparesNothing() async throws {
183        let (client, stub) = try makeClient()
184        let model = CompareViewModel(client: client, repoPath: "krz/gitbay")
185        model.base = "main"
186        model.head = "   "
187        await model.compare()
188
189        #expect(stub.seen.isEmpty)
190    }
191
192    @Test func refsComeFromRepoRefs() async throws {
193        let (client, stub) = try makeClient()
194        stub.enqueue(.init(status: 200, json: """
195            {"protocol_version":1,"data":{"branches":[{"name":"main","sha":"a"},\
196            {"name":"feature","sha":"b"}],"tags":[{"name":"v1","sha":"c"}]},"exit_code":0}
197            """))
198        let model = CompareViewModel(client: client, repoPath: "krz/gitbay")
199        await model.loadRefs()
200
201        #expect(model.refs?.branches.map(\.name) == ["main", "feature"])
202        #expect(model.refs?.tags.map(\.name) == ["v1"])
203        #expect(argvFrom(try #require(stub.seen.last).url)
204            == ["repo", "refs", "krz/gitbay"])
205    }
206
207    /// The lesson from the retarget picker: a failed ref load must not
208    /// look like a repository with no branches.
209    @Test func aFailedRefLoadIsDistinguishableFromNoBranches() async throws {
210        let (client, stub) = try makeClient()
211        stub.enqueue(.init(status: 200, json: """
212            {"protocol_version":1,"error":"denied","exit_code":4}
213            """))
214        let model = CompareViewModel(client: client, repoPath: "krz/gitbay")
215        await model.loadRefs()
216
217        #expect(model.refs == nil)
218        #expect(model.refsError?.isEmpty == false)
219    }
220
221    @Test func aFailedCompareIsTheScreensState() async throws {
222        let (client, stub) = try makeClient()
223        stub.enqueue(.init(status: 200, json: """
224            {"protocol_version":1,"error":"unknown revision","exit_code":1}
225            """))
226        let model = CompareViewModel(client: client, repoPath: "krz/gitbay")
227        model.base = "main"
228        model.head = "nope"
229        await model.compare()
230
231        guard case .failed = model.state else {
232            Testing.Issue.record("expected failed, got \(model.state)")
233            return
234        }
235    }
236
237    /// The realistic failure from `repo diff` on an unresolvable ref is
238    /// exit 3, which `GitbayError.isEmptyState` maps to `.empty`, not
239    /// `.failed` — pinning that mapping rather than assuming it.
240    @Test func anUnresolvableRefIsEmptyNotFailed() async throws {
241        let (client, stub) = try makeClient()
242        stub.enqueue(.init(status: 200, json: """
243            {"protocol_version":1,"error":"unknown revision","exit_code":3}
244            """))
245        let model = CompareViewModel(client: client, repoPath: "krz/gitbay")
246        model.base = "main"
247        model.head = "nope"
248        await model.compare()
249
250        guard case .empty = model.state else {
251            Testing.Issue.record("expected empty, got \(model.state)")
252            return
253        }
254    }
255
256    /// The race from the retarget bug: a compare is outstanding, the user
257    /// changes a ref before it resolves, and the outstanding request's
258    /// result must not land — it belongs to a ref pairing nobody is
259    /// looking at any more. `GatedProtocol` makes the interleaving exact
260    /// rather than probable: the request is provably still unanswered when
261    /// the ref changes, because nothing has let its response through yet.
262    @Test func aStaleCompareDoesNotOverwriteANewerRef() async throws {
263        let box = GatedProtocol.box()
264        let client = GitbayClient(
265            instance: try GitbayInstance(url: "https://gitbay.org"),
266            token: "test-token",
267            session: box.session()
268        )
269        let model = CompareViewModel(client: client, repoPath: "krz/gitbay")
270        model.base = "aaa111"
271        model.head = "v1"
272
273        let task = Task { await model.compare() }
274        let release = await box.awaitArrival() // the v1 request has arrived and is now blocked
275
276        guard case .loading = model.state else {
277            Testing.Issue.record("expected .loading while the request is outstanding, got \(model.state)")
278            release.signal()
279            await task.value
280            return
281        }
282
283        model.head = "v2" // ref changed while the v1 request is still in flight
284        release.signal() // let the v1 response through now that it's stale
285        await task.value
286
287        #expect(model.state.value == nil)
288        #expect(model.mergeBase == nil)
289        #expect(model.truncated == false)
290    }
291
292    /// A superseded compare must not leave the screen presenting as
293    /// in-flight forever: the ref change has to clear `.loading`
294    /// immediately, and the discarded response arriving afterward must
295    /// not put it back.
296    @Test func aSupersededCompareStopsPresentingAsInFlight() async throws {
297        let box = GatedProtocol.box()
298        let client = GitbayClient(
299            instance: try GitbayInstance(url: "https://gitbay.org"),
300            token: "test-token",
301            session: box.session()
302        )
303        let model = CompareViewModel(client: client, repoPath: "krz/gitbay")
304        model.base = "aaa111"
305        model.head = "v1"
306
307        let task = Task { await model.compare() }
308        let release = await box.awaitArrival()
309
310        guard case .loading = model.state else {
311            Testing.Issue.record("expected .loading while the request is outstanding, got \(model.state)")
312            release.signal()
313            await task.value
314            return
315        }
316
317        model.head = "v2" // ref changed while the v1 request is still in flight
318
319        if case .loading = model.state {
320            Testing.Issue.record("ref change did not clear .loading immediately")
321        }
322        release.signal()
323        await task.value
324
325        if case .loading = model.state {
326            Testing.Issue.record("superseded compare's response left the screen stuck presenting as in-flight")
327        }
328    }
329
330    /// A second compare that fails must not show the first compare's
331    /// merge base or truncation notice beside the failed state.
332    @Test func aFailedRecompareDropsTheStaleResult() async throws {
333        let (client, stub) = try makeClient()
334        stub.enqueue(.init(status: 200, json: patchJSON))
335        let model = CompareViewModel(client: client, repoPath: "krz/gitbay")
336        model.base = "main"
337        model.head = "feature"
338        await model.compare()
339
340        #expect(model.mergeBase == "ccc333")
341        #expect(model.state.value != nil)
342
343        stub.enqueue(.init(status: 200, json: """
344            {"protocol_version":1,"error":"unknown revision","exit_code":1}
345            """))
346        await model.compare()
347
348        guard case .failed = model.state else {
349            Testing.Issue.record("expected failed, got \(model.state)")
350            return
351        }
352        #expect(model.mergeBase == nil)
353        #expect(model.truncated == false)
354    }
355}