gitbayTests/MRLifecycleTests.swift

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

247 lines · 9715 bytes

  1import Foundation
  2import Testing
  3@testable import gitbay
  4
  5private func decodeDetail(_ json: String) throws -> MRDetail {
  6    let decoder = JSONDecoder()
  7    decoder.dateDecodingStrategy = .iso8601
  8    return try decoder.decode(MRDetail.self, from: Data(json.utf8))
  9}
 10
 11private func decodeRow(_ json: String) throws -> MergeRequest {
 12    let decoder = JSONDecoder()
 13    decoder.dateDecodingStrategy = .iso8601
 14    return try decoder.decode(MergeRequest.self, from: Data(json.utf8))
 15}
 16
 17private let plainDetail = """
 18    {"number":7,"title":"a change","state":"open","author":"cmc",\
 19    "source":"feat","target_ref":"main","head_sha":"abc123",\
 20    "created_at":"2026-09-01T00:00:00Z"}
 21    """
 22
 23struct MRLifecycleDecodingTests {
 24
 25    /// Every one of these keys is omitempty. The ordinary merge request
 26    /// carries none of them, so absence must decode, not throw.
 27    @Test func anOrdinaryMergeRequestHasNoneOfTheNewKeys() throws {
 28        let mr = try decodeDetail(plainDetail)
 29        #expect(mr.draft == false)
 30        #expect(mr.reviewRequests.isEmpty)
 31        #expect(mr.stackedOn == nil)
 32        #expect(mr.stacked.isEmpty)
 33    }
 34
 35    @Test func aDraftDecodesAndStaysOpen() throws {
 36        let mr = try decodeDetail("""
 37            {"number":7,"title":"a change","state":"open","draft":true,"author":"cmc",\
 38            "source":"feat","target_ref":"main","head_sha":"abc123",\
 39            "created_at":"2026-09-01T00:00:00Z"}
 40            """)
 41        #expect(mr.draft)
 42        // Draft is a flag, not a fifth state.
 43        #expect(mr.state == "open")
 44        #expect(mr.isOpen)
 45    }
 46
 47    @Test func reviewRequestsDecode() throws {
 48        let mr = try decodeDetail("""
 49            {"number":7,"title":"a change","state":"open","author":"cmc",\
 50            "review_requests":["rae","sam"],\
 51            "source":"feat","target_ref":"main","head_sha":"abc123",\
 52            "created_at":"2026-09-01T00:00:00Z"}
 53            """)
 54        #expect(mr.reviewRequests == ["rae", "sam"])
 55    }
 56
 57    @Test func bothHalvesOfAStackDecode() throws {
 58        let mr = try decodeDetail("""
 59            {"number":7,"title":"a change","state":"open","author":"cmc",\
 60            "stacked_on":{"number":6,"title":"the one below"},\
 61            "stacked":[{"number":8,"title":"one above"},{"number":9,"title":"another"}],\
 62            "source":"feat","target_ref":"main","head_sha":"abc123",\
 63            "created_at":"2026-09-01T00:00:00Z"}
 64            """)
 65        #expect(mr.stackedOn?.number == 6)
 66        #expect(mr.stackedOn?.title == "the one below")
 67        #expect(mr.stacked.map(\.number) == [8, 9])
 68    }
 69
 70    @Test func aListRowCarriesDraftAndStackedOn() throws {
 71        let row = try decodeRow("""
 72            {"number":7,"title":"a change","state":"open","draft":true,"author":"cmc",\
 73            "stacked_on":{"number":6,"title":"below"},\
 74            "source":"feat","target_ref":"main","head_sha":"abc123",\
 75            "created_at":"2026-09-01T00:00:00Z"}
 76            """)
 77        #expect(row.draft)
 78        #expect(row.stackedOn?.number == 6)
 79    }
 80
 81    @Test func aPlainListRowHasNeither() throws {
 82        let row = try decodeRow("""
 83            {"number":7,"title":"a change","state":"open","author":"cmc",\
 84            "source":"feat","target_ref":"main","head_sha":"abc123",\
 85            "created_at":"2026-09-01T00:00:00Z"}
 86            """)
 87        #expect(row.draft == false)
 88        #expect(row.stackedOn == nil)
 89    }
 90}
 91
 92@MainActor
 93struct MRLifecycleActionTests {
 94
 95    private func loaded() async throws -> (MRDetailViewModel, StubProtocol.Box) {
 96        let box = StubProtocol.box()
 97        let client = GitbayClient(
 98            instance: try GitbayInstance(url: "https://gitbay.org"),
 99            token: "test-token",
100            session: box.session()
101        )
102        box.enqueue(.init(status: 200, json: """
103            {"protocol_version":1,"data":\(plainDetail),"exit_code":0}
104            """))
105        let model = MRDetailViewModel(client: client, repoPath: "krz/gitbay", number: 7)
106        await model.load()
107        return (model, box)
108    }
109
110    private func argvOf(_ seen: StubProtocol.Seen) throws -> [String] {
111        let body = try #require(try JSONSerialization.jsonObject(with: seen.body) as? [String: Any])
112        return try #require(body["argv"] as? [String])
113    }
114
115    private func ok(_ box: StubProtocol.Box) {
116        box.enqueue(.init(status: 200, json: """
117            {"protocol_version":1,"exit_code":0}
118            """))
119        box.enqueue(.init(status: 200, json: """
120            {"protocol_version":1,"data":\(plainDetail),"exit_code":0}
121            """))
122    }
123
124    /// `load()` issues three GETs: `mr show`, `mr diff`, `mr threads`.
125    /// `loaded()` runs it once (3 GETs, the last two unstubbed and failed),
126    /// so a reload after a write brings the total to 6.
127    private func gets(_ box: StubProtocol.Box) -> Int {
128        box.seen.count { $0.method == "GET" }
129    }
130
131    @Test func markingDraftAndReadyAreDifferentCommands() async throws {
132        let (model, box) = try await loaded()
133        ok(box)
134        await model.setDraft(true)
135        var write = try #require(box.seen.first { $0.method == "POST" })
136        #expect(try argvOf(write) == ["mr", "draft", "krz/gitbay", "7"])
137        #expect(gets(box) == 6)
138
139        let (model2, box2) = try await loaded()
140        ok(box2)
141        await model2.setDraft(false)
142        write = try #require(box2.seen.first { $0.method == "POST" })
143        #expect(try argvOf(write) == ["mr", "ready", "krz/gitbay", "7"])
144        #expect(gets(box2) == 6)
145    }
146
147    @Test func retargetPassesTheBranchPositionally() async throws {
148        let (model, box) = try await loaded()
149        ok(box)
150        await model.retarget(to: "release")
151        let write = try #require(box.seen.first { $0.method == "POST" })
152        #expect(try argvOf(write) == ["mr", "retarget", "krz/gitbay", "7", "release"])
153        #expect(gets(box) == 6)
154    }
155
156    /// `mr review request` is a THREE-word path. Two words posts a
157    /// review instead — silently, with a 200.
158    @Test func requestingAReviewUsesTheThreeWordPath() async throws {
159        let (model, box) = try await loaded()
160        ok(box)
161        await model.requestReview(from: "rae")
162        let write = try #require(box.seen.first { $0.method == "POST" })
163        #expect(try argvOf(write)
164            == ["mr", "review", "request", "krz/gitbay", "7", "--add", "rae"])
165        #expect(gets(box) == 6)
166    }
167
168    @Test func removingAReviewRequestUsesRemoveNotAdd() async throws {
169        let (model, box) = try await loaded()
170        ok(box)
171        await model.removeReviewRequest("rae")
172        let write = try #require(box.seen.first { $0.method == "POST" })
173        #expect(try argvOf(write)
174            == ["mr", "review", "request", "krz/gitbay", "7", "--remove", "rae"])
175        #expect(gets(box) == 6)
176    }
177
178    @Test func branchesComeFromRepoRefs() async throws {
179        let (model, box) = try await loaded()
180        box.enqueue(.init(status: 200, json: """
181            {"protocol_version":1,"data":{"branches":[{"name":"main","sha":"a"},\
182            {"name":"release","sha":"b"}],"tags":[]},"exit_code":0}
183            """))
184        await model.loadBranches()
185        #expect(model.branches == ["main", "release"])
186    }
187
188    @Test func aRefusalSurfacesAndDoesNotReload() async throws {
189        let (model, box) = try await loaded()
190        box.enqueue(.init(status: 200, json: """
191            {"protocol_version":1,"error":"cannot retarget a merged merge request",\
192            "exit_code":1}
193            """))
194        await model.retarget(to: "release")
195        #expect(model.actionError?.isEmpty == false)
196        #expect(model.working == false)
197        // A refusal throws before `perform` reaches `load()`: still the
198        // 3 GETs from `loaded()`, none from a reload.
199        #expect(gets(box) == 3)
200    }
201}
202
203@MainActor
204struct MRCreateDraftTests {
205
206    @Test func creatingAsADraftAppendsTheFlag() async throws {
207        let box = StubProtocol.box()
208        let client = GitbayClient(
209            instance: try GitbayInstance(url: "https://gitbay.org"),
210            token: "test-token",
211            session: box.session()
212        )
213        box.enqueue(.init(status: 200, json: """
214            {"protocol_version":1,"data":{"number":7},"exit_code":0}
215            """))
216        let model = MRCreateViewModel(client: client, repoPath: "krz/gitbay")
217        model.draft = true
218        _ = await model.create(source: "feat", target: "main", title: "a change", body: "")
219
220        let write = try #require(box.seen.first { $0.method == "POST" })
221        let body = try #require(
222            try JSONSerialization.jsonObject(with: write.body) as? [String: Any]
223        )
224        let argv = try #require(body["argv"] as? [String])
225        #expect(argv.contains("--draft"))
226    }
227
228    @Test func creatingWithoutDraftOmitsTheFlag() async throws {
229        let box = StubProtocol.box()
230        let client = GitbayClient(
231            instance: try GitbayInstance(url: "https://gitbay.org"),
232            token: "test-token",
233            session: box.session()
234        )
235        box.enqueue(.init(status: 200, json: """
236            {"protocol_version":1,"data":{"number":7},"exit_code":0}
237            """))
238        let model = MRCreateViewModel(client: client, repoPath: "krz/gitbay")
239        _ = await model.create(source: "feat", target: "main", title: "a change", body: "")
240
241        let write = try #require(box.seen.first { $0.method == "POST" })
242        let body = try #require(
243            try JSONSerialization.jsonObject(with: write.body) as? [String: Any]
244        )
245        #expect((body["argv"] as? [String])?.contains("--draft") == false)
246    }
247}