gitbayTests/ComposeViewModelTests.swift

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

213 lines · 9214 bytes

  1import Foundation
  2import Testing
  3@testable import gitbay
  4
  5private func makeClient() throws -> (GitbayClient, StubProtocol.Box) {
  6    let box = StubProtocol.box()
  7    let client = GitbayClient(
  8        instance: try GitbayInstance(url: "https://gitbay.org"),
  9        token: "test-token",
 10        session: box.session()
 11    )
 12    return (client, box)
 13}
 14
 15private let createdJSON = #"{"protocol_version":1,"data":{"number":7},"exit_code":0}"#
 16private let okJSON = #"{"protocol_version":1,"data":{},"exit_code":0}"#
 17
 18private func argvOf(_ seen: StubProtocol.Seen) throws -> ([String], String?) {
 19    let body = try #require(try JSONSerialization.jsonObject(with: seen.body) as? [String: Any])
 20    return (try #require(body["argv"] as? [String]), body["stdin"] as? String)
 21}
 22
 23@MainActor
 24struct IssueCreateViewModelTests {
 25
 26    @Test func createSendsTitleInArgvAndBodyOverStdin() async throws {
 27        let (client, stub) = try makeClient()
 28        stub.enqueue(.init(status: 200, json: createdJSON))
 29        let model = IssueCreateViewModel(client: client, repoPath: "krz/gitbay")
 30
 31        let number = await model.create(title: "org READMEs", body: "render them properly")
 32
 33        #expect(number == 7)
 34        let (argv, stdin) = try argvOf(try #require(stub.seen.first))
 35        #expect(argv == ["issue", "create", "krz/gitbay", "--title", "org READMEs", "--file", "-"])
 36        #expect(stdin == "render them properly")
 37    }
 38
 39    @Test func anEmptyBodyOmitsStdinAndTheFileFlag() async throws {
 40        let (client, stub) = try makeClient()
 41        stub.enqueue(.init(status: 200, json: createdJSON))
 42        let model = IssueCreateViewModel(client: client, repoPath: "krz/gitbay")
 43
 44        _ = await model.create(title: "just a title", body: "")
 45
 46        let (argv, stdin) = try argvOf(try #require(stub.seen.first))
 47        #expect(argv == ["issue", "create", "krz/gitbay", "--title", "just a title"])
 48        #expect(stdin == nil)
 49    }
 50
 51    @Test func aRefusalSurfacesAndReturnsNil() async throws {
 52        let (client, stub) = try makeClient()
 53        stub.enqueue(.init(status: 403, json:
 54            #"{"protocol_version":1,"error":"krz/gitbay is archived and read-only","exit_code":4}"#))
 55        let model = IssueCreateViewModel(client: client, repoPath: "krz/gitbay")
 56
 57        let number = await model.create(title: "t", body: "b")
 58
 59        #expect(number == nil)
 60        #expect(model.errorMessage == "krz/gitbay is archived and read-only")
 61    }
 62}
 63
 64@MainActor
 65struct IssueTemplateTests {
 66
 67    @Test func loadTemplatesReadsTheRepoTemplates() async throws {
 68        let (client, stub) = try makeClient()
 69        stub.enqueue(.init(status: 200, json: """
 70            {"protocol_version":1,"data":[\
 71            {"name":"bug","body":"## Steps\\n\\n1. "},\
 72            {"name":"feature","body":"## Why\\n"}],"exit_code":0}
 73            """))
 74        let model = IssueCreateViewModel(client: client, repoPath: "krz/gitbay")
 75
 76        await model.loadTemplates()
 77
 78        #expect(model.templates.map(\.name) == ["bug", "feature"])
 79        #expect(model.templates.first?.body == "## Steps\n\n1. ")
 80        #expect(stub.seen.first?.url.query() == "argv=issue&argv=templates&argv=krz/gitbay")
 81    }
 82
 83    @Test func aTemplateReadFailureLeavesNoTemplatesAndNoError() async throws {
 84        let (client, stub) = try makeClient()
 85        stub.enqueue(.init(status: 500, json: #"{"protocol_version":1,"error":"boom","exit_code":1}"#))
 86        let model = IssueCreateViewModel(client: client, repoPath: "krz/gitbay")
 87
 88        await model.loadTemplates()
 89
 90        #expect(model.templates.isEmpty)
 91        #expect(model.errorMessage == nil)
 92    }
 93}
 94
 95@MainActor
 96struct MRCreateViewModelTests {
 97
 98    @Test func targetPrefillsFromRepoShow() async throws {
 99        let (client, stub) = try makeClient()
100        stub.enqueue(.init(status: 200, json: """
101            {"protocol_version":1,"data":{"path":"krz/gitbay","visibility":"public",\
102            "default_branch":"main"},"exit_code":0}
103            """))
104        let model = MRCreateViewModel(client: client, repoPath: "krz/gitbay")
105
106        await model.loadDefaultBranch()
107
108        #expect(model.defaultBranch == "main")
109    }
110
111    @Test func createSendsBranchesTitleAndBody() async throws {
112        let (client, stub) = try makeClient()
113        stub.enqueue(.init(status: 200, json:
114            #"{"protocol_version":1,"data":{"number":4,"head_sha":"aa"},"exit_code":0}"#))
115        let model = MRCreateViewModel(client: client, repoPath: "krz/gitbay")
116
117        let number = await model.create(
118            source: "fix-thing", target: "main", title: "fix the thing", body: "details"
119        )
120
121        #expect(number == 4)
122        let (argv, stdin) = try argvOf(try #require(stub.seen.first))
123        #expect(argv == ["mr", "create", "krz/gitbay",
124                         "--source", "fix-thing", "--target", "main",
125                         "--title", "fix the thing", "--file", "-"])
126        #expect(stdin == "details")
127    }
128}
129
130@MainActor
131struct EditAndMilestoneTests {
132
133    private let issueShow = """
134        {"protocol_version":1,"data":{"number":11,"title":"iOS app","state":"open",\
135        "author":"krz","body":"Build it.","created_at":"2026-08-20T10:00:00.000Z"},"exit_code":0}
136        """
137
138    @Test func issueEditSendsTitleAndBodyAlways() async throws {
139        let (client, stub) = try makeClient()
140        stub.enqueue(.init(status: 200, json: issueShow, match: "argv=show"))
141        let model = IssueDetailViewModel(client: client, repoPath: "krz/gitbay", number: 11)
142        await model.load()
143        stub.enqueue(.init(status: 200, json: okJSON, match: "cmd"))
144        stub.enqueue(.init(status: 200, json: issueShow, match: "argv=show"))
145
146        await model.edit(title: "iOS app (v1)", body: "")
147
148        let write = try #require(stub.seen.first { $0.method == "POST" })
149        let (argv, stdin) = try argvOf(write)
150        #expect(argv == ["issue", "edit", "krz/gitbay", "11", "--title", "iOS app (v1)", "--file", "-"])
151        #expect(stdin == "") // empty body clears, matching the CLI
152    }
153
154    @Test func milestoneSetAndClearSpellNoneCorrectly() async throws {
155        let (client, stub) = try makeClient()
156        stub.enqueue(.init(status: 200, json: issueShow, match: "argv=show"))
157        let model = IssueDetailViewModel(client: client, repoPath: "krz/gitbay", number: 11)
158        await model.load()
159        for _ in 0..<2 {
160            stub.enqueue(.init(status: 200, json: okJSON, match: "cmd"))
161            stub.enqueue(.init(status: 200, json: issueShow, match: "argv=show"))
162        }
163
164        await model.setMilestone("v1.0.0")
165        await model.setMilestone(nil)
166
167        let writes = try stub.seen.filter { $0.method == "POST" }.map { try argvOf($0).0 }
168        #expect(writes[0] == ["issue", "milestone", "krz/gitbay", "11", "v1.0.0"])
169        #expect(writes[1] == ["issue", "milestone", "krz/gitbay", "11", "none"])
170    }
171
172    @Test func milestonesLoadOnceForThePicker() async throws {
173        let (client, stub) = try makeClient()
174        stub.enqueue(.init(status: 200, json: issueShow, match: "argv=show"))
175        let model = IssueDetailViewModel(client: client, repoPath: "krz/gitbay", number: 11)
176        await model.load()
177        stub.enqueue(.init(status: 200, json: """
178            {"protocol_version":1,"data":[{"title":"v1.0.0","state":"open","open":3,"closed":5}],\
179            "exit_code":0}
180            """, match: "argv=milestone"))
181
182        await model.loadMilestones()
183        await model.loadMilestones() // second call must not refetch
184
185        #expect(model.availableMilestones?.first?.title == "v1.0.0")
186        #expect(stub.seen.count { ($0.url.query() ?? "").contains("argv=milestone") } == 1)
187    }
188
189    @Test func mrEditSendsTitleAndBody() async throws {
190        let (client, stub) = try makeClient()
191        let mrShow = """
192            {"protocol_version":1,"data":{"number":7,"title":"old","state":"open","author":"cmc",\
193            "source":"b","target_ref":"main","head_sha":"aa","created_at":"2026-08-20T10:00:00.000Z"},\
194            "exit_code":0}
195            """
196        stub.enqueue(.init(status: 200, json: mrShow, match: "argv=show"))
197        stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"output":"","exit_code":0}"#, match: "argv=diff"))
198        stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"exit_code":0}"#, match: "argv=threads"))
199        let model = MRDetailViewModel(client: client, repoPath: "krz/gitbay", number: 7)
200        await model.load()
201        stub.enqueue(.init(status: 200, json: okJSON, match: "cmd"))
202        stub.enqueue(.init(status: 200, json: mrShow, match: "argv=show"))
203        stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"output":"","exit_code":0}"#, match: "argv=diff"))
204        stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"exit_code":0}"#, match: "argv=threads"))
205
206        await model.edit(title: "new title", body: "new body")
207
208        let write = try #require(stub.seen.first { $0.method == "POST" })
209        let (argv, stdin) = try argvOf(write)
210        #expect(argv == ["mr", "edit", "krz/gitbay", "7", "--title", "new title", "--file", "-"])
211        #expect(stdin == "new body")
212    }
213}