gitbayTests/DepsTests.swift

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

194 lines · 7767 bytes

16 symbols in this file
  1import Foundation
  2import Testing
  3
  4@testable import gitbay
  5
  6private func makeClient() throws -> (GitbayClient, StubProtocol.Box) {
  7    let box = StubProtocol.box()
  8    let client = GitbayClient(
  9        instance: try GitbayInstance(url: "https://gitbay.org"),
 10        token: "test-token",
 11        session: box.session()
 12    )
 13    return (client, box)
 14}
 15
 16private func argvFrom(_ url: URL) -> [String] {
 17    URLComponents(url: url, resolvingAgainstBaseURL: false)?
 18        .queryItems?.filter { $0.name == "argv" }.compactMap(\.value) ?? []
 19}
 20
 21private func argvOf(_ seen: StubProtocol.Seen) throws -> [String] {
 22    let body = try #require(try JSONSerialization.jsonObject(with: seen.body) as? [String: Any])
 23    return try #require(body["argv"] as? [String])
 24}
 25
 26private let fullStatus = """
 27    {"protocol_version":1,"data":{"enabled":true,\
 28    "last_check":"2026-09-06T09:47:07.905Z","issue_number":140,\
 29    "behind":[{"ecosystem":"go","name":"github.com/yuin/goldmark",\
 30    "current":"v1.8.5","latest":"v1.8.6"}]},"exit_code":0}
 31    """
 32
 33struct DepsDecodingTests {
 34
 35    private func decode(_ json: String) throws -> DepsStatus {
 36        let decoder = GitbayClient.decoder()
 37        return try decoder.decode(DepsStatus.self, from: Data(json.utf8))
 38    }
 39
 40    /// A repository whose checks were just switched on has never been
 41    /// checked, so it carries none of the three omitempty fields. That is
 42    /// the ordinary case, not an edge case.
 43    @Test func aNeverCheckedRepositoryHasNoTimestampErrorOrIssue() throws {
 44        let status = try decode("""
 45            {"enabled":true,"behind":[]}
 46            """)
 47        #expect(status.enabled)
 48        #expect(status.lastCheck == nil)
 49        #expect(status.lastError == nil)
 50        #expect(status.issueNumber == nil)
 51        #expect(status.behind.isEmpty)
 52    }
 53
 54    @Test func aCheckedRepositoryDecodesEverything() throws {
 55        let status = try decode("""
 56            {"enabled":true,"last_check":"2026-09-06T09:47:07Z","issue_number":140,\
 57            "behind":[{"ecosystem":"go","name":"modernc.org/sqlite",\
 58            "current":"v1.57.0","latest":"v1.58.0"}]}
 59            """)
 60        #expect(status.lastCheck != nil)
 61        #expect(status.issueNumber == 140)
 62        #expect(status.behind.first?.name == "modernc.org/sqlite")
 63        #expect(status.behind.first?.latest == "v1.58.0")
 64    }
 65
 66    @Test func aFailedCheckCarriesItsError() throws {
 67        let status = try decode("""
 68            {"enabled":true,"last_error":"could not read go.mod","behind":[]}
 69            """)
 70        #expect(status.lastError == "could not read go.mod")
 71    }
 72
 73    /// Two ecosystems can carry the same package name, so the id must
 74    /// include the ecosystem or SwiftUI reuses one row for the other.
 75    @Test func behindRowsAreUniqueAcrossEcosystems() throws {
 76        let status = try decode("""
 77            {"enabled":true,"behind":[\
 78            {"ecosystem":"go","name":"x","current":"1","latest":"2"},\
 79            {"ecosystem":"npm","name":"x","current":"1","latest":"2"}]}
 80            """)
 81        let ids = status.behind.map(\.id)
 82        #expect(Set(ids).count == 2)
 83    }
 84}
 85
 86@MainActor
 87struct DepsActionTests {
 88
 89    /// All three are THREE-word paths. The app sends two-word `repo`
 90    /// commands everywhere else, so this is easy to get wrong.
 91    @Test func statusUsesTheThreeWordPath() async throws {
 92        let (client, stub) = try makeClient()
 93        stub.enqueue(.init(status: 200, json: fullStatus))
 94        let model = RepoSettingsViewModel(client: client, repoPath: "krz/gitbay")
 95        await model.loadDeps()
 96
 97        #expect(argvFrom(try #require(stub.seen.last).url)
 98            == ["repo", "deps", "status", "krz/gitbay"])
 99        #expect(model.deps?.enabled == true)
100        #expect(model.deps?.behind.count == 1)
101    }
102
103    @Test func enableAndDisableAreDifferentCommands() async throws {
104        let (client, stub) = try makeClient()
105        let model = RepoSettingsViewModel(client: client, repoPath: "krz/gitbay")
106
107        stub.enqueue(.init(status: 200, json: """
108            {"protocol_version":1,"exit_code":0}
109            """))
110        stub.enqueue(.init(status: 200, json: fullStatus))
111        await model.setDepsEnabled(true)
112        var write = try #require(stub.seen.first { $0.method == "POST" })
113        #expect(try argvOf(write) == ["repo", "deps", "enable", "krz/gitbay"])
114        // The write must reload deps specifically, not the whole settings
115        // screen — otherwise this enqueued status stub goes unconsumed.
116        #expect(model.deps?.enabled == true)
117
118        let (client2, stub2) = try makeClient()
119        let model2 = RepoSettingsViewModel(client: client2, repoPath: "krz/gitbay")
120        stub2.enqueue(.init(status: 200, json: """
121            {"protocol_version":1,"exit_code":0}
122            """))
123        stub2.enqueue(.init(status: 200, json: fullStatus))
124        await model2.setDepsEnabled(false)
125        write = try #require(stub2.seen.first { $0.method == "POST" })
126        #expect(try argvOf(write) == ["repo", "deps", "disable", "krz/gitbay"])
127        #expect(model2.deps?.enabled == true)
128    }
129
130    /// "Could not ask" must not render as "checks are off".
131    @Test func aFailedStatusReadIsDistinguishableFromChecksBeingOff() async throws {
132        let (client, stub) = try makeClient()
133        stub.enqueue(.init(status: 200, json: """
134            {"protocol_version":1,"error":"denied","exit_code":4}
135            """))
136        let model = RepoSettingsViewModel(client: client, repoPath: "krz/gitbay")
137        await model.loadDeps()
138
139        #expect(model.deps == nil)
140        #expect(model.depsError?.isEmpty == false)
141    }
142
143    @Test func aRefusedWriteSurfacesAndLeavesTheStatusAlone() async throws {
144        let (client, stub) = try makeClient()
145        stub.enqueue(.init(status: 200, json: fullStatus))
146        let model = RepoSettingsViewModel(client: client, repoPath: "krz/gitbay")
147        await model.loadDeps()
148        #expect(model.deps?.enabled == true)
149
150        stub.enqueue(.init(status: 200, json: """
151            {"protocol_version":1,"error":"turning dependency checks on needs admin","exit_code":4}
152            """))
153        await model.setDepsEnabled(false)
154
155        #expect(model.actionError?.isEmpty == false)
156        // The write failed, so the last known status still says enabled.
157        #expect(model.deps?.enabled == true)
158    }
159
160    /// A successful load after a failed one must clear the error.
161    @Test func depsErrorClearsOnALaterSuccess() async throws {
162        let (client, stub) = try makeClient()
163        stub.enqueue(.init(status: 200, json: """
164            {"protocol_version":1,"error":"denied","exit_code":4}
165            """))
166        let model = RepoSettingsViewModel(client: client, repoPath: "krz/gitbay")
167        await model.loadDeps()
168        #expect(model.depsError != nil)
169
170        stub.enqueue(.init(status: 200, json: fullStatus))
171        await model.loadDeps()
172
173        #expect(model.depsError == nil)
174        #expect(model.deps?.enabled == true)
175    }
176
177    /// The converse of the above: a later failure must drop the stale
178    /// status, not leave it sitting alongside the new error.
179    @Test func depsClearsOnALaterFailureAfterASuccess() async throws {
180        let (client, stub) = try makeClient()
181        stub.enqueue(.init(status: 200, json: fullStatus))
182        let model = RepoSettingsViewModel(client: client, repoPath: "krz/gitbay")
183        await model.loadDeps()
184        #expect(model.deps?.enabled == true)
185
186        stub.enqueue(.init(status: 200, json: """
187            {"protocol_version":1,"error":"denied","exit_code":4}
188            """))
189        await model.loadDeps()
190
191        #expect(model.deps == nil)
192        #expect(model.depsError?.isEmpty == false)
193    }
194}