gitbayTests/RepoViewModelTests.swift
316 lines · 12761 bytes
23 symbols in this file
makeClientRepoListViewModelTestsloadsOnePageInServerOrderfilterMatchesPathAndDescriptionanEmptyListIsAnEmptyStateNotAnErrorRepoDetailViewModelTestsloadsHeaderThenFindsAndFetchesReadmeaRepoWithoutAReadmeIsFineaMissingRepoIsAnEmptyStateloadsOpenCountsAndTheLatestBuildmissingCountsLeaveTheGridBareTreeViewModelTestsdirectoriesSortFirstThenAlphabeticalchildPathsNestCorrectlyrefIsForwardedWhenSetFileViewModelTeststextFileLoadsbinaryFileCarriesBase64NotTextLogViewModelTestscommitsDecodeWithSignatureStatesSyntaxHighlighterLanguageTestscommonExtensionsResolveunknownExtensionsFallBackToPlain
1import Foundation
2import Testing
3@testable import gitbay
4
5private func makeClient() throws -> (GitbayClient, StubProtocol.Box) {
6 let box = StubProtocol.box()
7 let client = GitbayClient(
8 instance: try GitbayInstance(url: "https://gitbay.org"),
9 token: "test-token",
10 session: box.session()
11 )
12 return (client, box)
13}
14
15private let repoListJSON = """
16 {"protocol_version":1,"data":{"items":[\
17 {"path":"krz/gitbay","visibility":"public","description":"a CLI-first git forge"},\
18 {"path":"krz/hutch","visibility":"public","description":"SourceHut iOS client"},\
19 {"path":"krz/secrets","visibility":"private","archived":true}\
20 ]},"exit_code":0}
21 """
22
23private let emptyDashboardJSON = """
24 {"protocol_version":1,"data":{"pinned":[],"open_mrs":[],"assigned_issues":[],"builds":[]},\
25 "exit_code":0}
26 """
27
28private let repoShowJSON = """
29 {"protocol_version":1,"data":{"path":"krz/gitbay","description":"a CLI-first git forge",\
30 "visibility":"public","default_branch":"main","topics":["git","forge"],\
31 "protected_branches":["main"]},"exit_code":0}
32 """
33
34private let rootTreeJSON = """
35 {"protocol_version":1,"data":{"path":"krz/gitbay","ref":"main","dir":"","entries":[\
36 {"name":"internal","type":"tree","mode":"040000","sha":"aaa1"},\
37 {"name":"README.md","type":"blob","mode":"100644","sha":"bbb2","size":1200},\
38 {"name":"main.go","type":"blob","mode":"100644","sha":"ccc3","size":300}\
39 ]},"exit_code":0}
40 """
41
42private let readmeJSON = """
43 {"protocol_version":1,"data":{"path":"krz/gitbay","ref":"main","file":"README.md",\
44 "size":18,"binary":false,"content":"# gitbay\\n\\na forge"},"exit_code":0}
45 """
46
47private let binaryFileJSON = """
48 {"protocol_version":1,"data":{"path":"krz/gitbay","ref":"main","file":"logo.png",\
49 "size":4,"binary":true,"base64":"iVBORw=="},"exit_code":0}
50 """
51
52private let logJSON = """
53 {"protocol_version":1,"data":[\
54 {"sha":"65ba14e0000000000000000000000000000000ab","subject":"httpd: GET /api/v1/read",\
55 "author_name":"krz","author_email":"krz@gitbay.org","date":"2026-08-20T10:00:00Z",\
56 "signature":{"state":"verified","signer":"krz","key_fingerprint":"SHA256:abc"}},\
57 {"sha":"7953e780000000000000000000000000000000cd","subject":"httpd: rate limit",\
58 "author_name":"krz","author_email":"krz@gitbay.org","date":"2026-08-19T10:00:00Z",\
59 "signature":{"state":"unsigned"}},\
60 {"sha":"40dab3e0000000000000000000000000000000ef","subject":"control: repo tree and cat",\
61 "author_name":"krz","author_email":"krz@gitbay.org","date":"2026-08-18T10:00:00Z",\
62 "signature":{"state":"one_day_a_new_state"}}\
63 ],"exit_code":0}
64 """
65
66@MainActor
67struct RepoListViewModelTests {
68
69 @Test func loadsOnePageInServerOrder() async throws {
70 let (client, stub) = try makeClient()
71 stub.enqueue(.init(status: 200, json: repoListJSON))
72 let model = RepoListViewModel(client: client)
73
74 await model.load()
75
76 #expect(model.visibleRepos.map(\.path) == ["krz/gitbay", "krz/hutch", "krz/secrets"])
77 #expect(model.visibleRepos[2].isPrivate)
78 #expect(model.visibleRepos[2].isArchived)
79 let seen = try #require(stub.seen.first)
80 #expect(seen.url.query() == "argv=repo&argv=list&argv=--limit&argv=100")
81 }
82
83 @Test func filterMatchesPathAndDescription() async throws {
84 let (client, stub) = try makeClient()
85 stub.enqueue(.init(status: 200, json: repoListJSON))
86 let model = RepoListViewModel(client: client)
87 await model.load()
88
89 model.searchText = "SourceHut"
90 #expect(model.visibleRepos.map(\.path) == ["krz/hutch"])
91
92 model.searchText = "gitb"
93 #expect(model.visibleRepos.map(\.path) == ["krz/gitbay"])
94 }
95
96 @Test func anEmptyListIsAnEmptyStateNotAnError() async throws {
97 let (client, stub) = try makeClient()
98 stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"data":{"items":[]},"exit_code":0}"#))
99 let model = RepoListViewModel(client: client)
100
101 await model.load()
102
103 guard case .empty = model.state else {
104 Issue.record("expected .empty, got \(model.state)")
105 return
106 }
107 }
108}
109
110@MainActor
111struct RepoDetailViewModelTests {
112
113 @Test func loadsHeaderThenFindsAndFetchesReadme() async throws {
114 let (client, stub) = try makeClient()
115 stub.enqueue(.init(status: 200, json: repoShowJSON, match: "argv=show"))
116 stub.enqueue(.init(status: 200, json: emptyDashboardJSON, match: "argv=dashboard"))
117 stub.enqueue(.init(status: 200, json: rootTreeJSON, match: "argv=tree"))
118 stub.enqueue(.init(status: 200, json: readmeJSON, match: "argv=cat"))
119 let model = RepoDetailViewModel(client: client, path: "krz/gitbay")
120
121 await model.load()
122
123 let detail = try #require(model.state.value)
124 #expect(detail.defaultBranch == "main")
125 #expect(detail.topics == ["git", "forge"])
126 #expect(model.readme == "# gitbay\n\na forge")
127 #expect(model.isPinned == false)
128 // The README was fetched by name from the tree listing.
129 let catRequest = try #require(stub.seen.first { ($0.url.query() ?? "").contains("argv=cat") })
130 #expect(catRequest.url.query()?.contains("argv=README.md") == true)
131 }
132
133 @Test func aRepoWithoutAReadmeIsFine() async throws {
134 let (client, stub) = try makeClient()
135 stub.enqueue(.init(status: 200, json: repoShowJSON, match: "argv=show"))
136 stub.enqueue(.init(status: 200, json: emptyDashboardJSON, match: "argv=dashboard"))
137 stub.enqueue(.init(status: 200, json: """
138 {"protocol_version":1,"data":{"path":"krz/gitbay","ref":"main","dir":"",\
139 "entries":[{"name":"main.go","type":"blob","mode":"100644","sha":"ccc3","size":300}]},\
140 "exit_code":0}
141 """, match: "argv=tree"))
142 let model = RepoDetailViewModel(client: client, path: "krz/gitbay")
143
144 await model.load()
145
146 #expect(model.state.value != nil)
147 #expect(model.readme == nil)
148 #expect(!stub.seen.contains { ($0.url.query() ?? "").contains("argv=cat") })
149 }
150
151 @Test func aMissingRepoIsAnEmptyState() async throws {
152 let (client, stub) = try makeClient()
153 stub.enqueue(.init(status: 404, json:
154 #"{"protocol_version":1,"error":"no such repository krz/nope","exit_code":3}"#))
155 let model = RepoDetailViewModel(client: client, path: "krz/nope")
156
157 await model.load()
158
159 guard case .empty(let message) = model.state else {
160 Issue.record("expected .empty, got \(model.state)")
161 return
162 }
163 #expect(message == "no such repository krz/nope")
164 }
165
166 @Test func loadsOpenCountsAndTheLatestBuild() async throws {
167 let (client, stub) = try makeClient()
168 stub.enqueue(.init(status: 200, json: repoShowJSON, match: "argv=show"))
169 stub.enqueue(.init(status: 200, json: emptyDashboardJSON, match: "argv=dashboard"))
170 stub.enqueue(.init(status: 200, json: """
171 {"protocol_version":1,"data":{"items":[{"number":3},{"number":2}]},"exit_code":0}
172 """, match: "argv=mr&argv=list"))
173 stub.enqueue(.init(status: 200, json: """
174 {"protocol_version":1,"data":{"items":[{"number":9}],"next":"aXNzdWU6OQ"},"exit_code":0}
175 """, match: "argv=issue&argv=list"))
176 stub.enqueue(.init(status: 200, json: """
177 {"protocol_version":1,"data":{"items":[{"number":1761,"job":"test","status":"failure",\
178 "sha":"d0c63c43655f5d6f03e2b77ca1f2c07dd53a43a3","ref":"main",\
179 "created_at":"2026-09-29T17:12:08Z"}],"next":"YnVpbGQ6MTc2MQ"},"exit_code":0}
180 """, match: "argv=build&argv=list"))
181 let model = RepoDetailViewModel(client: client, path: "krz/gitbay")
182
183 await model.load()
184
185 #expect(model.openMRs == "2")
186 // A further page means the count is a floor.
187 #expect(model.openIssues == "1+")
188 #expect(model.latestBuild?.status == "failure")
189 let buildQuery = try #require(stub.seen.first {
190 ($0.url.query() ?? "").contains("argv=build&argv=list")
191 }?.url.query())
192 #expect(buildQuery.hasSuffix("argv=--limit&argv=1"))
193 }
194
195 @Test func missingCountsLeaveTheGridBare() async throws {
196 let (client, stub) = try makeClient()
197 stub.enqueue(.init(status: 200, json: repoShowJSON, match: "argv=show"))
198 stub.enqueue(.init(status: 200, json: emptyDashboardJSON, match: "argv=dashboard"))
199 let model = RepoDetailViewModel(client: client, path: "krz/gitbay")
200
201 await model.load()
202
203 #expect(model.state.value != nil)
204 #expect(model.openMRs == nil)
205 #expect(model.openIssues == nil)
206 #expect(model.latestBuild == nil)
207 }
208}
209
210@MainActor
211struct TreeViewModelTests {
212
213 @Test func directoriesSortFirstThenAlphabetical() async throws {
214 let (client, stub) = try makeClient()
215 stub.enqueue(.init(status: 200, json: rootTreeJSON))
216 let model = TreeViewModel(client: client, repoPath: "krz/gitbay")
217
218 await model.load()
219
220 #expect(model.entries.map(\.name) == ["internal", "main.go", "README.md"])
221 }
222
223 @Test func childPathsNestCorrectly() async throws {
224 let (client, stub) = try makeClient()
225 stub.enqueue(.init(status: 200, json: rootTreeJSON))
226 let root = TreeViewModel(client: client, repoPath: "krz/gitbay")
227 await root.load()
228 let dir = try #require(root.entries.first { $0.isDirectory })
229
230 #expect(root.childDirectory(dir) == "internal")
231
232 let nested = TreeViewModel(client: client, repoPath: "krz/gitbay", directory: "internal")
233 #expect(nested.childDirectory(dir) == "internal/internal")
234 }
235
236 @Test func refIsForwardedWhenSet() async throws {
237 let (client, stub) = try makeClient()
238 stub.enqueue(.init(status: 200, json: rootTreeJSON))
239 let model = TreeViewModel(
240 client: client, repoPath: "krz/gitbay", directory: "internal", ref: "dev"
241 )
242
243 await model.load()
244
245 let seen = try #require(stub.seen.first)
246 #expect(seen.url.query() ==
247 "argv=repo&argv=tree&argv=krz/gitbay&argv=internal&argv=--ref&argv=dev")
248 }
249}
250
251@MainActor
252struct FileViewModelTests {
253
254 @Test func textFileLoads() async throws {
255 let (client, stub) = try makeClient()
256 stub.enqueue(.init(status: 200, json: readmeJSON))
257 let model = FileViewModel(client: client, repoPath: "krz/gitbay", filePath: "README.md")
258
259 await model.load()
260
261 let file = try #require(model.state.value)
262 #expect(file.content?.hasPrefix("# gitbay") == true)
263 #expect(!file.binary)
264 #expect(model.fileName == "README.md")
265 }
266
267 @Test func binaryFileCarriesBase64NotText() async throws {
268 let (client, stub) = try makeClient()
269 stub.enqueue(.init(status: 200, json: binaryFileJSON))
270 let model = FileViewModel(client: client, repoPath: "krz/gitbay", filePath: "logo.png")
271
272 await model.load()
273
274 let file = try #require(model.state.value)
275 #expect(file.binary)
276 #expect(file.content == nil)
277 #expect(file.data != nil)
278 }
279}
280
281@MainActor
282struct LogViewModelTests {
283
284 @Test func commitsDecodeWithSignatureStates() async throws {
285 let (client, stub) = try makeClient()
286 stub.enqueue(.init(status: 200, json: logJSON))
287 let model = LogViewModel(client: client, repoPath: "krz/gitbay")
288
289 await model.load()
290
291 let commits = try #require(model.state.value)
292 #expect(commits.count == 3)
293 #expect(commits[0].signature.state == .verified)
294 #expect(commits[0].signature.signer == "krz")
295 #expect(commits[1].signature.state == .unsigned)
296 // A state this build has never heard of must not sink the log.
297 #expect(commits[2].signature.state == .unrecognized("one_day_a_new_state"))
298 #expect(commits[0].shortSHA == "65ba14e000")
299 }
300}
301
302struct SyntaxHighlighterLanguageTests {
303
304 @Test func commonExtensionsResolve() {
305 #expect(SyntaxHighlighter.language(for: "GitbayClient.swift") == "swift")
306 #expect(SyntaxHighlighter.language(for: "read.go") == "go")
307 #expect(SyntaxHighlighter.language(for: "Makefile") == "makefile")
308 #expect(SyntaxHighlighter.language(for: "index.test.ts") == "typescript")
309 }
310
311 @Test func unknownExtensionsFallBackToPlain() {
312 #expect(SyntaxHighlighter.language(for: "notes.txt") == nil)
313 #expect(SyntaxHighlighter.language(for: "LICENSE") == nil)
314 #expect(SyntaxHighlighter.language(for: "weird.xyz123") == nil)
315 }
316}