gitbayTests/RepoViewModelTests.swift
273 lines · 10606 bytes
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.last)
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
167@MainActor
168struct TreeViewModelTests {
169
170 @Test func directoriesSortFirstThenAlphabetical() async throws {
171 let (client, stub) = try makeClient()
172 stub.enqueue(.init(status: 200, json: rootTreeJSON))
173 let model = TreeViewModel(client: client, repoPath: "krz/gitbay")
174
175 await model.load()
176
177 #expect(model.entries.map(\.name) == ["internal", "main.go", "README.md"])
178 }
179
180 @Test func childPathsNestCorrectly() async throws {
181 let (client, stub) = try makeClient()
182 stub.enqueue(.init(status: 200, json: rootTreeJSON))
183 let root = TreeViewModel(client: client, repoPath: "krz/gitbay")
184 await root.load()
185 let dir = try #require(root.entries.first { $0.isDirectory })
186
187 #expect(root.childDirectory(dir) == "internal")
188
189 let nested = TreeViewModel(client: client, repoPath: "krz/gitbay", directory: "internal")
190 #expect(nested.childDirectory(dir) == "internal/internal")
191 }
192
193 @Test func refIsForwardedWhenSet() async throws {
194 let (client, stub) = try makeClient()
195 stub.enqueue(.init(status: 200, json: rootTreeJSON))
196 let model = TreeViewModel(
197 client: client, repoPath: "krz/gitbay", directory: "internal", ref: "dev"
198 )
199
200 await model.load()
201
202 let seen = try #require(stub.seen.first)
203 #expect(seen.url.query() ==
204 "argv=repo&argv=tree&argv=krz/gitbay&argv=internal&argv=--ref&argv=dev")
205 }
206}
207
208@MainActor
209struct FileViewModelTests {
210
211 @Test func textFileLoads() async throws {
212 let (client, stub) = try makeClient()
213 stub.enqueue(.init(status: 200, json: readmeJSON))
214 let model = FileViewModel(client: client, repoPath: "krz/gitbay", filePath: "README.md")
215
216 await model.load()
217
218 let file = try #require(model.state.value)
219 #expect(file.content?.hasPrefix("# gitbay") == true)
220 #expect(!file.binary)
221 #expect(model.fileName == "README.md")
222 }
223
224 @Test func binaryFileCarriesBase64NotText() async throws {
225 let (client, stub) = try makeClient()
226 stub.enqueue(.init(status: 200, json: binaryFileJSON))
227 let model = FileViewModel(client: client, repoPath: "krz/gitbay", filePath: "logo.png")
228
229 await model.load()
230
231 let file = try #require(model.state.value)
232 #expect(file.binary)
233 #expect(file.content == nil)
234 #expect(file.data != nil)
235 }
236}
237
238@MainActor
239struct LogViewModelTests {
240
241 @Test func commitsDecodeWithSignatureStates() async throws {
242 let (client, stub) = try makeClient()
243 stub.enqueue(.init(status: 200, json: logJSON))
244 let model = LogViewModel(client: client, repoPath: "krz/gitbay")
245
246 await model.load()
247
248 let commits = try #require(model.state.value)
249 #expect(commits.count == 3)
250 #expect(commits[0].signature.state == .verified)
251 #expect(commits[0].signature.signer == "krz")
252 #expect(commits[1].signature.state == .unsigned)
253 // A state this build has never heard of must not sink the log.
254 #expect(commits[2].signature.state == .unrecognized("one_day_a_new_state"))
255 #expect(commits[0].shortSHA == "65ba14e000")
256 }
257}
258
259struct SyntaxHighlighterLanguageTests {
260
261 @Test func commonExtensionsResolve() {
262 #expect(SyntaxHighlighter.language(for: "GitbayClient.swift") == "swift")
263 #expect(SyntaxHighlighter.language(for: "read.go") == "go")
264 #expect(SyntaxHighlighter.language(for: "Makefile") == "makefile")
265 #expect(SyntaxHighlighter.language(for: "index.test.ts") == "typescript")
266 }
267
268 @Test func unknownExtensionsFallBackToPlain() {
269 #expect(SyntaxHighlighter.language(for: "notes.txt") == nil)
270 #expect(SyntaxHighlighter.language(for: "LICENSE") == nil)
271 #expect(SyntaxHighlighter.language(for: "weird.xyz123") == nil)
272 }
273}