gitbayTests/RefsMilestoneTests.swift

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

251 lines · 10595 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 func argvOf(_ seen: StubProtocol.Seen) throws -> [String] {
 16    let body = try #require(try JSONSerialization.jsonObject(with: seen.body) as? [String: Any])
 17    return try #require(body["argv"] as? [String])
 18}
 19
 20private let refsJSON = """
 21    {"protocol_version":1,"data":{"branches":[\
 22    {"name":"main","sha":"aaaaaaaaaabbbbbbbbbb"},{"name":"feature","sha":"cccccccccc"}],\
 23    "tags":[{"name":"v1.0.0","sha":"dddddddddd"}]},"exit_code":0}
 24    """
 25private let repoShowJSON = """
 26    {"protocol_version":1,"data":{"path":"krz/gitbay","visibility":"public",\
 27    "default_branch":"main"},"exit_code":0}
 28    """
 29private let milestonesJSON = """
 30    {"protocol_version":1,"data":[\
 31    {"title":"v1.0.0","description":"launch","state":"open","open":3,"closed":9},\
 32    {"title":"v0.5.0","state":"closed","open":0,"closed":4}],"exit_code":0}
 33    """
 34
 35@MainActor
 36struct RefsViewModelTests {
 37
 38    private func loadedModel() async throws -> (RefsViewModel, StubProtocol.Box) {
 39        let (client, stub) = try makeClient()
 40        stub.enqueue(.init(status: 200, json: refsJSON, match: "argv=refs"))
 41        stub.enqueue(.init(status: 200, json: repoShowJSON, match: "argv=show"))
 42        let model = RefsViewModel(client: client, repoPath: "krz/gitbay")
 43        await model.load()
 44        return (model, stub)
 45    }
 46
 47    @Test func loadsBranchesTagsAndTheDefault() async throws {
 48        let (model, stub) = try await loadedModel()
 49
 50        #expect(model.branches.map(\.name) == ["main", "feature"])
 51        #expect(model.tags.map(\.name) == ["v1.0.0"])
 52        #expect(model.defaultBranch == "main")
 53        #expect(stub.seen.contains { $0.url.query() == "argv=repo&argv=refs&argv=krz/gitbay" })
 54    }
 55
 56    @Test func theFilterMatchesBothBranchesAndTags() async throws {
 57        let (model, _) = try await loadedModel()
 58
 59        model.searchText = "v1"
 60        #expect(model.branches.isEmpty)
 61        #expect(model.tags.map(\.name) == ["v1.0.0"])
 62
 63        model.searchText = "feat"
 64        #expect(model.branches.map(\.name) == ["feature"])
 65        #expect(model.tags.isEmpty)
 66    }
 67
 68    @Test func aRepoWithoutRefsIsAnEmptyState() async throws {
 69        let (client, stub) = try makeClient()
 70        stub.enqueue(.init(status: 200, json:
 71            #"{"protocol_version":1,"data":{"branches":[],"tags":[]},"exit_code":0}"#,
 72            match: "argv=refs"))
 73        stub.enqueue(.init(status: 200, json: repoShowJSON, match: "argv=show"))
 74        let model = RefsViewModel(client: client, repoPath: "krz/empty")
 75
 76        await model.load()
 77
 78        guard case .empty = model.state else {
 79            Issue.record("expected .empty, got \(model.state)")
 80            return
 81        }
 82    }
 83}
 84
 85@MainActor
 86struct MilestoneListViewModelTests {
 87
 88    @Test func loadsMilestonesWithProgress() async throws {
 89        let (client, stub) = try makeClient()
 90        stub.enqueue(.init(status: 200, json: milestonesJSON))
 91        let model = MilestoneListViewModel(client: client, repoPath: "krz/gitbay")
 92
 93        await model.load()
 94
 95        let milestones = try #require(model.state.value)
 96        #expect(milestones.map(\.title) == ["v1.0.0", "v0.5.0"])
 97        #expect(milestones[0].open == 3 && milestones[0].closed == 9)
 98        #expect(stub.seen.first?.url.query() ==
 99            "argv=milestone&argv=list&argv=krz/gitbay&argv=--state&argv=open")
100    }
101
102    /// v1.19.0: a milestone the repository sees through its org carries
103    /// `org: true`; repository rows and older servers omit the key.
104    @Test func anOrgRowIsMarkedAndARepoRowIsNot() async throws {
105        let (client, stub) = try makeClient()
106        stub.enqueue(.init(status: 200, json: """
107            {"protocol_version":1,"data":[\
108            {"title":"v2","state":"open","org":true,"open":1,"closed":0},\
109            {"title":"v1.0.0","state":"open","open":3,"closed":9}],"exit_code":0}
110            """))
111        let model = MilestoneListViewModel(client: client, repoPath: "krz/gitbay")
112
113        await model.load()
114
115        let milestones = try #require(model.state.value)
116        #expect(milestones.map(\.org) == [true, false])
117    }
118
119    @Test func changingTheFilterReloadsWithThatState() async throws {
120        let (client, stub) = try makeClient()
121        stub.enqueue(.init(status: 200, json: milestonesJSON))
122        stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"exit_code":0}"#))
123        let model = MilestoneListViewModel(client: client, repoPath: "krz/gitbay")
124        await model.load()
125
126        model.filter = .closed
127        // The reload happens in a spawned task; wait for it to settle
128        // rather than for a fixed interval to pass. Checking `model.state`
129        // alone is not enough: right after the assignment, before the
130        // spawned task has even run, the state is still the *previous*
131        // load's non-loading result, which would satisfy a bare
132        // "not loading" check before the reload ever started.
133        await until {
134            guard stub.seen.count == 2 else { return false }
135            if case .loading = model.state { return false }
136            return true
137        }
138
139        #expect(stub.seen.count == 2)
140        let second = try #require(stub.seen.dropFirst().first)
141        #expect(second.url.query()?.contains("argv=closed") == true)
142    }
143
144    private func loaded() async throws -> (MilestoneListViewModel, StubProtocol.Box) {
145        let (client, stub) = try makeClient()
146        stub.enqueue(.init(status: 200, json: milestonesJSON, match: "argv=list"))
147        let model = MilestoneListViewModel(client: client, repoPath: "krz/gitbay")
148        await model.load()
149        return (model, stub)
150    }
151
152    @Test func createSendsTitleDescriptionAndDueThenReloads() async throws {
153        let (model, stub) = try await loaded()
154        stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"data":{"milestone":"v1.1.0"},"exit_code":0}"#, match: "cmd"))
155        stub.enqueue(.init(status: 200, json: milestonesJSON, match: "argv=list"))
156
157        let ok = await model.create(title: "v1.1.0", description: "next", due: "2026-10-01")
158
159        #expect(ok)
160        let write = try #require(stub.seen.first { $0.method == "POST" })
161        #expect(try argvOf(write) == ["milestone", "create", "krz/gitbay", "v1.1.0",
162                                      "--description", "next", "--due", "2026-10-01"])
163        #expect(stub.seen.filter { $0.method == "GET" }.count == 2)
164    }
165
166    @Test func createOmitsEmptyDescriptionAndDue() async throws {
167        let (model, stub) = try await loaded()
168        stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"data":{"milestone":"v1.1.0"},"exit_code":0}"#, match: "cmd"))
169        stub.enqueue(.init(status: 200, json: milestonesJSON, match: "argv=list"))
170
171        _ = await model.create(title: " v1.1.0 ", description: "", due: "")
172
173        let write = try #require(stub.seen.first { $0.method == "POST" })
174        #expect(try argvOf(write) == ["milestone", "create", "krz/gitbay", "v1.1.0"])
175    }
176
177    @Test func closeAndReopenUseTheirCommands() async throws {
178        let (model, stub) = try await loaded()
179        for _ in 0..<2 {
180            stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"data":{},"exit_code":0}"#, match: "cmd"))
181            stub.enqueue(.init(status: 200, json: milestonesJSON, match: "argv=list"))
182        }
183
184        await model.close("v1.0.0")
185        await model.reopen("v0.5.0")
186
187        let writes = try stub.seen.filter { $0.method == "POST" }.map(argvOf)
188        #expect(writes == [
189            ["milestone", "close", "krz/gitbay", "v1.0.0"],
190            ["milestone", "reopen", "krz/gitbay", "v0.5.0"],
191        ])
192    }
193
194    @Test func aRefusedCreateSurfacesAndReturnsFalse() async throws {
195        let (model, stub) = try await loaded()
196        stub.enqueue(.init(status: 403, json: #"{"protocol_version":1,"error":"write access required","exit_code":4}"#, match: "cmd"))
197
198        let ok = await model.create(title: "v9", description: "", due: "")
199
200        #expect(!ok)
201        #expect(model.actionError == "write access required")
202    }
203}
204
205@MainActor
206struct MRMilestoneTests {
207
208    private let mrShow = """
209        {"protocol_version":1,"data":{"number":7,"title":"t","state":"open","author":"cmc",\
210        "source":"b","target_ref":"main","head_sha":"aa","milestone":"v1.0.0",\
211        "created_at":"2026-08-20T10:00:00.000Z"},"exit_code":0}
212        """
213
214    private func loadedModel() async throws -> (MRDetailViewModel, StubProtocol.Box) {
215        let (client, stub) = try makeClient()
216        stub.enqueue(.init(status: 200, json: mrShow, match: "argv=show"))
217        stub.enqueue(.init(status: 200, json:
218            #"{"protocol_version":1,"output":"","exit_code":0}"#, match: "argv=diff"))
219        stub.enqueue(.init(status: 200, json:
220            #"{"protocol_version":1,"exit_code":0}"#, match: "argv=threads"))
221        let model = MRDetailViewModel(client: client, repoPath: "krz/gitbay", number: 7)
222        await model.load()
223        return (model, stub)
224    }
225
226    @Test func mrShowCarriesItsMilestone() async throws {
227        let (model, _) = try await loadedModel()
228        // Previously a blind write: you could set it and nothing read back.
229        #expect(model.state.value?.milestone == "v1.0.0")
230    }
231
232    @Test func settingAndClearingUseTheCommandsSpelling() async throws {
233        let (model, stub) = try await loadedModel()
234        for _ in 0..<2 {
235            stub.enqueue(.init(status: 200, json:
236                #"{"protocol_version":1,"data":{},"exit_code":0}"#, match: "cmd"))
237            stub.enqueue(.init(status: 200, json: mrShow, match: "argv=show"))
238            stub.enqueue(.init(status: 200, json:
239                #"{"protocol_version":1,"output":"","exit_code":0}"#, match: "argv=diff"))
240            stub.enqueue(.init(status: 200, json:
241                #"{"protocol_version":1,"exit_code":0}"#, match: "argv=threads"))
242        }
243
244        await model.setMilestone("v1.1.0")
245        await model.setMilestone(nil)
246
247        let writes = try stub.seen.filter { $0.method == "POST" }.map(argvOf)
248        #expect(writes[0] == ["mr", "milestone", "krz/gitbay", "7", "v1.1.0"])
249        #expect(writes[1] == ["mr", "milestone", "krz/gitbay", "7", "none"])
250    }
251}