import Foundation import Testing @testable import gitbay private func makeClient() throws -> (GitbayClient, StubProtocol.Box) { let box = StubProtocol.box() let client = GitbayClient( instance: try GitbayInstance(url: "https://gitbay.org"), token: "test-token", session: box.session() ) return (client, box) } private func argvOf(_ seen: StubProtocol.Seen) throws -> [String] { let body = try #require(try JSONSerialization.jsonObject(with: seen.body) as? [String: Any]) return try #require(body["argv"] as? [String]) } private let ok = """ {"protocol_version":1,"exit_code":0} """ @MainActor struct BuildCancelTests { /// The wire status is "pending", not "queued" — the refusal message /// says "queued" but the value never does. @Test func onlyPendingAndRunningBuildsAreCancellable() { #expect(BuildDetailViewModel.isCancellable(status: "pending")) #expect(BuildDetailViewModel.isCancellable(status: "running")) for status in ["success", "failure", "cancelled", "queued", ""] { #expect(BuildDetailViewModel.isCancellable(status: status) == false, "\(status) should not be cancellable") } } @Test func cancelSendsTheBuildNumber() async throws { let (client, stub) = try makeClient() // The write, then its reload — `build show` and `build log` fire // concurrently, so each needs its own matched stub. let model = BuildDetailViewModel(client: client, repoPath: "krz/gitbay", number: 966) stub.enqueue(.init(status: 200, json: ok)) stub.enqueue(.init(status: 200, json: """ {"protocol_version":1,"data":{"number":966,"job":"test","status":"cancelled",\ "sha":"abc123","ref":"refs/heads/main","created_at":"2026-01-01T00:00:00Z"},\ "exit_code":0} """, match: "argv=show")) stub.enqueue(.init(status: 200, json: ok, match: "argv=log")) await model.cancel() let write = try #require(stub.seen.first { $0.method == "POST" }) #expect(try argvOf(write) == ["build", "cancel", "krz/gitbay", "966"]) } /// The view reads the instance property, not the static function — /// nothing pinned that it tracks the loaded build, or that it starts /// out false before anything has loaded. @Test func instanceIsCancellableTracksTheLoadedBuild() async throws { let (client, stub) = try makeClient() let model = BuildDetailViewModel(client: client, repoPath: "krz/gitbay", number: 966) #expect(model.isCancellable == false) stub.enqueue(.init(status: 200, json: """ {"protocol_version":1,"data":{"number":966,"job":"test","status":"running",\ "sha":"abc123","ref":"refs/heads/main","created_at":"2026-01-01T00:00:00Z"},\ "exit_code":0} """, match: "argv=show")) stub.enqueue(.init(status: 200, json: ok, match: "argv=log")) await model.load() #expect(model.isCancellable) } } @MainActor struct ReleaseDeleteTests { /// `--yes` is required by the command, not optional. @Test func deleteSendsTheConfirmationFlag() async throws { let (client, stub) = try makeClient() let model = ReleaseDetailViewModel(client: client, repoPath: "krz/gitbay", tag: "v1.0.0") stub.enqueue(.init(status: 200, json: ok)) let deleted = await model.delete() #expect(deleted) let write = try #require(stub.seen.first { $0.method == "POST" }) #expect(try argvOf(write) == ["release", "delete", "krz/gitbay", "v1.0.0", "--yes"]) } @Test func aRefusedDeleteReturnsFalseAndSurfaces() async throws { let (client, stub) = try makeClient() let model = ReleaseDetailViewModel(client: client, repoPath: "krz/gitbay", tag: "v1.0.0") stub.enqueue(.init(status: 200, json: """ {"protocol_version":1,"error":"deleting a release needs write access","exit_code":4} """)) let deleted = await model.delete() #expect(deleted == false) #expect(model.actionError?.isEmpty == false) } /// A stale `actionError` surviving an unrelated later call has been the /// bug five times over; every `perform` clears it up front, but nothing /// pinned that. @Test func actionErrorClearsOnALaterSuccess() async throws { let (client, stub) = try makeClient() let model = ReleaseDetailViewModel(client: client, repoPath: "krz/gitbay", tag: "v1.0.0") stub.enqueue(.init(status: 200, json: """ {"protocol_version":1,"error":"deleting a release needs write access","exit_code":4} """)) let firstDelete = await model.delete() #expect(firstDelete == false) #expect(model.actionError != nil) stub.enqueue(.init(status: 200, json: ok)) let secondDelete = await model.delete() #expect(secondDelete) #expect(model.actionError == nil) } } @MainActor struct OrgCreateRenameTests { /// Both names are positional, OLD first. Swapping them renames the /// wrong way with no error at all. /// /// A successful rename triggers `load()`, which fires two concurrent /// GETs (`org show`, `org team list`) alongside the POST — all three /// need a stub, or the reload fails and the model ends up showing an /// error despite the rename having worked. @Test func renameSendsOldThenNewAndReloadsTheNewName() async throws { let (client, stub) = try makeClient() let model = OrgViewModel(client: client, orgName: "krz") stub.enqueue(.init(status: 200, json: ok)) stub.enqueue(.init(status: 200, json: """ {"protocol_version":1,"data":{"org":"kerouac","members":[]},"exit_code":0} """, match: "argv=show")) stub.enqueue(.init(status: 200, json: """ {"protocol_version":1,"data":[],"exit_code":0} """, match: "argv=team")) let renamed = await model.rename(to: "kerouac") #expect(renamed) let write = try #require(stub.seen.first { $0.method == "POST" }) #expect(try argvOf(write) == ["org", "rename", "krz", "kerouac"]) #expect(model.actionError == nil) guard case .loaded = model.state else { Issue.record("expected .loaded after a successful rename, got \(model.state)") return } let reloads = try stub.seen.filter { $0.method == "GET" }.map { seen in try #require(seen.url.query(percentEncoded: false)).components(separatedBy: "&") } for reload in reloads { #expect(reload.contains("argv=kerouac"), "expected the reload to target the new name, got \(reload)") } } @Test func aBlankRenameSendsNothing() async throws { let (client, stub) = try makeClient() let model = OrgViewModel(client: client, orgName: "krz") let renamed = await model.rename(to: " ") #expect(renamed == false) #expect(stub.seen.filter { $0.method == "POST" }.isEmpty) } @Test func createSendsTheName() async throws { let (client, stub) = try makeClient() stub.enqueue(.init(status: 200, json: ok)) stub.enqueue(.init(status: 200, json: ok)) let model = ProfileViewModel(client: client, name: "cmc") let created = await model.createOrg("newco") #expect(created) let write = try #require(stub.seen.first { $0.method == "POST" }) #expect(try argvOf(write) == ["org", "create", "newco"]) } @Test func aBlankCreateSendsNothing() async throws { let (client, stub) = try makeClient() let model = ProfileViewModel(client: client, name: "cmc") let created = await model.createOrg(" ") #expect(created == false) #expect(stub.seen.filter { $0.method == "POST" }.isEmpty) } }