| @@ -0,0 +1,173 @@ |
| |
1 | import Foundation |
| |
2 | import Testing |
| |
3 | |
| |
4 | @testable import gitbay |
| |
5 | |
| |
6 | private 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 | |
| |
16 | private func argvFrom(_ url: URL) -> [String] { |
| |
17 | URLComponents(url: url, resolvingAgainstBaseURL: false)? |
| |
18 | .queryItems?.filter { $0.name == "argv" }.compactMap(\.value) ?? [] |
| |
19 | } |
| |
20 | |
| |
21 | private 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 | |
| |
26 | private 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 | |
| |
33 | struct 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 |
| |
88 | struct 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 | |
| |
116 | let (client2, stub2) = try makeClient() |
| |
117 | let model2 = RepoSettingsViewModel(client: client2, repoPath: "krz/gitbay") |
| |
118 | stub2.enqueue(.init(status: 200, json: """ |
| |
119 | {"protocol_version":1,"exit_code":0} |
| |
120 | """)) |
| |
121 | stub2.enqueue(.init(status: 200, json: fullStatus)) |
| |
122 | await model2.setDepsEnabled(false) |
| |
123 | write = try #require(stub2.seen.first { $0.method == "POST" }) |
| |
124 | #expect(try argvOf(write) == ["repo", "deps", "disable", "krz/gitbay"]) |
| |
125 | } |
| |
126 | |
| |
127 | /// "Could not ask" must not render as "checks are off". |
| |
128 | @Test func aFailedStatusReadIsDistinguishableFromChecksBeingOff() async throws { |
| |
129 | let (client, stub) = try makeClient() |
| |
130 | stub.enqueue(.init(status: 200, json: """ |
| |
131 | {"protocol_version":1,"error":"denied","exit_code":4} |
| |
132 | """)) |
| |
133 | let model = RepoSettingsViewModel(client: client, repoPath: "krz/gitbay") |
| |
134 | await model.loadDeps() |
| |
135 | |
| |
136 | #expect(model.deps == nil) |
| |
137 | #expect(model.depsError?.isEmpty == false) |
| |
138 | } |
| |
139 | |
| |
140 | @Test func aRefusedWriteSurfacesAndLeavesTheStatusAlone() async throws { |
| |
141 | let (client, stub) = try makeClient() |
| |
142 | stub.enqueue(.init(status: 200, json: fullStatus)) |
| |
143 | let model = RepoSettingsViewModel(client: client, repoPath: "krz/gitbay") |
| |
144 | await model.loadDeps() |
| |
145 | #expect(model.deps?.enabled == true) |
| |
146 | |
| |
147 | stub.enqueue(.init(status: 200, json: """ |
| |
148 | {"protocol_version":1,"error":"turning dependency checks on needs admin","exit_code":4} |
| |
149 | """)) |
| |
150 | await model.setDepsEnabled(false) |
| |
151 | |
| |
152 | #expect(model.actionError?.isEmpty == false) |
| |
153 | // The write failed, so the last known status still says enabled. |
| |
154 | #expect(model.deps?.enabled == true) |
| |
155 | } |
| |
156 | |
| |
157 | /// A successful load after a failed one must clear the error. |
| |
158 | @Test func depsErrorClearsOnALaterSuccess() async throws { |
| |
159 | let (client, stub) = try makeClient() |
| |
160 | stub.enqueue(.init(status: 200, json: """ |
| |
161 | {"protocol_version":1,"error":"denied","exit_code":4} |
| |
162 | """)) |
| |
163 | let model = RepoSettingsViewModel(client: client, repoPath: "krz/gitbay") |
| |
164 | await model.loadDeps() |
| |
165 | #expect(model.depsError != nil) |
| |
166 | |
| |
167 | stub.enqueue(.init(status: 200, json: fullStatus)) |
| |
168 | await model.loadDeps() |
| |
169 | |
| |
170 | #expect(model.depsError == nil) |
| |
171 | #expect(model.deps?.enabled == true) |
| |
172 | } |
| |
173 | } |