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