gitbayTests/SmallActionTests.swift
194 lines · 7991 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 ok = """
21 {"protocol_version":1,"exit_code":0}
22 """
23
24@MainActor
25struct BuildCancelTests {
26
27 /// The wire status is "pending", not "queued" — the refusal message
28 /// says "queued" but the value never does.
29 @Test func onlyPendingAndRunningBuildsAreCancellable() {
30 #expect(BuildDetailViewModel.isCancellable(status: "pending"))
31 #expect(BuildDetailViewModel.isCancellable(status: "running"))
32 for status in ["success", "failure", "cancelled", "queued", ""] {
33 #expect(BuildDetailViewModel.isCancellable(status: status) == false,
34 "\(status) should not be cancellable")
35 }
36 }
37
38 @Test func cancelSendsTheBuildNumber() async throws {
39 let (client, stub) = try makeClient()
40 // The write, then its reload — `build show` and `build log` fire
41 // concurrently, so each needs its own matched stub.
42 let model = BuildDetailViewModel(client: client, repoPath: "krz/gitbay", number: 966)
43 stub.enqueue(.init(status: 200, json: ok))
44 stub.enqueue(.init(status: 200, json: """
45 {"protocol_version":1,"data":{"number":966,"job":"test","status":"cancelled",\
46 "sha":"abc123","ref":"refs/heads/main","created_at":"2026-01-01T00:00:00Z"},\
47 "exit_code":0}
48 """, match: "argv=show"))
49 stub.enqueue(.init(status: 200, json: ok, match: "argv=log"))
50 await model.cancel()
51
52 let write = try #require(stub.seen.first { $0.method == "POST" })
53 #expect(try argvOf(write) == ["build", "cancel", "krz/gitbay", "966"])
54 }
55
56 /// The view reads the instance property, not the static function —
57 /// nothing pinned that it tracks the loaded build, or that it starts
58 /// out false before anything has loaded.
59 @Test func instanceIsCancellableTracksTheLoadedBuild() async throws {
60 let (client, stub) = try makeClient()
61 let model = BuildDetailViewModel(client: client, repoPath: "krz/gitbay", number: 966)
62 #expect(model.isCancellable == false)
63
64 stub.enqueue(.init(status: 200, json: """
65 {"protocol_version":1,"data":{"number":966,"job":"test","status":"running",\
66 "sha":"abc123","ref":"refs/heads/main","created_at":"2026-01-01T00:00:00Z"},\
67 "exit_code":0}
68 """, match: "argv=show"))
69 stub.enqueue(.init(status: 200, json: ok, match: "argv=log"))
70 await model.load()
71
72 #expect(model.isCancellable)
73 }
74}
75
76@MainActor
77struct ReleaseDeleteTests {
78
79 /// `--yes` is required by the command, not optional.
80 @Test func deleteSendsTheConfirmationFlag() async throws {
81 let (client, stub) = try makeClient()
82 let model = ReleaseDetailViewModel(client: client, repoPath: "krz/gitbay", tag: "v1.0.0")
83 stub.enqueue(.init(status: 200, json: ok))
84 let deleted = await model.delete()
85
86 #expect(deleted)
87 let write = try #require(stub.seen.first { $0.method == "POST" })
88 #expect(try argvOf(write) == ["release", "delete", "krz/gitbay", "v1.0.0", "--yes"])
89 }
90
91 @Test func aRefusedDeleteReturnsFalseAndSurfaces() async throws {
92 let (client, stub) = try makeClient()
93 let model = ReleaseDetailViewModel(client: client, repoPath: "krz/gitbay", tag: "v1.0.0")
94 stub.enqueue(.init(status: 200, json: """
95 {"protocol_version":1,"error":"deleting a release needs write access","exit_code":4}
96 """))
97 let deleted = await model.delete()
98
99 #expect(deleted == false)
100 #expect(model.actionError?.isEmpty == false)
101 }
102
103 /// A stale `actionError` surviving an unrelated later call has been the
104 /// bug five times over; every `perform` clears it up front, but nothing
105 /// pinned that.
106 @Test func actionErrorClearsOnALaterSuccess() async throws {
107 let (client, stub) = try makeClient()
108 let model = ReleaseDetailViewModel(client: client, repoPath: "krz/gitbay", tag: "v1.0.0")
109 stub.enqueue(.init(status: 200, json: """
110 {"protocol_version":1,"error":"deleting a release needs write access","exit_code":4}
111 """))
112 let firstDelete = await model.delete()
113
114 #expect(firstDelete == false)
115 #expect(model.actionError != nil)
116
117 stub.enqueue(.init(status: 200, json: ok))
118 let secondDelete = await model.delete()
119
120 #expect(secondDelete)
121 #expect(model.actionError == nil)
122 }
123}
124
125@MainActor
126struct OrgCreateRenameTests {
127
128 /// Both names are positional, OLD first. Swapping them renames the
129 /// wrong way with no error at all.
130 ///
131 /// A successful rename triggers `load()`, which fires two concurrent
132 /// GETs (`org show`, `org team list`) alongside the POST — all three
133 /// need a stub, or the reload fails and the model ends up showing an
134 /// error despite the rename having worked.
135 @Test func renameSendsOldThenNewAndReloadsTheNewName() async throws {
136 let (client, stub) = try makeClient()
137 let model = OrgViewModel(client: client, orgName: "krz")
138 stub.enqueue(.init(status: 200, json: ok))
139 stub.enqueue(.init(status: 200, json: """
140 {"protocol_version":1,"data":{"org":"kerouac","members":[]},"exit_code":0}
141 """, match: "argv=show"))
142 stub.enqueue(.init(status: 200, json: """
143 {"protocol_version":1,"data":[],"exit_code":0}
144 """, match: "argv=team"))
145 let renamed = await model.rename(to: "kerouac")
146
147 #expect(renamed)
148 let write = try #require(stub.seen.first { $0.method == "POST" })
149 #expect(try argvOf(write) == ["org", "rename", "krz", "kerouac"])
150
151 #expect(model.actionError == nil)
152 guard case .loaded = model.state else {
153 Issue.record("expected .loaded after a successful rename, got \(model.state)")
154 return
155 }
156 let reloads = try stub.seen.filter { $0.method == "GET" }.map { seen in
157 try #require(seen.url.query(percentEncoded: false)).components(separatedBy: "&")
158 }
159 for reload in reloads {
160 #expect(reload.contains("argv=kerouac"),
161 "expected the reload to target the new name, got \(reload)")
162 }
163 }
164
165 @Test func aBlankRenameSendsNothing() async throws {
166 let (client, stub) = try makeClient()
167 let model = OrgViewModel(client: client, orgName: "krz")
168 let renamed = await model.rename(to: " ")
169
170 #expect(renamed == false)
171 #expect(stub.seen.filter { $0.method == "POST" }.isEmpty)
172 }
173
174 @Test func createSendsTheName() async throws {
175 let (client, stub) = try makeClient()
176 stub.enqueue(.init(status: 200, json: ok))
177 stub.enqueue(.init(status: 200, json: ok))
178 let model = ProfileViewModel(client: client, name: "cmc")
179 let created = await model.createOrg("newco")
180
181 #expect(created)
182 let write = try #require(stub.seen.first { $0.method == "POST" })
183 #expect(try argvOf(write) == ["org", "create", "newco"])
184 }
185
186 @Test func aBlankCreateSendsNothing() async throws {
187 let (client, stub) = try makeClient()
188 let model = ProfileViewModel(client: client, name: "cmc")
189 let created = await model.createOrg(" ")
190
191 #expect(created == false)
192 #expect(stub.seen.filter { $0.method == "POST" }.isEmpty)
193 }
194}