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], String?) { let body = try #require(try JSONSerialization.jsonObject(with: seen.body) as? [String: Any]) return (try #require(body["argv"] as? [String]), body["stdin"] as? String) } private let releaseShowJSON = """ {"protocol_version":1,"data":{"tag":"v1.0.0","title":"v1.0.0 — the CLI is the interface",\ "notes":"Replace the binary and restart.","author":"cmc",\ "created_at":"2026-08-26T05:27:36.148Z",\ "assets":[{"name":"SHA256SUMS","size":557,\ "sha256":"1646f3ec57f91bb2c346078735d47e6f853e4ee0ee5e5176123337663cf8c7f4"}]},"exit_code":0} """ @MainActor struct ReleaseListViewModelTests { @Test func listsReleases() async throws { let (client, stub) = try makeClient() stub.enqueue(.init(status: 200, json: """ {"protocol_version":1,"data":[\ {"tag":"v1.0.0","title":"one","created_at":"2026-08-26T05:27:36.148Z"},\ {"tag":"v0.5.0","title":"half","created_at":"2026-08-20T05:27:36.148Z"}\ ],"exit_code":0} """)) let model = ReleaseListViewModel(client: client, repoPath: "krz/gitbay") await model.load() #expect(model.state.value?.map(\.tag) == ["v1.0.0", "v0.5.0"]) #expect(stub.seen.first?.url.query() == "argv=release&argv=list&argv=krz/gitbay") } @Test func createSendsTagTitleAndNotesOverStdin() async throws { let (client, stub) = try makeClient() stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"data":{},"exit_code":0}"#, match: "cmd")) stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"exit_code":0}"#, match: "argv=list")) let model = ReleaseListViewModel(client: client, repoPath: "krz/gitbay") let created = await model.create(tag: "v1.1.0", title: "next", notes: "notes body") #expect(created) let (argv, stdin) = try argvOf(try #require(stub.seen.first { $0.method == "POST" })) #expect(argv == ["release", "create", "krz/gitbay", "v1.1.0", "--title", "next", "--file", "-"]) #expect(stdin == "notes body") } @Test func aMissingTagRefusalSurfacesVerbatim() async throws { let (client, stub) = try makeClient() stub.enqueue(.init(status: 404, json: #"{"protocol_version":1,"error":"no tag \"v9\" in krz/gitbay — push the tag first","exit_code":3}"#)) let model = ReleaseListViewModel(client: client, repoPath: "krz/gitbay") let created = await model.create(tag: "v9", title: "", notes: "") #expect(!created) #expect(model.createError == #"no tag "v9" in krz/gitbay — push the tag first"#) } @Test func emptyTitleAndNotesAreOmittedFromArgv() async throws { let (client, stub) = try makeClient() stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"data":{},"exit_code":0}"#, match: "cmd")) stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"exit_code":0}"#, match: "argv=list")) let model = ReleaseListViewModel(client: client, repoPath: "krz/gitbay") _ = await model.create(tag: "v1.1.0", title: "", notes: "") let (argv, stdin) = try argvOf(try #require(stub.seen.first { $0.method == "POST" })) #expect(argv == ["release", "create", "krz/gitbay", "v1.1.0"]) #expect(stdin == nil) } } @MainActor struct ReleaseDetailViewModelTests { @Test func loadsShowWithAssets() async throws { let (client, stub) = try makeClient() stub.enqueue(.init(status: 200, json: releaseShowJSON)) let model = ReleaseDetailViewModel(client: client, repoPath: "krz/gitbay", tag: "v1.0.0") await model.load() let release = try #require(model.state.value) #expect(release.assets?.count == 1) #expect(stub.seen.first?.url.query() == "argv=release&argv=show&argv=krz/gitbay&argv=v1.0.0") } @Test func editSendsTitleAndNotes() async throws { let (client, stub) = try makeClient() stub.enqueue(.init(status: 200, json: releaseShowJSON, match: "argv=show")) let model = ReleaseDetailViewModel(client: client, repoPath: "krz/gitbay", tag: "v1.0.0") await model.load() stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"data":{},"exit_code":0}"#, match: "cmd")) stub.enqueue(.init(status: 200, json: releaseShowJSON, match: "argv=show")) await model.edit(title: "new title", notes: "new notes") let (argv, stdin) = try argvOf(try #require(stub.seen.first { $0.method == "POST" })) #expect(argv == ["release", "edit", "krz/gitbay", "v1.0.0", "--title", "new title", "--file", "-"]) #expect(stdin == "new notes") } @Test func removeAssetSendsTheCommandAndReloads() async throws { let (client, stub) = try makeClient() stub.enqueue(.init(status: 200, json: releaseShowJSON, match: "argv=show")) let model = ReleaseDetailViewModel(client: client, repoPath: "krz/gitbay", tag: "v1.0.0") await model.load() stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"data":{},"exit_code":0}"#, match: "cmd")) stub.enqueue(.init(status: 200, json: releaseShowJSON, match: "argv=show")) await model.removeAsset(named: "SHA256SUMS") let (argv, _) = try argvOf(try #require(stub.seen.first { $0.method == "POST" })) #expect(argv == ["release", "asset", "remove", "krz/gitbay", "v1.0.0", "SHA256SUMS"]) #expect(stub.seen.filter { $0.method == "GET" }.count == 2) #expect(model.actionError == nil) } @Test func aRefusedAssetRemovalSurfaces() async throws { let (client, stub) = try makeClient() stub.enqueue(.init(status: 200, json: releaseShowJSON, match: "argv=show")) let model = ReleaseDetailViewModel(client: client, repoPath: "krz/gitbay", tag: "v1.0.0") await model.load() stub.enqueue(.init(status: 403, json: #"{"protocol_version":1,"error":"write access required","exit_code":4}"#, match: "cmd")) await model.removeAsset(named: "SHA256SUMS") #expect(model.actionError == "write access required") #expect(model.state.value?.assets?.count == 1) } @Test func downloadURLsUseTheWebRouteOnTheInstance() async throws { let (client, _) = try makeClient() let model = ReleaseDetailViewModel(client: client, repoPath: "krz/gitbay", tag: "v1.0.0") let asset = Release.Asset(name: "gitbay-v1.0.0-linux-amd64", size: 1, sha256: "aa") let url = model.downloadURL(for: asset) #expect(url.absoluteString == "https://gitbay.org/krz/gitbay/releases/download/v1.0.0/gitbay-v1.0.0-linux-amd64") } } struct FeedReleaseDestinationTests { @Test func releaseEventsNavigateToTheRelease() throws { let decoder = GitbayClient.decoder() let event = try decoder.decode(FeedEvent.self, from: Data(""" {"id":2,"repo":"krz/gitbay","actor":"cmc","kind":"release.created",\ "data":{"tag":"v1.0.0"},"created_at":"2026-08-26T05:27:36Z"} """.utf8)) #expect(event.destination == .release(repo: "krz/gitbay", tag: "v1.0.0")) } } @MainActor struct ReleaseSummaryTests { @Test func theSummaryIsOneLinePerMergeRequestAppendedAfterWhatWasTyped() async throws { let (client, stub) = try makeClient() stub.enqueue(.init(status: 200, json: """ {"protocol_version":1,"data":{"since":"v1.0.0","items":[\ {"number":12,"title":"Add a thing","author":"cmc","merged_at":"2026-08-20T10:00:00.000Z"},\ {"number":14,"title":"Fix it","author":"dee","merged_at":"2026-08-21T10:00:00.000Z"}]},"exit_code":0} """)) let model = ReleaseListViewModel(client: client, repoPath: "krz/gitbay") let lines = try #require(await model.summary(tag: "v1.1.0")) #expect(ReleaseListViewModel.appending(lines, to: "Second cut.\n") == "Second cut.\n\n- Add a thing (!12) by @cmc\n- Fix it (!14) by @dee\n") #expect(ReleaseListViewModel.appending(lines, to: " ") == lines) #expect(stub.seen.first?.url.query() == "argv=release&argv=notes&argv=krz/gitbay&argv=v1.1.0") #expect(model.summaryNote == nil) } @Test func anEmptyRangeGivesNoLinesAndSaysWhy() async throws { let (client, stub) = try makeClient() stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"data":{"since":"v1.0.0","items":[]},"exit_code":0}"#)) let model = ReleaseListViewModel(client: client, repoPath: "krz/gitbay") #expect(await model.summary(tag: "v1.1.0") == nil) #expect(model.summaryNote == "No merge requests landed since v1.0.0.") } @Test func aRefusalGivesNoLinesAndShowsIt() async throws { let (client, stub) = try makeClient() stub.enqueue(.init(status: 404, json: #"{"protocol_version":1,"error":"no ref \"v9\" in krz/gitbay","exit_code":3}"#)) let model = ReleaseListViewModel(client: client, repoPath: "krz/gitbay") #expect(await model.summary(tag: "v9") == nil) #expect(model.summaryNote == #"no ref "v9" in krz/gitbay"#) } }