gitbayTests/ProfileEditTests.swift
250 lines · 10201 bytes
23 symbols in this file
ProfileEditFlagTestsediteveryFieldSendsItsFlaganEmptiedFieldSendsAnEmptyValueNotNothingnoLinksSendsAnEmptyLinkFlageveryLinkIsSentSoTheSetReplacesaLabellessLinkSendsJustTheUrlnoAboutFlagIsSentmoreThanFiveLinksIsInvalidaLinkWithoutAUrlIsInvalidaLabelContainingAPipeIsInvalidProfileEditFromProfileTestsseedsFromTheProfilemakeClientargvOfProfileSaveTestssavesScalarsAndLinksWithNoStdinanOrgProfileSavesThroughOrgProfileanOrgAdminMayEditItsProfileAndAMemberMayNotaUserProfileIsEditableOnlyByItsOwneraSuccessfulSaveReloadsFromTheServeraRefusedSaveReturnsFalseAndSurfacesanInvalidEditSendsNothingAtAll
1import Foundation
2import Testing
3@testable import gitbay
4
5struct ProfileEditFlagTests {
6
7 private func edit(
8 description: String = "", website: String = "",
9 links: [ProfileEdit.Link] = []
10 ) -> ProfileEdit {
11 ProfileEdit(description: description, website: website, links: links)
12 }
13
14 @Test func everyFieldSendsItsFlag() {
15 let e = edit(description: "hi", website: "https://x.test",
16 links: [.init(label: "blog", url: "https://b.test")])
17 let flags = e.flags()
18 #expect(flags.contains("--description"))
19 #expect(flags.contains("hi"))
20 #expect(flags.contains("--website"))
21 #expect(flags.contains("https://x.test"))
22 #expect(flags.contains("blog|https://b.test"))
23 }
24
25 /// An emptied field CLEARS it. Omitting the flag would leave the old
26 /// value in place, which is a different outcome.
27 @Test func anEmptiedFieldSendsAnEmptyValueNotNothing() {
28 let flags = edit(description: "", website: "https://x.test").flags()
29 let i = try! #require(flags.firstIndex(of: "--description"))
30 #expect(flags[i + 1] == "")
31 }
32
33 /// `--link ""` is how the whole set is cleared. Omitting it would
34 /// leave the existing links untouched.
35 @Test func noLinksSendsAnEmptyLinkFlag() {
36 let flags = edit(description: "hi").flags()
37 let i = try! #require(flags.firstIndex(of: "--link"))
38 #expect(flags[i + 1] == "")
39 }
40
41 /// The set replaces wholesale, so every kept link is sent every time.
42 @Test func everyLinkIsSentSoTheSetReplaces() {
43 let flags = edit(links: [
44 .init(label: "a", url: "https://a.test"),
45 .init(label: "b", url: "https://b.test"),
46 ]).flags()
47 #expect(flags.filter { $0 == "--link" }.count == 2)
48 #expect(flags.contains("a|https://a.test"))
49 #expect(flags.contains("b|https://b.test"))
50 }
51
52 /// Without a separator the whole value is the URL, so a label-less
53 /// link must not send a leading pipe.
54 @Test func aLabellessLinkSendsJustTheUrl() {
55 let flags = edit(links: [.init(label: "", url: "https://a.test")]).flags()
56 #expect(flags.contains("https://a.test"))
57 #expect(flags.contains("|https://a.test") == false)
58 }
59
60 /// Since gitbay v1.31.0 the about text is a file in <owner>/.gitbay,
61 /// and `profile set` refuses any of these as an unknown flag (exit 2).
62 @Test func noAboutFlagIsSent() {
63 let flags = edit(description: "hi", website: "https://x.test",
64 links: [.init(label: "a", url: "https://a.test")]).flags()
65 #expect(!flags.contains("--about"))
66 #expect(!flags.contains("--about-format"))
67 #expect(!flags.contains("--file"))
68 }
69
70 @Test func moreThanFiveLinksIsInvalid() {
71 let six = (1...6).map { ProfileEdit.Link(label: "l\($0)", url: "https://\($0).test") }
72 #expect(edit(links: six).validationError != nil)
73 let five = (1...5).map { ProfileEdit.Link(label: "l\($0)", url: "https://\($0).test") }
74 #expect(edit(links: five).validationError == nil)
75 }
76
77 @Test func aLinkWithoutAUrlIsInvalid() {
78 #expect(edit(links: [.init(label: "blog", url: " ")]).validationError != nil)
79 }
80
81 /// The server splits a link on its first `|`, so a label carrying one
82 /// would silently corrupt the URL rather than be rejected — caught
83 /// here, not just in the view.
84 @Test func aLabelContainingAPipeIsInvalid() {
85 #expect(edit(links: [.init(label: "a|b", url: "https://x.test")]).validationError != nil)
86 }
87}
88
89struct ProfileEditFromProfileTests {
90
91 private let orgProfileJSON = """
92 {"name":"cmc","kind":"user",\
93 "description":"about me","website":"https://cleberg.net",\
94 "about":"* heading\\n\\n| a | b |","about_format":"org",\
95 "links":[{"label":null,"url":"https://a.test"},{"label":"blog","url":"https://b.test"}],\
96 "repos":[],"activity_total":0}
97 """
98
99 @Test func seedsFromTheProfile() throws {
100 let data = try #require(orgProfileJSON.data(using: .utf8))
101 let profile = try JSONDecoder().decode(ProfileViewModel.Profile.self, from: data)
102
103 let edit = ProfileEdit(from: profile)
104
105 #expect(edit.description == "about me")
106 #expect(edit.website == "https://cleberg.net")
107 #expect(edit.links.map(\.url) == ["https://a.test", "https://b.test"])
108 #expect(edit.links.map(\.label) == ["", "blog"])
109 }
110}
111
112private func makeClient() throws -> (GitbayClient, StubProtocol.Box) {
113 let box = StubProtocol.box()
114 let client = GitbayClient(
115 instance: try GitbayInstance(url: "https://gitbay.org"),
116 token: "test-token",
117 session: box.session()
118 )
119 return (client, box)
120}
121
122private func argvOf(_ seen: StubProtocol.Seen) throws -> ([String], String?) {
123 let body = try #require(try JSONSerialization.jsonObject(with: seen.body) as? [String: Any])
124 return (try #require(body["argv"] as? [String]), body["stdin"] as? String)
125}
126
127private let okJSON = #"{"protocol_version":1,"exit_code":0}"#
128
129private let profileJSON = """
130 {"protocol_version":1,"data":{"name":"cmc","kind":"user",\
131 "description":"server value","website":"https://cleberg.net","repos":[],\
132 "activity_total":0},"exit_code":0}
133 """
134
135private let orgShowJSON = """
136 {"protocol_version":1,"data":{"name":"krz","kind":"org",\
137 "description":"apps","members":[{"name":"cmc","role":"admin"},{"name":"guest","role":"member"}],\
138 "repos":[],"activity_total":0},"exit_code":0}
139 """
140
141@MainActor
142struct ProfileSaveTests {
143
144 /// The write, then the reload `profile set` triggers — one POST, one
145 /// GET. Both need a stub or the flow ends up looking like a refusal
146 /// even when the write itself succeeded.
147 @Test func savesScalarsAndLinksWithNoStdin() async throws {
148 let (client, stub) = try makeClient()
149 stub.enqueue(.init(status: 200, json: okJSON, match: "cmd"))
150 stub.enqueue(.init(status: 200, json: profileJSON, match: "argv=show"))
151 let model = ProfileViewModel(client: client, name: "cmc")
152 let edit = ProfileEdit(
153 description: "hi", website: "https://cleberg.net",
154 links: [.init(label: "blog", url: "https://b.test")]
155 )
156
157 let saved = await model.saveProfile(edit)
158
159 #expect(saved)
160 let write = try #require(stub.seen.first { $0.method == "POST" })
161 let (argv, stdin) = try argvOf(write)
162 #expect(argv == [
163 "profile", "set",
164 "--description", "hi", "--website", "https://cleberg.net",
165 "--link", "blog|https://b.test",
166 ])
167 #expect(stdin == nil)
168 }
169
170 /// An org's profile is written by `org profile <org>`, with the same
171 /// flags; the loaded profile's kind decides which command runs.
172 @Test func anOrgProfileSavesThroughOrgProfile() async throws {
173 let (client, stub) = try makeClient()
174 stub.enqueue(.init(status: 200, json: orgShowJSON, match: "argv=show"))
175 let model = ProfileViewModel(client: client, name: "krz")
176 await model.load()
177 stub.enqueue(.init(status: 200, json: okJSON, match: "cmd"))
178 stub.enqueue(.init(status: 200, json: orgShowJSON, match: "argv=show"))
179
180 let saved = await model.saveProfile(ProfileEdit(description: "apps", website: "https://krz.sh"))
181
182 #expect(saved)
183 let write = try #require(stub.seen.first { $0.method == "POST" })
184 let (argv, _) = try argvOf(write)
185 #expect(Array(argv.prefix(3)) == ["org", "profile", "krz"])
186 #expect(argv.contains("--description"))
187 }
188
189 @Test func anOrgAdminMayEditItsProfileAndAMemberMayNot() throws {
190 let profile = try JSONDecoder().decode(
191 Envelope.self, from: Data(orgShowJSON.utf8)).data
192
193 #expect(profile.canEdit(by: "cmc"))
194 #expect(!profile.canEdit(by: "guest"))
195 #expect(!profile.canEdit(by: "nobody"))
196 }
197
198 @Test func aUserProfileIsEditableOnlyByItsOwner() throws {
199 let profile = try JSONDecoder().decode(
200 Envelope.self, from: Data(profileJSON.utf8)).data
201
202 #expect(profile.canEdit(by: "cmc"))
203 #expect(!profile.canEdit(by: "guest"))
204 }
205
206 nonisolated private struct Envelope: Decodable {
207 let data: ProfileViewModel.Profile
208 }
209
210 /// The screen shows what the server stored, not what was typed — the
211 /// reload's payload, not the edit, is what the caller sees next.
212 @Test func aSuccessfulSaveReloadsFromTheServer() async throws {
213 let (client, stub) = try makeClient()
214 stub.enqueue(.init(status: 200, json: okJSON, match: "cmd"))
215 stub.enqueue(.init(status: 200, json: profileJSON, match: "argv=show"))
216 let model = ProfileViewModel(client: client, name: "cmc")
217
218 let saved = await model.saveProfile(ProfileEdit(description: "typed value", website: "https://cleberg.net"))
219
220 #expect(saved)
221 let profile = try #require(model.state.value)
222 #expect(profile.description == "server value")
223 }
224
225 @Test func aRefusedSaveReturnsFalseAndSurfaces() async throws {
226 let (client, stub) = try makeClient()
227 stub.enqueue(.init(status: 200, json: """
228 {"protocol_version":1,"error":"setting a profile needs write access","exit_code":4}
229 """))
230 let model = ProfileViewModel(client: client, name: "cmc")
231
232 let saved = await model.saveProfile(ProfileEdit(description: "hi"))
233
234 #expect(saved == false)
235 #expect(model.actionError?.isEmpty == false)
236 }
237
238 /// Six links fail `ProfileEdit.validationError`; the save must refuse
239 /// before any request goes out, not after the server rejects it.
240 @Test func anInvalidEditSendsNothingAtAll() async throws {
241 let (client, stub) = try makeClient()
242 let model = ProfileViewModel(client: client, name: "cmc")
243 let six = (1...6).map { ProfileEdit.Link(label: "l\($0)", url: "https://\($0).test") }
244
245 let saved = await model.saveProfile(ProfileEdit(links: six))
246
247 #expect(saved == false)
248 #expect(stub.seen.isEmpty)
249 }
250}