gitbayTests/ProfileEditTests.swift

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

250 lines · 10201 bytes

  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}