gitbayTests/ReleaseTests.swift
171 lines · 7676 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], String?) {
16 let body = try #require(try JSONSerialization.jsonObject(with: seen.body) as? [String: Any])
17 return (try #require(body["argv"] as? [String]), body["stdin"] as? String)
18}
19
20private let releaseShowJSON = """
21 {"protocol_version":1,"data":{"tag":"v1.0.0","title":"v1.0.0 — the CLI is the interface",\
22 "notes":"Replace the binary and restart.","author":"cmc",\
23 "created_at":"2026-08-26T05:27:36.148Z",\
24 "assets":[{"name":"SHA256SUMS","size":557,\
25 "sha256":"1646f3ec57f91bb2c346078735d47e6f853e4ee0ee5e5176123337663cf8c7f4"}]},"exit_code":0}
26 """
27
28@MainActor
29struct ReleaseListViewModelTests {
30
31 @Test func listsReleases() async throws {
32 let (client, stub) = try makeClient()
33 stub.enqueue(.init(status: 200, json: """
34 {"protocol_version":1,"data":[\
35 {"tag":"v1.0.0","title":"one","created_at":"2026-08-26T05:27:36.148Z"},\
36 {"tag":"v0.5.0","title":"half","created_at":"2026-08-20T05:27:36.148Z"}\
37 ],"exit_code":0}
38 """))
39 let model = ReleaseListViewModel(client: client, repoPath: "krz/gitbay")
40
41 await model.load()
42
43 #expect(model.state.value?.map(\.tag) == ["v1.0.0", "v0.5.0"])
44 #expect(stub.seen.first?.url.query() == "argv=release&argv=list&argv=krz/gitbay")
45 }
46
47 @Test func createSendsTagTitleAndNotesOverStdin() async throws {
48 let (client, stub) = try makeClient()
49 stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"data":{},"exit_code":0}"#, match: "cmd"))
50 stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"exit_code":0}"#, match: "argv=list"))
51 let model = ReleaseListViewModel(client: client, repoPath: "krz/gitbay")
52
53 let created = await model.create(tag: "v1.1.0", title: "next", notes: "notes body")
54
55 #expect(created)
56 let (argv, stdin) = try argvOf(try #require(stub.seen.first { $0.method == "POST" }))
57 #expect(argv == ["release", "create", "krz/gitbay", "v1.1.0",
58 "--title", "next", "--file", "-"])
59 #expect(stdin == "notes body")
60 }
61
62 @Test func aMissingTagRefusalSurfacesVerbatim() async throws {
63 let (client, stub) = try makeClient()
64 stub.enqueue(.init(status: 404, json:
65 #"{"protocol_version":1,"error":"no tag \"v9\" in krz/gitbay — push the tag first","exit_code":3}"#))
66 let model = ReleaseListViewModel(client: client, repoPath: "krz/gitbay")
67
68 let created = await model.create(tag: "v9", title: "", notes: "")
69
70 #expect(!created)
71 #expect(model.createError == #"no tag "v9" in krz/gitbay — push the tag first"#)
72 }
73
74 @Test func emptyTitleAndNotesAreOmittedFromArgv() async throws {
75 let (client, stub) = try makeClient()
76 stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"data":{},"exit_code":0}"#, match: "cmd"))
77 stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"exit_code":0}"#, match: "argv=list"))
78 let model = ReleaseListViewModel(client: client, repoPath: "krz/gitbay")
79
80 _ = await model.create(tag: "v1.1.0", title: "", notes: "")
81
82 let (argv, stdin) = try argvOf(try #require(stub.seen.first { $0.method == "POST" }))
83 #expect(argv == ["release", "create", "krz/gitbay", "v1.1.0"])
84 #expect(stdin == nil)
85 }
86}
87
88@MainActor
89struct ReleaseDetailViewModelTests {
90
91 @Test func loadsShowWithAssets() async throws {
92 let (client, stub) = try makeClient()
93 stub.enqueue(.init(status: 200, json: releaseShowJSON))
94 let model = ReleaseDetailViewModel(client: client, repoPath: "krz/gitbay", tag: "v1.0.0")
95
96 await model.load()
97
98 let release = try #require(model.state.value)
99 #expect(release.assets?.count == 1)
100 #expect(stub.seen.first?.url.query() ==
101 "argv=release&argv=show&argv=krz/gitbay&argv=v1.0.0")
102 }
103
104 @Test func editSendsTitleAndNotes() async throws {
105 let (client, stub) = try makeClient()
106 stub.enqueue(.init(status: 200, json: releaseShowJSON, match: "argv=show"))
107 let model = ReleaseDetailViewModel(client: client, repoPath: "krz/gitbay", tag: "v1.0.0")
108 await model.load()
109 stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"data":{},"exit_code":0}"#, match: "cmd"))
110 stub.enqueue(.init(status: 200, json: releaseShowJSON, match: "argv=show"))
111
112 await model.edit(title: "new title", notes: "new notes")
113
114 let (argv, stdin) = try argvOf(try #require(stub.seen.first { $0.method == "POST" }))
115 #expect(argv == ["release", "edit", "krz/gitbay", "v1.0.0",
116 "--title", "new title", "--file", "-"])
117 #expect(stdin == "new notes")
118 }
119
120 @Test func removeAssetSendsTheCommandAndReloads() async throws {
121 let (client, stub) = try makeClient()
122 stub.enqueue(.init(status: 200, json: releaseShowJSON, match: "argv=show"))
123 let model = ReleaseDetailViewModel(client: client, repoPath: "krz/gitbay", tag: "v1.0.0")
124 await model.load()
125 stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"data":{},"exit_code":0}"#, match: "cmd"))
126 stub.enqueue(.init(status: 200, json: releaseShowJSON, match: "argv=show"))
127
128 await model.removeAsset(named: "SHA256SUMS")
129
130 let (argv, _) = try argvOf(try #require(stub.seen.first { $0.method == "POST" }))
131 #expect(argv == ["release", "asset", "remove", "krz/gitbay", "v1.0.0", "SHA256SUMS"])
132 #expect(stub.seen.filter { $0.method == "GET" }.count == 2)
133 #expect(model.actionError == nil)
134 }
135
136 @Test func aRefusedAssetRemovalSurfaces() async throws {
137 let (client, stub) = try makeClient()
138 stub.enqueue(.init(status: 200, json: releaseShowJSON, match: "argv=show"))
139 let model = ReleaseDetailViewModel(client: client, repoPath: "krz/gitbay", tag: "v1.0.0")
140 await model.load()
141 stub.enqueue(.init(status: 403, json: #"{"protocol_version":1,"error":"write access required","exit_code":4}"#, match: "cmd"))
142
143 await model.removeAsset(named: "SHA256SUMS")
144
145 #expect(model.actionError == "write access required")
146 #expect(model.state.value?.assets?.count == 1)
147 }
148
149 @Test func downloadURLsUseTheWebRouteOnTheInstance() async throws {
150 let (client, _) = try makeClient()
151 let model = ReleaseDetailViewModel(client: client, repoPath: "krz/gitbay", tag: "v1.0.0")
152 let asset = Release.Asset(name: "gitbay-v1.0.0-linux-amd64", size: 1, sha256: "aa")
153
154 let url = model.downloadURL(for: asset)
155
156 #expect(url.absoluteString ==
157 "https://gitbay.org/krz/gitbay/releases/download/v1.0.0/gitbay-v1.0.0-linux-amd64")
158 }
159}
160
161struct FeedReleaseDestinationTests {
162
163 @Test func releaseEventsNavigateToTheRelease() throws {
164 let decoder = GitbayClient.decoder()
165 let event = try decoder.decode(FeedEvent.self, from: Data("""
166 {"id":2,"repo":"krz/gitbay","actor":"cmc","kind":"release.created",\
167 "data":{"tag":"v1.0.0"},"created_at":"2026-08-26T05:27:36Z"}
168 """.utf8))
169 #expect(event.destination == .release(repo: "krz/gitbay", tag: "v1.0.0"))
170 }
171}