gitbayTests/DepsTests.swift

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

195 lines · 7806 bytes

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