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 writes(_ stub: StubProtocol.Box) throws -> [[String]] { try stub.seen.filter { $0.url.path().hasSuffix("/cmd") }.map { let body = try #require(try JSONSerialization.jsonObject(with: $0.body) as? [String: Any]) return try #require(body["argv"] as? [String]) } } private let mergedJSON = """ {"protocol_version":1,"data":{"number":7,"title":"client: envelope decoding",\ "state":"merged","author":"cmc","source":"client-envelope","target_ref":"main",\ "head_sha":"aabbcc","created_at":"2026-08-20T10:00:00.000Z",\ "merged_at":"2026-08-21T09:15:00.000Z","merged_by":"cmc"},"exit_code":0} """ @MainActor struct OrgDeleteTests { @Test func deleteConfirmsAndTellsOtherScreens() async throws { let (client, stub) = try makeClient() stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"data":{"deleted":"acme"},"exit_code":0}"#, match: "cmd")) let model = OrgViewModel(client: client, orgName: "acme") var noticed: String? let token = NotificationCenter.default.addObserver(forName: .gitbayOrganizationsChanged, object: nil, queue: nil) { noticed = $0.object as? String } defer { NotificationCenter.default.removeObserver(token) } #expect(await model.delete()) #expect(try writes(stub) == [["org", "delete", "acme", "--yes"]]) #expect(noticed == "acme") } @Test func aNonEmptyOrganizationIsRefusedWithTheReason() async throws { let (client, stub) = try makeClient() let refusal = #"{"protocol_version":1,"error":"the organization still owns 2 repositories; delete or transfer them first","exit_code":1}"# stub.enqueue(.init(status: 500, json: refusal, match: "cmd")) stub.enqueue(.init(status: 500, json: refusal, match: "cmd")) let model = OrgViewModel(client: client, orgName: "acme") #expect(await model.delete() == false) #expect(model.actionError == "the organization still owns 2 repositories; delete or transfer them first") } @Test func aRetryThatFindsItGoneIsDone() async throws { let (client, stub) = try makeClient() stub.enqueue(.init(status: 500, json: #"{"protocol_version":1,"error":"database is locked","exit_code":1}"#, match: "cmd")) stub.enqueue(.init(status: 404, json: #"{"protocol_version":1,"error":"no organization acme","exit_code":3}"#, match: "cmd")) let model = OrgViewModel(client: client, orgName: "acme") #expect(await model.delete()) } } @MainActor struct MRPruneTests { @Test func pruneNamesTheMergeRequestAndConfirms() async throws { let (client, stub) = try makeClient() stub.enqueue(.init(status: 200, json: mergedJSON, match: "argv=show")) stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"data":{"user":"cmc","admin":true},"exit_code":0}"#, match: "argv=whoami")) stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"data":[{"number":7,"head":"abc123"}],"exit_code":0}"#, match: "cmd")) stub.enqueue(.init(status: 200, json: mergedJSON, match: "argv=show")) let model = MRDetailViewModel(client: client, repoPath: "krz/gitbay", number: 7) await model.load() await model.checkCanPruneHead() #expect(model.canPruneHead, "an admin on a merged merge request is offered the prune") await model.pruneHead() #expect(try writes(stub) == [["admin", "mr", "prune", "krz/gitbay", "7", "--yes"]]) #expect(model.actionError == nil) #expect(model.canPruneHead == false, "a pruned head is not offered again") } @Test func aStillMergeableHeadIsRefusedWithTheReason() async throws { let (client, stub) = try makeClient() let refusal = #"{"protocol_version":1,"error":"!7 is still mergeable and its head is what makes it so; merge or close it first","exit_code":1}"# stub.enqueue(.init(status: 500, json: refusal, match: "cmd")) stub.enqueue(.init(status: 500, json: refusal, match: "cmd")) let model = MRDetailViewModel(client: client, repoPath: "krz/gitbay", number: 7) await model.pruneHead() #expect(model.actionError == "!7 is still mergeable and its head is what makes it so; merge or close it first") } }