gitbayTests/ProfileCommitTests.swift

v1.14.0
gitbay-ios/gitbayTests/ProfileCommitTests.swift history · blame · raw

294 lines · 12924 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 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}