gitbayTests/RefsMilestoneTests.swift
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}