gitbayTests/DepsTests.swift
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}