gitbayTests/ProfileEditTests.swift

v1.1.0
gitbay-ios/gitbayTests/ProfileEditTests.swift history · blame · raw

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}