gitbayTests/DepsTests.swift
194 lines · 7767 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 = 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}