import Foundation import Testing @testable import gitbay private func makeClient() throws -> (GitbayClient, StubProtocol.Box) { let box = StubProtocol.box() let client = GitbayClient( instance: try GitbayInstance(url: "https://gitbay.org"), token: "test-token", session: box.session() ) return (client, box) } private let repoListJSON = """ {"protocol_version":1,"data":{"items":[\ {"path":"krz/gitbay","visibility":"public","description":"a CLI-first git forge"},\ {"path":"krz/hutch","visibility":"public","description":"SourceHut iOS client"},\ {"path":"krz/secrets","visibility":"private","archived":true}\ ]},"exit_code":0} """ private let emptyDashboardJSON = """ {"protocol_version":1,"data":{"pinned":[],"open_mrs":[],"assigned_issues":[],"builds":[]},\ "exit_code":0} """ private let repoShowJSON = """ {"protocol_version":1,"data":{"path":"krz/gitbay","description":"a CLI-first git forge",\ "visibility":"public","default_branch":"main","topics":["git","forge"],\ "protected_branches":["main"]},"exit_code":0} """ private let rootTreeJSON = """ {"protocol_version":1,"data":{"path":"krz/gitbay","ref":"main","dir":"","entries":[\ {"name":"internal","type":"tree","mode":"040000","sha":"aaa1"},\ {"name":"README.md","type":"blob","mode":"100644","sha":"bbb2","size":1200},\ {"name":"main.go","type":"blob","mode":"100644","sha":"ccc3","size":300}\ ]},"exit_code":0} """ private let readmeJSON = """ {"protocol_version":1,"data":{"path":"krz/gitbay","ref":"main","file":"README.md",\ "size":18,"binary":false,"content":"# gitbay\\n\\na forge"},"exit_code":0} """ private let binaryFileJSON = """ {"protocol_version":1,"data":{"path":"krz/gitbay","ref":"main","file":"logo.png",\ "size":4,"binary":true,"base64":"iVBORw=="},"exit_code":0} """ private let logJSON = """ {"protocol_version":1,"data":[\ {"sha":"65ba14e0000000000000000000000000000000ab","subject":"httpd: GET /api/v1/read",\ "author_name":"krz","author_email":"krz@gitbay.org","date":"2026-08-20T10:00:00Z",\ "signature":{"state":"verified","signer":"krz","key_fingerprint":"SHA256:abc"}},\ {"sha":"7953e780000000000000000000000000000000cd","subject":"httpd: rate limit",\ "author_name":"krz","author_email":"krz@gitbay.org","date":"2026-08-19T10:00:00Z",\ "signature":{"state":"unsigned"}},\ {"sha":"40dab3e0000000000000000000000000000000ef","subject":"control: repo tree and cat",\ "author_name":"krz","author_email":"krz@gitbay.org","date":"2026-08-18T10:00:00Z",\ "signature":{"state":"one_day_a_new_state"}}\ ],"exit_code":0} """ @MainActor struct RepoListViewModelTests { @Test func loadsOnePageInServerOrder() async throws { let (client, stub) = try makeClient() stub.enqueue(.init(status: 200, json: repoListJSON)) let model = RepoListViewModel(client: client) await model.load() #expect(model.visibleRepos.map(\.path) == ["krz/gitbay", "krz/hutch", "krz/secrets"]) #expect(model.visibleRepos[2].isPrivate) #expect(model.visibleRepos[2].isArchived) let seen = try #require(stub.seen.first) #expect(seen.url.query() == "argv=repo&argv=list&argv=--limit&argv=100") } @Test func filterMatchesPathAndDescription() async throws { let (client, stub) = try makeClient() stub.enqueue(.init(status: 200, json: repoListJSON)) let model = RepoListViewModel(client: client) await model.load() model.searchText = "SourceHut" #expect(model.visibleRepos.map(\.path) == ["krz/hutch"]) model.searchText = "gitb" #expect(model.visibleRepos.map(\.path) == ["krz/gitbay"]) } @Test func anEmptyListIsAnEmptyStateNotAnError() async throws { let (client, stub) = try makeClient() stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"data":{"items":[]},"exit_code":0}"#)) let model = RepoListViewModel(client: client) await model.load() guard case .empty = model.state else { Issue.record("expected .empty, got \(model.state)") return } } } @MainActor struct RepoDetailViewModelTests { @Test func loadsHeaderThenFindsAndFetchesReadme() async throws { let (client, stub) = try makeClient() stub.enqueue(.init(status: 200, json: repoShowJSON, match: "argv=show")) stub.enqueue(.init(status: 200, json: emptyDashboardJSON, match: "argv=dashboard")) stub.enqueue(.init(status: 200, json: rootTreeJSON, match: "argv=tree")) stub.enqueue(.init(status: 200, json: readmeJSON, match: "argv=cat")) let model = RepoDetailViewModel(client: client, path: "krz/gitbay") await model.load() let detail = try #require(model.state.value) #expect(detail.defaultBranch == "main") #expect(detail.topics == ["git", "forge"]) #expect(model.readme == "# gitbay\n\na forge") #expect(model.isPinned == false) // The README was fetched by name from the tree listing. let catRequest = try #require(stub.seen.last) #expect(catRequest.url.query()?.contains("argv=README.md") == true) } @Test func aRepoWithoutAReadmeIsFine() async throws { let (client, stub) = try makeClient() stub.enqueue(.init(status: 200, json: repoShowJSON, match: "argv=show")) stub.enqueue(.init(status: 200, json: emptyDashboardJSON, match: "argv=dashboard")) stub.enqueue(.init(status: 200, json: """ {"protocol_version":1,"data":{"path":"krz/gitbay","ref":"main","dir":"",\ "entries":[{"name":"main.go","type":"blob","mode":"100644","sha":"ccc3","size":300}]},\ "exit_code":0} """, match: "argv=tree")) let model = RepoDetailViewModel(client: client, path: "krz/gitbay") await model.load() #expect(model.state.value != nil) #expect(model.readme == nil) #expect(!stub.seen.contains { ($0.url.query() ?? "").contains("argv=cat") }) } @Test func aMissingRepoIsAnEmptyState() async throws { let (client, stub) = try makeClient() stub.enqueue(.init(status: 404, json: #"{"protocol_version":1,"error":"no such repository krz/nope","exit_code":3}"#)) let model = RepoDetailViewModel(client: client, path: "krz/nope") await model.load() guard case .empty(let message) = model.state else { Issue.record("expected .empty, got \(model.state)") return } #expect(message == "no such repository krz/nope") } } @MainActor struct TreeViewModelTests { @Test func directoriesSortFirstThenAlphabetical() async throws { let (client, stub) = try makeClient() stub.enqueue(.init(status: 200, json: rootTreeJSON)) let model = TreeViewModel(client: client, repoPath: "krz/gitbay") await model.load() #expect(model.entries.map(\.name) == ["internal", "main.go", "README.md"]) } @Test func childPathsNestCorrectly() async throws { let (client, stub) = try makeClient() stub.enqueue(.init(status: 200, json: rootTreeJSON)) let root = TreeViewModel(client: client, repoPath: "krz/gitbay") await root.load() let dir = try #require(root.entries.first { $0.isDirectory }) #expect(root.childDirectory(dir) == "internal") let nested = TreeViewModel(client: client, repoPath: "krz/gitbay", directory: "internal") #expect(nested.childDirectory(dir) == "internal/internal") } @Test func refIsForwardedWhenSet() async throws { let (client, stub) = try makeClient() stub.enqueue(.init(status: 200, json: rootTreeJSON)) let model = TreeViewModel( client: client, repoPath: "krz/gitbay", directory: "internal", ref: "dev" ) await model.load() let seen = try #require(stub.seen.first) #expect(seen.url.query() == "argv=repo&argv=tree&argv=krz/gitbay&argv=internal&argv=--ref&argv=dev") } } @MainActor struct FileViewModelTests { @Test func textFileLoads() async throws { let (client, stub) = try makeClient() stub.enqueue(.init(status: 200, json: readmeJSON)) let model = FileViewModel(client: client, repoPath: "krz/gitbay", filePath: "README.md") await model.load() let file = try #require(model.state.value) #expect(file.content?.hasPrefix("# gitbay") == true) #expect(!file.binary) #expect(model.fileName == "README.md") } @Test func binaryFileCarriesBase64NotText() async throws { let (client, stub) = try makeClient() stub.enqueue(.init(status: 200, json: binaryFileJSON)) let model = FileViewModel(client: client, repoPath: "krz/gitbay", filePath: "logo.png") await model.load() let file = try #require(model.state.value) #expect(file.binary) #expect(file.content == nil) #expect(file.data != nil) } } @MainActor struct LogViewModelTests { @Test func commitsDecodeWithSignatureStates() async throws { let (client, stub) = try makeClient() stub.enqueue(.init(status: 200, json: logJSON)) let model = LogViewModel(client: client, repoPath: "krz/gitbay") await model.load() let commits = try #require(model.state.value) #expect(commits.count == 3) #expect(commits[0].signature.state == .verified) #expect(commits[0].signature.signer == "krz") #expect(commits[1].signature.state == .unsigned) // A state this build has never heard of must not sink the log. #expect(commits[2].signature.state == .unrecognized("one_day_a_new_state")) #expect(commits[0].shortSHA == "65ba14e000") } } struct SyntaxHighlighterLanguageTests { @Test func commonExtensionsResolve() { #expect(SyntaxHighlighter.language(for: "GitbayClient.swift") == "swift") #expect(SyntaxHighlighter.language(for: "read.go") == "go") #expect(SyntaxHighlighter.language(for: "Makefile") == "makefile") #expect(SyntaxHighlighter.language(for: "index.test.ts") == "typescript") } @Test func unknownExtensionsFallBackToPlain() { #expect(SyntaxHighlighter.language(for: "notes.txt") == nil) #expect(SyntaxHighlighter.language(for: "LICENSE") == nil) #expect(SyntaxHighlighter.language(for: "weird.xyz123") == nil) } }