gitbayTests/RepoViewModelTests.swift

v1.7.0
gitbay-ios/gitbayTests/RepoViewModelTests.swift history · blame · raw

316 lines · 12761 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.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}