gitbayTests/SmallActionTests.swift

v1.0.6
gitbay-ios/gitbayTests/SmallActionTests.swift history · blame · raw

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}