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 userProfileJSON = """ {"protocol_version":1,"data":{"name":"cmc","kind":"user",\ "description":"Org-Mode · Self-Hosting · Privacy","website":"https://cleberg.net",\ "about":"* Christian\\n\\nOrg-Mode.","about_format":"org","about_path":"profile/README.org",\ "links":[{"label":"Tor","url":"http://example.onion/"},{"url":"https://krz.sh"}],\ "orgs":[{"name":"krz","role":"admin"},{"name":"audit-labs","role":"admin"}],\ "repos":[{"path":"cmc/notes","visibility":"private","description":"notes",\ "topics":["org","writing"],"license":"MIT","default_branch":"main","updated":"2026-08-28"},\ {"path":"cmc/cv","visibility":"public"}],\ "activity":[{"date":"2026-08-01","count":3},{"date":"2026-08-02","count":12}],\ "activity_total":15},"exit_code":0} """ private let orgProfileJSON = """ {"protocol_version":1,"data":{"name":"krz","kind":"org","description":"warez",\ "members":[{"name":"cmc","role":"admin"}],"repos":[],"activity_total":0},"exit_code":0} """ @MainActor struct ProfileAggregateTests { @Test func aUserProfileCarriesOrgsReposAndActivity() async throws { let (client, stub) = try makeClient() stub.enqueue(.init(status: 200, json: userProfileJSON)) let model = ProfileViewModel(client: client, name: "cmc") await model.load() let profile = try #require(model.state.value) #expect(!profile.isOrg) #expect(profile.orgs?.map(\.name) == ["krz", "audit-labs"]) #expect(profile.repos.map(\.path) == ["cmc/notes", "cmc/cv"]) #expect(profile.repos[0].isPrivate) // Repo rows carry the listing metadata, so a profile lists // repositories the way explore does. #expect(profile.repos[0].topics == ["org", "writing"]) #expect(profile.repos[0].meta == "main · MIT · updated 2026-08-28") // A row the server reported without any of it still renders. #expect(profile.repos[1].topics == nil) #expect(profile.repos[1].meta.isEmpty) #expect(profile.about == "* Christian\n\nOrg-Mode.") // The file's own path picks the renderer, as it does on the web. #expect(profile.aboutPath == "profile/README.org") #expect(profile.links?.map(\.title) == ["Tor", "https://krz.sh"]) #expect(profile.activityTotal == 15) // One read builds the whole page. #expect(stub.seen.count == 1) let first = try #require(stub.seen.first) #expect(first.url.query() == "argv=profile&argv=show&argv=cmc") } @Test func anOrgProfileCarriesMembersInsteadOfOrgs() async throws { let (client, stub) = try makeClient() stub.enqueue(.init(status: 200, json: orgProfileJSON)) let model = ProfileViewModel(client: client, name: "krz") await model.load() let profile = try #require(model.state.value) #expect(profile.isOrg) #expect(profile.members?.map(\.name) == ["cmc"]) #expect(profile.orgs == nil) #expect(profile.repos.isEmpty) // A profile with no about text and no links renders neither. #expect(profile.about == nil) #expect(profile.links == nil) // No about file, no path. #expect(profile.aboutPath == nil) } @Test func anUnknownNameIsAnEmptyState() async throws { let (client, stub) = try makeClient() stub.enqueue(.init(status: 404, json: #"{"protocol_version":1,"error":"no user or organization \"nobody\"","exit_code":3}"#)) let model = ProfileViewModel(client: client, name: "nobody") await model.load() guard case .empty = model.state else { Issue.record("expected .empty, got \(model.state)") return } } } @MainActor struct CommitDetailTests { private let commitJSON = """ {"protocol_version":1,"data":{"path":"krz/gitbay",\ "sha":"59ae14400000000000000000000000000000abcd",\ "subject":"control: profile aggregate","message":"Body text.",\ "author_name":"Christian Cleberg","author_email":"hello@cleberg.net",\ "date":"2026-08-28T02:00:00Z",\ "signature":{"state":"verified","signer":"cmc"},\ "checks":[{"context":"ci","state":"success"}],\ "diff":"diff --git a/main.go b/main.go\\n--- a/main.go\\n+++ b/main.go\\n@@ -1,2 +1,3 @@\\n package main\\n+// added\\n"},\ "exit_code":0} """ @Test func loadsTheCommitAndParsesItsPatch() async throws { let (client, stub) = try makeClient() stub.enqueue(.init(status: 200, json: commitJSON)) let model = CommitDetailViewModel( client: client, repoPath: "krz/gitbay", sha: "59ae144") await model.load() let commit = try #require(model.state.value) #expect(commit.subject == "control: profile aggregate") #expect(commit.signature.state == .verified) #expect(commit.checks?.first?.state == "success") #expect(commit.shortSHA == "59ae144000") // The patch is parsed by the same parser mr diff uses. let diff = try #require(model.diff) #expect(diff.files.map(\.displayPath) == ["main.go"]) #expect(diff.additions == 1) #expect(stub.seen.first?.url.query() == "argv=repo&argv=commit&argv=krz/gitbay&argv=59ae144") } @Test func anUnknownShaIsAnEmptyState() async throws { let (client, stub) = try makeClient() stub.enqueue(.init(status: 404, json: #"{"protocol_version":1,"error":"no commit \"deadbeef\" in krz/gitbay","exit_code":3}"#)) let model = CommitDetailViewModel( client: client, repoPath: "krz/gitbay", sha: "deadbeef") await model.load() guard case .empty = model.state else { Issue.record("expected .empty, got \(model.state)") return } } } private func postArgv(_ stub: StubProtocol.Box) throws -> [([String], String?)] { try stub.seen.filter { $0.method == "POST" }.map { seen in let body = try #require(try JSONSerialization.jsonObject(with: seen.body) as? [String: Any]) return (try #require(body["argv"] as? [String]), body["stdin"] as? String) } } private let okCmdJSON = #"{"protocol_version":1,"exit_code":0}"# @MainActor struct AboutFileTests { /// The web's "profile-repo" form: create /.gitbay, commit a /// starter README, show the result. @Test func createsTheRepositoryAndAStarterFile() async throws { let (client, stub) = try makeClient() stub.enqueue(.init(status: 404, json: #"{"protocol_version":1,"error":"no repository cmc/.gitbay","exit_code":3}"#, match: "argv=repo&argv=show")) stub.enqueue(.init(status: 200, json: okCmdJSON, match: "cmd")) stub.enqueue(.init(status: 200, json: okCmdJSON, match: "cmd")) stub.enqueue(.init(status: 200, json: userProfileJSON, match: "argv=profile&argv=show")) let model = ProfileViewModel(client: client, name: "cmc") let created = await model.createAboutFile() #expect(created) #expect(model.actionError == nil) let writes = try postArgv(stub) #expect(writes.map(\.0) == [ ["repo", "create", "cmc/.gitbay"], ["repo", "commit-file", "cmc/.gitbay", "profile/README.md", "--ref", "main", "--message", "add profile about", "--file", "-"], ]) #expect(writes[1].1 == "# cmc\n\nThis is the about text on your profile.\n") #expect(model.state.value?.aboutPath == "profile/README.org") } /// A .gitbay repository can exist without an about file in it — it /// holds other per-owner configuration too. Creating it again would /// fail, so only the file is written. @Test func skipsCreateWhenTheRepositoryExists() async throws { let (client, stub) = try makeClient() stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"data":{"path":"krz/.gitbay"},"exit_code":0}"#, match: "argv=repo&argv=show")) stub.enqueue(.init(status: 200, json: okCmdJSON, match: "cmd")) stub.enqueue(.init(status: 200, json: orgProfileJSON, match: "argv=profile&argv=show")) let model = ProfileViewModel(client: client, name: "krz") let created = await model.createAboutFile() #expect(created) let writes = try postArgv(stub) #expect(writes.count == 1) #expect(Array(writes[0].0.prefix(4)) == ["repo", "commit-file", "krz/.gitbay", "profile/README.md"]) #expect(writes[0].1 == "# krz\n\nThis is the about text on your profile.\n") } /// A refused create must not go on to write a file into nothing. @Test func aRefusedCreateStopsAndSurfaces() async throws { let (client, stub) = try makeClient() stub.enqueue(.init(status: 404, json: #"{"protocol_version":1,"error":"no repository krz/.gitbay","exit_code":3}"#, match: "argv=repo&argv=show")) stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"error":"only admins of krz can create repositories there","exit_code":4}"#, match: "cmd")) let model = ProfileViewModel(client: client, name: "krz") let created = await model.createAboutFile() #expect(created == false) #expect(model.actionError?.isEmpty == false) #expect(try postArgv(stub).count == 1) } @Test func theAboutRepositoryIsTheOwnersDotGitbay() throws { let (client, _) = try makeClient() #expect(ProfileViewModel(client: client, name: "cmc").aboutRepo == "cmc/.gitbay") } /// The about file is only read from the repository's default branch, /// which is not always `main` for a repository that already exists. @Test func commitsToTheExistingRepositorysDefaultBranch() async throws { let (client, stub) = try makeClient() stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"data":{"path":"cmc/.gitbay","default_branch":"master"},"exit_code":0}"#, match: "argv=repo&argv=show")) stub.enqueue(.init(status: 200, json: okCmdJSON, match: "cmd")) stub.enqueue(.init(status: 200, json: userProfileJSON, match: "argv=profile&argv=show")) let model = ProfileViewModel(client: client, name: "cmc") let created = await model.createAboutFile() #expect(created) let writes = try postArgv(stub) #expect(writes.count == 1) #expect(writes[0].0 == [ "repo", "commit-file", "cmc/.gitbay", "profile/README.md", "--ref", "master", "--message", "add profile about", "--file", "-", ]) } /// Any failure but not-found on `repo show` stops the flow before any /// write — a permission refusal must not fall through to creating or /// committing into a repository the caller cannot even read. @Test func aNonNotFoundShowFailureStopsBeforeAnyWrite() async throws { let (client, stub) = try makeClient() stub.enqueue(.init(status: 403, json: #"{"protocol_version":1,"error":"denied","exit_code":4}"#, match: "argv=repo&argv=show")) let model = ProfileViewModel(client: client, name: "krz") let created = await model.createAboutFile() #expect(created == false) #expect(model.actionError?.isEmpty == false) #expect(try postArgv(stub).isEmpty) } /// A create that succeeds but a commit that then fails must not /// report success or reload — the file was never written. @Test func aFailedCommitAfterCreateStopsAndDoesNotReload() async throws { let (client, stub) = try makeClient() stub.enqueue(.init(status: 404, json: #"{"protocol_version":1,"error":"no repository krz/.gitbay","exit_code":3}"#, match: "argv=repo&argv=show")) stub.enqueue(.init(status: 200, json: okCmdJSON, match: "cmd")) stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"error":"disk full","exit_code":1}"#, match: "cmd")) let model = ProfileViewModel(client: client, name: "krz") let created = await model.createAboutFile() #expect(created == false) #expect(model.actionError?.isEmpty == false) let writes = try postArgv(stub) #expect(writes.count == 2) #expect(stub.seen.contains { $0.url.query()?.contains("argv=profile&argv=show") == true } == false) } }