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 buildRow(_ number: Int, subject: String? = nil) -> String { let subjectField = subject.map { #","subject":"\#($0)""# } ?? "" return """ {"number":\(number),"job":"test","status":"success",\ "sha":"db7503f06f11c31a41f1c8e91187e1ff8f861c7f","ref":"main",\ "created_at":"2026-09-22T04:40:16.000Z"\(subjectField)} """ } struct BuildSubjectTests { private func decode(_ json: String) throws -> Build { try GitbayClient.decoder().decode(Build.self, from: Data(json.utf8)) } @Test func aRowLeadsWithTheCommitSubject() throws { let build = try decode(buildRow(1479, subject: "CHANGELOG: v1.34.1")) #expect(build.subject == "CHANGELOG: v1.34.1") #expect(build.title == "#1479 CHANGELOG: v1.34.1") } /// A build whose commit is gone has no subject to report; the sha is /// all there is. @Test func aRowWithoutASubjectFallsBackToTheSha() throws { let build = try decode(buildRow(12)) #expect(build.subject == nil) #expect(build.title == "#12 db7503f06f") } @Test func anEmptySubjectAlsoFallsBack() throws { let build = try decode(buildRow(12, subject: "")) #expect(build.title == "#12 db7503f06f") } } private func page(_ rows: [String], next: String? = nil) -> String { let nextField = next.map { #","next":"\#($0)""# } ?? "" return #"{"protocol_version":1,"data":{"items":["# + rows.joined(separator: ",") + "]" + nextField + #"},"exit_code":0}"# } @MainActor struct BuildListPagingTests { /// The web asks for thirty; so does the app. @Test func asksForThirtyAndFollowsTheCursor() async throws { let (client, stub) = try makeClient() stub.enqueue(.init(status: 200, json: page([buildRow(5), buildRow(4)], next: "YnVpbGQ6NA"), match: "argv=build&argv=list")) let model = BuildListViewModel(client: client, repoPath: "krz/gitbay") await model.load() #expect(try #require(model.state.value).map(\.number) == [5, 4]) #expect(model.list.hasMore) let first = try #require(stub.seen.first) #expect(first.url.query() == "argv=build&argv=list&argv=krz/gitbay&argv=--limit&argv=30") stub.enqueue(.init(status: 200, json: page([buildRow(3)]), match: "argv=build&argv=list")) await model.list.loadMore() #expect(try #require(model.state.value).map(\.number) == [5, 4, 3]) #expect(model.list.hasMore == false) let second = try #require(stub.seen.last) #expect(second.url.query()?.hasSuffix("argv=--limit&argv=30&argv=--cursor&argv=YnVpbGQ6NA") == true) } /// Rows are shown in the order the server sent them. A client sort /// would reorder rows across a page boundary. @Test func keepsTheServersOrder() async throws { let (client, stub) = try makeClient() stub.enqueue(.init(status: 200, json: page([buildRow(3), buildRow(4)]))) let model = BuildListViewModel(client: client, repoPath: "krz/gitbay") await model.load() #expect(try #require(model.state.value).map(\.number) == [3, 4]) } @Test func aFilterChangeStartsOverFromTheFirstPage() async throws { let (client, stub) = try makeClient() stub.enqueue(.init(status: 200, json: page([buildRow(5)], next: "YnVpbGQ6NQ"))) let model = BuildListViewModel(client: client, repoPath: "krz/gitbay") await model.load() stub.enqueue(.init(status: 200, json: page([]))) var filter = BuildFilter() filter.status = .failure model.filter = filter await until { if case .empty = model.state { return true } return false } let second = try #require(stub.seen.last) #expect(second.url.query() == "argv=build&argv=list&argv=krz/gitbay&argv=--status&argv=failure&argv=--limit&argv=30") #expect(model.list.hasMore == false) } }