import Foundation import Testing @testable import gitbay private func makeClient() throws -> (GitbayClient, StubProtocol.Box) { let box = StubProtocol.box() let client = GitbayClient( instance: try GitbayInstance(url: "https://gitbay.org"), token: "test-token", session: box.session() ) return (client, box) } private func argvFrom(_ url: URL) -> [String] { URLComponents(url: url, resolvingAgainstBaseURL: false)? .queryItems?.filter { $0.name == "argv" }.compactMap(\.value) ?? [] } private func argvOf(_ seen: StubProtocol.Seen) throws -> [String] { let body = try #require(try JSONSerialization.jsonObject(with: seen.body) as? [String: Any]) return try #require(body["argv"] as? [String]) } private let fullStatus = """ {"protocol_version":1,"data":{"enabled":true,\ "last_check":"2026-09-06T09:47:07.905Z","issue_number":140,\ "behind":[{"ecosystem":"go","name":"github.com/yuin/goldmark",\ "current":"v1.8.5","latest":"v1.8.6"}]},"exit_code":0} """ struct DepsDecodingTests { private func decode(_ json: String) throws -> DepsStatus { let decoder = JSONDecoder() decoder.dateDecodingStrategy = .iso8601 return try decoder.decode(DepsStatus.self, from: Data(json.utf8)) } /// A repository whose checks were just switched on has never been /// checked, so it carries none of the three omitempty fields. That is /// the ordinary case, not an edge case. @Test func aNeverCheckedRepositoryHasNoTimestampErrorOrIssue() throws { let status = try decode(""" {"enabled":true,"behind":[]} """) #expect(status.enabled) #expect(status.lastCheck == nil) #expect(status.lastError == nil) #expect(status.issueNumber == nil) #expect(status.behind.isEmpty) } @Test func aCheckedRepositoryDecodesEverything() throws { let status = try decode(""" {"enabled":true,"last_check":"2026-09-06T09:47:07Z","issue_number":140,\ "behind":[{"ecosystem":"go","name":"modernc.org/sqlite",\ "current":"v1.57.0","latest":"v1.58.0"}]} """) #expect(status.lastCheck != nil) #expect(status.issueNumber == 140) #expect(status.behind.first?.name == "modernc.org/sqlite") #expect(status.behind.first?.latest == "v1.58.0") } @Test func aFailedCheckCarriesItsError() throws { let status = try decode(""" {"enabled":true,"last_error":"could not read go.mod","behind":[]} """) #expect(status.lastError == "could not read go.mod") } /// Two ecosystems can carry the same package name, so the id must /// include the ecosystem or SwiftUI reuses one row for the other. @Test func behindRowsAreUniqueAcrossEcosystems() throws { let status = try decode(""" {"enabled":true,"behind":[\ {"ecosystem":"go","name":"x","current":"1","latest":"2"},\ {"ecosystem":"npm","name":"x","current":"1","latest":"2"}]} """) let ids = status.behind.map(\.id) #expect(Set(ids).count == 2) } } @MainActor struct DepsActionTests { /// All three are THREE-word paths. The app sends two-word `repo` /// commands everywhere else, so this is easy to get wrong. @Test func statusUsesTheThreeWordPath() async throws { let (client, stub) = try makeClient() stub.enqueue(.init(status: 200, json: fullStatus)) let model = RepoSettingsViewModel(client: client, repoPath: "krz/gitbay") await model.loadDeps() #expect(argvFrom(try #require(stub.seen.last).url) == ["repo", "deps", "status", "krz/gitbay"]) #expect(model.deps?.enabled == true) #expect(model.deps?.behind.count == 1) } @Test func enableAndDisableAreDifferentCommands() async throws { let (client, stub) = try makeClient() let model = RepoSettingsViewModel(client: client, repoPath: "krz/gitbay") stub.enqueue(.init(status: 200, json: """ {"protocol_version":1,"exit_code":0} """)) stub.enqueue(.init(status: 200, json: fullStatus)) await model.setDepsEnabled(true) var write = try #require(stub.seen.first { $0.method == "POST" }) #expect(try argvOf(write) == ["repo", "deps", "enable", "krz/gitbay"]) // The write must reload deps specifically, not the whole settings // screen — otherwise this enqueued status stub goes unconsumed. #expect(model.deps?.enabled == true) let (client2, stub2) = try makeClient() let model2 = RepoSettingsViewModel(client: client2, repoPath: "krz/gitbay") stub2.enqueue(.init(status: 200, json: """ {"protocol_version":1,"exit_code":0} """)) stub2.enqueue(.init(status: 200, json: fullStatus)) await model2.setDepsEnabled(false) write = try #require(stub2.seen.first { $0.method == "POST" }) #expect(try argvOf(write) == ["repo", "deps", "disable", "krz/gitbay"]) #expect(model2.deps?.enabled == true) } /// "Could not ask" must not render as "checks are off". @Test func aFailedStatusReadIsDistinguishableFromChecksBeingOff() async throws { let (client, stub) = try makeClient() stub.enqueue(.init(status: 200, json: """ {"protocol_version":1,"error":"denied","exit_code":4} """)) let model = RepoSettingsViewModel(client: client, repoPath: "krz/gitbay") await model.loadDeps() #expect(model.deps == nil) #expect(model.depsError?.isEmpty == false) } @Test func aRefusedWriteSurfacesAndLeavesTheStatusAlone() async throws { let (client, stub) = try makeClient() stub.enqueue(.init(status: 200, json: fullStatus)) let model = RepoSettingsViewModel(client: client, repoPath: "krz/gitbay") await model.loadDeps() #expect(model.deps?.enabled == true) stub.enqueue(.init(status: 200, json: """ {"protocol_version":1,"error":"turning dependency checks on needs admin","exit_code":4} """)) await model.setDepsEnabled(false) #expect(model.actionError?.isEmpty == false) // The write failed, so the last known status still says enabled. #expect(model.deps?.enabled == true) } /// A successful load after a failed one must clear the error. @Test func depsErrorClearsOnALaterSuccess() async throws { let (client, stub) = try makeClient() stub.enqueue(.init(status: 200, json: """ {"protocol_version":1,"error":"denied","exit_code":4} """)) let model = RepoSettingsViewModel(client: client, repoPath: "krz/gitbay") await model.loadDeps() #expect(model.depsError != nil) stub.enqueue(.init(status: 200, json: fullStatus)) await model.loadDeps() #expect(model.depsError == nil) #expect(model.deps?.enabled == true) } /// The converse of the above: a later failure must drop the stale /// status, not leave it sitting alongside the new error. @Test func depsClearsOnALaterFailureAfterASuccess() async throws { let (client, stub) = try makeClient() stub.enqueue(.init(status: 200, json: fullStatus)) let model = RepoSettingsViewModel(client: client, repoPath: "krz/gitbay") await model.loadDeps() #expect(model.deps?.enabled == true) stub.enqueue(.init(status: 200, json: """ {"protocol_version":1,"error":"denied","exit_code":4} """)) await model.loadDeps() #expect(model.deps == nil) #expect(model.depsError?.isEmpty == false) } }