import Foundation import Testing @testable import gitbay struct ProfileEditFlagTests { private func edit( description: String = "", website: String = "", about: String = "", aboutFormat: String = "md", links: [ProfileEdit.Link] = [] ) -> ProfileEdit { ProfileEdit(description: description, website: website, about: about, aboutFormat: aboutFormat, links: links) } @Test func everyFieldSendsItsFlag() { let e = edit(description: "hi", website: "https://x.test", links: [.init(label: "blog", url: "https://b.test")]) let flags = e.flags() #expect(flags.contains("--description")) #expect(flags.contains("hi")) #expect(flags.contains("--website")) #expect(flags.contains("https://x.test")) #expect(flags.contains("blog|https://b.test")) } /// An emptied field CLEARS it. Omitting the flag would leave the old /// value in place, which is a different outcome. @Test func anEmptiedFieldSendsAnEmptyValueNotNothing() { let flags = edit(description: "", website: "https://x.test").flags() let i = try! #require(flags.firstIndex(of: "--description")) #expect(flags[i + 1] == "") } /// `--link ""` is how the whole set is cleared. Omitting it would /// leave the existing links untouched. @Test func noLinksSendsAnEmptyLinkFlag() { let flags = edit(description: "hi").flags() let i = try! #require(flags.firstIndex(of: "--link")) #expect(flags[i + 1] == "") } /// The set replaces wholesale, so every kept link is sent every time. @Test func everyLinkIsSentSoTheSetReplaces() { let flags = edit(links: [ .init(label: "a", url: "https://a.test"), .init(label: "b", url: "https://b.test"), ]).flags() #expect(flags.filter { $0 == "--link" }.count == 2) #expect(flags.contains("a|https://a.test")) #expect(flags.contains("b|https://b.test")) } /// Without a separator the whole value is the URL, so a label-less /// link must not send a leading pipe. @Test func aLabellessLinkSendsJustTheUrl() { let flags = edit(links: [.init(label: "", url: "https://a.test")]).flags() #expect(flags.contains("https://a.test")) #expect(flags.contains("|https://a.test") == false) } /// Long-form text with newlines belongs in stdin, not argv. @Test func theAboutTextGoesToStdinNotArgv() { let e = edit(about: "line one\n\nline two", aboutFormat: "org") #expect(e.flags().contains { $0.contains("line one") } == false) #expect(e.flags().contains("--file")) #expect(e.flags().contains("-")) #expect(e.aboutStdin == "line one\n\nline two") } @Test func theAboutFormatAccompaniesTheText() { let flags = edit(about: "x", aboutFormat: "org").flags() let i = try! #require(flags.firstIndex(of: "--about-format")) #expect(flags[i + 1] == "org") } @Test func moreThanFiveLinksIsInvalid() { let six = (1...6).map { ProfileEdit.Link(label: "l\($0)", url: "https://\($0).test") } #expect(edit(links: six).validationError != nil) let five = (1...5).map { ProfileEdit.Link(label: "l\($0)", url: "https://\($0).test") } #expect(edit(links: five).validationError == nil) } @Test func aLinkWithoutAUrlIsInvalid() { #expect(edit(links: [.init(label: "blog", url: " ")]).validationError != nil) } /// The server splits a link on its first `|`, so a label carrying one /// would silently corrupt the URL rather than be rejected — caught /// here, not just in the view. @Test func aLabelContainingAPipeIsInvalid() { #expect(edit(links: [.init(label: "a|b", url: "https://x.test")]).validationError != nil) } } struct ProfileEditFromProfileTests { private let orgProfileJSON = """ {"name":"cmc","kind":"user",\ "description":"about me","website":"https://cleberg.net",\ "about":"* heading\\n\\n| a | b |","about_format":"org",\ "links":[{"label":null,"url":"https://a.test"},{"label":"blog","url":"https://b.test"}],\ "repos":[],"activity_total":0} """ /// The real profile on gitbay.org is org, with tables and links that /// render as flat text under markdown — the stored format must round /// trip, not default away from what the server holds. @Test func seedsFromAnOrgProfile() throws { let data = try #require(orgProfileJSON.data(using: .utf8)) let profile = try JSONDecoder().decode(ProfileViewModel.Profile.self, from: data) let edit = ProfileEdit(from: profile) #expect(edit.aboutFormat == "org") #expect(edit.description == "about me") #expect(edit.website == "https://cleberg.net") #expect(edit.about == "* heading\n\n| a | b |") #expect(edit.links.map(\.url) == ["https://a.test", "https://b.test"]) #expect(edit.links.map(\.label) == ["", "blog"]) } /// Absent `about_format`, seeding falls back to markdown. @Test func seedingFallsBackToMarkdownWhenFormatIsAbsent() throws { let json = """ {"name":"cmc","kind":"user",\ "description":"hi","repos":[],"activity_total":0} """ let data = try #require(json.data(using: .utf8)) let profile = try JSONDecoder().decode(ProfileViewModel.Profile.self, from: data) let edit = ProfileEdit(from: profile) #expect(edit.aboutFormat == "md") } } private func makeClient() throws -> (GitbayClient, StubProtocol.Box) { let box = StubProtocol.box() let client = GitbayClient( instance: try GitbayInstance(url: "https://gitbay.org"), token: "test-token", session: box.session() ) return (client, box) } private func argvOf(_ seen: StubProtocol.Seen) throws -> ([String], String?) { let body = try #require(try JSONSerialization.jsonObject(with: seen.body) as? [String: Any]) return (try #require(body["argv"] as? [String]), body["stdin"] as? String) } private let okJSON = #"{"protocol_version":1,"exit_code":0}"# private let profileJSON = """ {"protocol_version":1,"data":{"name":"cmc","kind":"user",\ "description":"server value","website":"https://cleberg.net","repos":[],\ "activity_total":0},"exit_code":0} """ @MainActor struct ProfileSaveTests { /// The write, then the reload `profile set` triggers — one POST, one /// GET. Both need a stub or the flow ends up looking like a refusal /// even when the write itself succeeded. @Test func savesTheFullArgvWithAboutOnlyInStdin() async throws { let (client, stub) = try makeClient() stub.enqueue(.init(status: 200, json: okJSON, match: "cmd")) stub.enqueue(.init(status: 200, json: profileJSON, match: "argv=show")) let model = ProfileViewModel(client: client, name: "cmc") let edit = ProfileEdit( description: "hi", website: "https://cleberg.net", about: "long form\n\nprose", aboutFormat: "org", links: [.init(label: "blog", url: "https://b.test")] ) let saved = await model.saveProfile(edit) #expect(saved) let write = try #require(stub.seen.first { $0.method == "POST" }) let (argv, stdin) = try argvOf(write) #expect(argv == [ "profile", "set", "--description", "hi", "--website", "https://cleberg.net", "--link", "blog|https://b.test", "--about-format", "org", "--file", "-", ]) #expect(stdin == "long form\n\nprose") #expect(argv.contains { $0.contains("long form") } == false) } /// The screen shows what the server stored, not what was typed — the /// reload's payload, not the edit, is what the caller sees next. @Test func aSuccessfulSaveReloadsFromTheServer() async throws { let (client, stub) = try makeClient() stub.enqueue(.init(status: 200, json: okJSON, match: "cmd")) stub.enqueue(.init(status: 200, json: profileJSON, match: "argv=show")) let model = ProfileViewModel(client: client, name: "cmc") let saved = await model.saveProfile(ProfileEdit(description: "typed value", website: "https://cleberg.net")) #expect(saved) let profile = try #require(model.state.value) #expect(profile.description == "server value") } @Test func aRefusedSaveReturnsFalseAndSurfaces() async throws { let (client, stub) = try makeClient() stub.enqueue(.init(status: 200, json: """ {"protocol_version":1,"error":"setting a profile needs write access","exit_code":4} """)) let model = ProfileViewModel(client: client, name: "cmc") let saved = await model.saveProfile(ProfileEdit(description: "hi")) #expect(saved == false) #expect(model.actionError?.isEmpty == false) } /// Six links fail `ProfileEdit.validationError`; the save must refuse /// before any request goes out, not after the server rejects it. @Test func anInvalidEditSendsNothingAtAll() async throws { let (client, stub) = try makeClient() let model = ProfileViewModel(client: client, name: "cmc") let six = (1...6).map { ProfileEdit.Link(label: "l\($0)", url: "https://\($0).test") } let saved = await model.saveProfile(ProfileEdit(links: six)) #expect(saved == false) #expect(stub.seen.isEmpty) } }