gitbayTests/ProfileCommitTests.swift
294 lines · 12924 bytes
17 symbols in this file
makeClientProfileAggregateTestsaUserProfileCarriesOrgsReposAndActivityanOrgProfileCarriesMembersInsteadOfOrgsanUnknownNameIsAnEmptyStateCommitDetailTestsloadsTheCommitAndParsesItsPatchanUnknownShaIsAnEmptyStatepostArgvAboutFileTestscreatesTheRepositoryAndAStarterFileskipsCreateWhenTheRepositoryExistsaRefusedCreateStopsAndSurfacestheAboutRepositoryIsTheOwnersDotGitbaycommitsToTheExistingRepositorysDefaultBranchaNonNotFoundShowFailureStopsBeforeAnyWriteaFailedCommitAfterCreateStopsAndDoesNotReload
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 userProfileJSON = """
16 {"protocol_version":1,"data":{"name":"cmc","kind":"user",\
17 "description":"Org-Mode · Self-Hosting · Privacy","website":"https://cleberg.net",\
18 "about":"* Christian\\n\\nOrg-Mode.","about_format":"org","about_path":"profile/README.org",\
19 "links":[{"label":"Tor","url":"http://example.onion/"},{"url":"https://krz.sh"}],\
20 "orgs":[{"name":"krz","role":"admin"},{"name":"audit-labs","role":"admin"}],\
21 "repos":[{"path":"cmc/notes","visibility":"private","description":"notes",\
22 "topics":["org","writing"],"license":"MIT","default_branch":"main","updated":"2026-08-28"},\
23 {"path":"cmc/cv","visibility":"public"}],\
24 "activity":[{"date":"2026-08-01","count":3},{"date":"2026-08-02","count":12}],\
25 "activity_total":15},"exit_code":0}
26 """
27
28private let orgProfileJSON = """
29 {"protocol_version":1,"data":{"name":"krz","kind":"org","description":"warez",\
30 "members":[{"name":"cmc","role":"admin"}],"repos":[],"activity_total":0},"exit_code":0}
31 """
32
33@MainActor
34struct ProfileAggregateTests {
35
36 @Test func aUserProfileCarriesOrgsReposAndActivity() async throws {
37 let (client, stub) = try makeClient()
38 stub.enqueue(.init(status: 200, json: userProfileJSON))
39 let model = ProfileViewModel(client: client, name: "cmc")
40
41 await model.load()
42
43 let profile = try #require(model.state.value)
44 #expect(!profile.isOrg)
45 #expect(profile.orgs?.map(\.name) == ["krz", "audit-labs"])
46 #expect(profile.repos.map(\.path) == ["cmc/notes", "cmc/cv"])
47 #expect(profile.repos[0].isPrivate)
48 // Repo rows carry the listing metadata, so a profile lists
49 // repositories the way explore does.
50 #expect(profile.repos[0].topics == ["org", "writing"])
51 #expect(profile.repos[0].meta == "main · MIT · updated 2026-08-28")
52 // A row the server reported without any of it still renders.
53 #expect(profile.repos[1].topics == nil)
54 #expect(profile.repos[1].meta.isEmpty)
55 #expect(profile.about == "* Christian\n\nOrg-Mode.")
56 // The file's own path picks the renderer, as it does on the web.
57 #expect(profile.aboutPath == "profile/README.org")
58 #expect(profile.links?.map(\.title) == ["Tor", "https://krz.sh"])
59 #expect(profile.activityTotal == 15)
60 // One read builds the whole page.
61 #expect(stub.seen.count == 1)
62 let first = try #require(stub.seen.first)
63 #expect(first.url.query() == "argv=profile&argv=show&argv=cmc")
64 }
65
66 @Test func anOrgProfileCarriesMembersInsteadOfOrgs() async throws {
67 let (client, stub) = try makeClient()
68 stub.enqueue(.init(status: 200, json: orgProfileJSON))
69 let model = ProfileViewModel(client: client, name: "krz")
70
71 await model.load()
72
73 let profile = try #require(model.state.value)
74 #expect(profile.isOrg)
75 #expect(profile.members?.map(\.name) == ["cmc"])
76 #expect(profile.orgs == nil)
77 #expect(profile.repos.isEmpty)
78 // A profile with no about text and no links renders neither.
79 #expect(profile.about == nil)
80 #expect(profile.links == nil)
81 // No about file, no path.
82 #expect(profile.aboutPath == nil)
83 }
84
85 @Test func anUnknownNameIsAnEmptyState() async throws {
86 let (client, stub) = try makeClient()
87 stub.enqueue(.init(status: 404, json:
88 #"{"protocol_version":1,"error":"no user or organization \"nobody\"","exit_code":3}"#))
89 let model = ProfileViewModel(client: client, name: "nobody")
90
91 await model.load()
92
93 guard case .empty = model.state else {
94 Issue.record("expected .empty, got \(model.state)")
95 return
96 }
97 }
98}
99
100@MainActor
101struct CommitDetailTests {
102
103 private let commitJSON = """
104 {"protocol_version":1,"data":{"path":"krz/gitbay",\
105 "sha":"59ae14400000000000000000000000000000abcd",\
106 "subject":"control: profile aggregate","message":"Body text.",\
107 "author_name":"Christian Cleberg","author_email":"hello@cleberg.net",\
108 "date":"2026-08-28T02:00:00Z",\
109 "signature":{"state":"verified","signer":"cmc"},\
110 "checks":[{"context":"ci","state":"success"}],\
111 "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"},\
112 "exit_code":0}
113 """
114
115 @Test func loadsTheCommitAndParsesItsPatch() async throws {
116 let (client, stub) = try makeClient()
117 stub.enqueue(.init(status: 200, json: commitJSON))
118 let model = CommitDetailViewModel(
119 client: client, repoPath: "krz/gitbay", sha: "59ae144")
120
121 await model.load()
122
123 let commit = try #require(model.state.value)
124 #expect(commit.subject == "control: profile aggregate")
125 #expect(commit.signature.state == .verified)
126 #expect(commit.checks?.first?.state == "success")
127 #expect(commit.shortSHA == "59ae144000")
128 // The patch is parsed by the same parser mr diff uses.
129 let diff = try #require(model.diff)
130 #expect(diff.files.map(\.displayPath) == ["main.go"])
131 #expect(diff.additions == 1)
132 #expect(stub.seen.first?.url.query() ==
133 "argv=repo&argv=commit&argv=krz/gitbay&argv=59ae144")
134 }
135
136 @Test func anUnknownShaIsAnEmptyState() async throws {
137 let (client, stub) = try makeClient()
138 stub.enqueue(.init(status: 404, json:
139 #"{"protocol_version":1,"error":"no commit \"deadbeef\" in krz/gitbay","exit_code":3}"#))
140 let model = CommitDetailViewModel(
141 client: client, repoPath: "krz/gitbay", sha: "deadbeef")
142
143 await model.load()
144
145 guard case .empty = model.state else {
146 Issue.record("expected .empty, got \(model.state)")
147 return
148 }
149 }
150}
151
152private func postArgv(_ stub: StubProtocol.Box) throws -> [([String], String?)] {
153 try stub.seen.filter { $0.method == "POST" }.map { seen in
154 let body = try #require(try JSONSerialization.jsonObject(with: seen.body) as? [String: Any])
155 return (try #require(body["argv"] as? [String]), body["stdin"] as? String)
156 }
157}
158
159private let okCmdJSON = #"{"protocol_version":1,"exit_code":0}"#
160
161@MainActor
162struct AboutFileTests {
163
164 /// The web's "profile-repo" form: create <owner>/.gitbay, commit a
165 /// starter README, show the result.
166 @Test func createsTheRepositoryAndAStarterFile() async throws {
167 let (client, stub) = try makeClient()
168 stub.enqueue(.init(status: 404, json:
169 #"{"protocol_version":1,"error":"no repository cmc/.gitbay","exit_code":3}"#,
170 match: "argv=repo&argv=show"))
171 stub.enqueue(.init(status: 200, json: okCmdJSON, match: "cmd"))
172 stub.enqueue(.init(status: 200, json: okCmdJSON, match: "cmd"))
173 stub.enqueue(.init(status: 200, json: userProfileJSON, match: "argv=profile&argv=show"))
174 let model = ProfileViewModel(client: client, name: "cmc")
175
176 let created = await model.createAboutFile()
177
178 #expect(created)
179 #expect(model.actionError == nil)
180 let writes = try postArgv(stub)
181 #expect(writes.map(\.0) == [
182 ["repo", "create", "cmc/.gitbay"],
183 ["repo", "commit-file", "cmc/.gitbay", "profile/README.md", "--ref", "main",
184 "--message", "add profile about", "--file", "-"],
185 ])
186 #expect(writes[1].1 == "# cmc\n\nThis is the about text on your profile.\n")
187 #expect(model.state.value?.aboutPath == "profile/README.org")
188 }
189
190 /// A .gitbay repository can exist without an about file in it — it
191 /// holds other per-owner configuration too. Creating it again would
192 /// fail, so only the file is written.
193 @Test func skipsCreateWhenTheRepositoryExists() async throws {
194 let (client, stub) = try makeClient()
195 stub.enqueue(.init(status: 200, json:
196 #"{"protocol_version":1,"data":{"path":"krz/.gitbay"},"exit_code":0}"#,
197 match: "argv=repo&argv=show"))
198 stub.enqueue(.init(status: 200, json: okCmdJSON, match: "cmd"))
199 stub.enqueue(.init(status: 200, json: orgProfileJSON, match: "argv=profile&argv=show"))
200 let model = ProfileViewModel(client: client, name: "krz")
201
202 let created = await model.createAboutFile()
203
204 #expect(created)
205 let writes = try postArgv(stub)
206 #expect(writes.count == 1)
207 #expect(Array(writes[0].0.prefix(4)) == ["repo", "commit-file", "krz/.gitbay", "profile/README.md"])
208 #expect(writes[0].1 == "# krz\n\nThis is the about text on your profile.\n")
209 }
210
211 /// A refused create must not go on to write a file into nothing.
212 @Test func aRefusedCreateStopsAndSurfaces() async throws {
213 let (client, stub) = try makeClient()
214 stub.enqueue(.init(status: 404, json:
215 #"{"protocol_version":1,"error":"no repository krz/.gitbay","exit_code":3}"#,
216 match: "argv=repo&argv=show"))
217 stub.enqueue(.init(status: 200, json:
218 #"{"protocol_version":1,"error":"only admins of krz can create repositories there","exit_code":4}"#,
219 match: "cmd"))
220 let model = ProfileViewModel(client: client, name: "krz")
221
222 let created = await model.createAboutFile()
223
224 #expect(created == false)
225 #expect(model.actionError?.isEmpty == false)
226 #expect(try postArgv(stub).count == 1)
227 }
228
229 @Test func theAboutRepositoryIsTheOwnersDotGitbay() throws {
230 let (client, _) = try makeClient()
231 #expect(ProfileViewModel(client: client, name: "cmc").aboutRepo == "cmc/.gitbay")
232 }
233
234 /// The about file is only read from the repository's default branch,
235 /// which is not always `main` for a repository that already exists.
236 @Test func commitsToTheExistingRepositorysDefaultBranch() async throws {
237 let (client, stub) = try makeClient()
238 stub.enqueue(.init(status: 200, json:
239 #"{"protocol_version":1,"data":{"path":"cmc/.gitbay","default_branch":"master"},"exit_code":0}"#,
240 match: "argv=repo&argv=show"))
241 stub.enqueue(.init(status: 200, json: okCmdJSON, match: "cmd"))
242 stub.enqueue(.init(status: 200, json: userProfileJSON, match: "argv=profile&argv=show"))
243 let model = ProfileViewModel(client: client, name: "cmc")
244
245 let created = await model.createAboutFile()
246
247 #expect(created)
248 let writes = try postArgv(stub)
249 #expect(writes.count == 1)
250 #expect(writes[0].0 == [
251 "repo", "commit-file", "cmc/.gitbay", "profile/README.md", "--ref", "master",
252 "--message", "add profile about", "--file", "-",
253 ])
254 }
255
256 /// Any failure but not-found on `repo show` stops the flow before any
257 /// write — a permission refusal must not fall through to creating or
258 /// committing into a repository the caller cannot even read.
259 @Test func aNonNotFoundShowFailureStopsBeforeAnyWrite() async throws {
260 let (client, stub) = try makeClient()
261 stub.enqueue(.init(status: 403, json:
262 #"{"protocol_version":1,"error":"denied","exit_code":4}"#,
263 match: "argv=repo&argv=show"))
264 let model = ProfileViewModel(client: client, name: "krz")
265
266 let created = await model.createAboutFile()
267
268 #expect(created == false)
269 #expect(model.actionError?.isEmpty == false)
270 #expect(try postArgv(stub).isEmpty)
271 }
272
273 /// A create that succeeds but a commit that then fails must not
274 /// report success or reload — the file was never written.
275 @Test func aFailedCommitAfterCreateStopsAndDoesNotReload() async throws {
276 let (client, stub) = try makeClient()
277 stub.enqueue(.init(status: 404, json:
278 #"{"protocol_version":1,"error":"no repository krz/.gitbay","exit_code":3}"#,
279 match: "argv=repo&argv=show"))
280 stub.enqueue(.init(status: 200, json: okCmdJSON, match: "cmd"))
281 stub.enqueue(.init(status: 200, json:
282 #"{"protocol_version":1,"error":"disk full","exit_code":1}"#,
283 match: "cmd"))
284 let model = ProfileViewModel(client: client, name: "krz")
285
286 let created = await model.createAboutFile()
287
288 #expect(created == false)
289 #expect(model.actionError?.isEmpty == false)
290 let writes = try postArgv(stub)
291 #expect(writes.count == 2)
292 #expect(stub.seen.contains { $0.url.query()?.contains("argv=profile&argv=show") == true } == false)
293 }
294}