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 posted(_ stub: StubProtocol.Box) throws -> [[String]] { try stub.seen.filter { $0.method == "POST" }.map { seen in let body = try #require(try JSONSerialization.jsonObject(with: seen.body) as? [String: Any]) return try #require(body["argv"] as? [String]) } } private func argv(_ url: URL) -> [String] { URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems?.compactMap(\.value) ?? [] } private let listJSON = """ {"protocol_version":1,"data":[{"name":"mine","query":"is:open author:@me","pinned":true},\ {"name":"bugs","query":"is:issue label:bug","pinned":false}],"exit_code":0} """ private let ok = #"{"protocol_version":1,"data":{},"exit_code":0}"# @MainActor struct SavedQueriesViewModelTests { private func loaded() async throws -> (SavedQueriesViewModel, StubProtocol.Box) { let (client, stub) = try makeClient() stub.enqueue(.init(status: 200, json: listJSON, match: "argv=list")) let model = SavedQueriesViewModel(client: client) await model.load() return (model, stub) } @Test func listsSavedQueries() async throws { let (model, stub) = try await loaded() #expect(model.state.value?.map(\.name) == ["mine", "bugs"]) #expect(model.state.value?.first?.pinned == true) #expect(argv(try #require(stub.seen.first).url) == ["query", "list"]) } @Test func savingSendsTheNameAndTheQueryAsOneWord() async throws { let (model, stub) = try await loaded() stub.enqueue(.init(status: 200, json: ok, match: "cmd")) stub.enqueue(.init(status: 200, json: listJSON, match: "argv=list")) let saved = await model.save(name: " review ", query: #" is:mr label:"needs review" "#, replace: false) #expect(saved) #expect(try posted(stub) == [["query", "save", "--", "review", #"is:mr label:"needs review""#]]) } @Test func replacingAddsForce() async throws { let (model, stub) = try await loaded() stub.enqueue(.init(status: 200, json: ok, match: "cmd")) stub.enqueue(.init(status: 200, json: listJSON, match: "argv=list")) _ = await model.save(name: "bugs", query: "is:issue label:bug is:open", replace: true) #expect(try posted(stub) == [["query", "save", "--force", "--", "bugs", "is:issue label:bug is:open"]]) } @Test func aRefusedSaveKeepsTheSheetAndSaysWhy() async throws { let (model, stub) = try await loaded() // A failure is retried once before it surfaces. for _ in 0..<2 { stub.enqueue(.init(status: 500, json: #"{"protocol_version":1,"error":"you already have a query named bugs; pass --force to replace it","exit_code":1}"#, match: "cmd")) } let saved = await model.save(name: "bugs", query: "x", replace: false) #expect(!saved) #expect(model.actionError == "you already have a query named bugs; pass --force to replace it") } /// The query is typed, so a syntax refusal says what to fix rather /// than reading as the app's own mistake. @Test func aQueryTheServerCannotParseShowsItsMessage() async throws { let (model, stub) = try await loaded() stub.enqueue(.init(status: 400, json: #"{"protocol_version":1,"error":"unknown qualifier; the qualifiers are repo:, owner:, is:, label:, no:, milestone:, assignee: and author:","exit_code":2}"#, match: "cmd")) #expect(!(await model.save(name: "x", query: "colour:red", replace: false))) #expect(model.actionError?.hasPrefix("unknown qualifier") == true) } @Test func pinUnpinAndRemoveNameTheQuery() async throws { let (model, stub) = try await loaded() for _ in 0..<3 { stub.enqueue(.init(status: 200, json: ok, match: "cmd")) stub.enqueue(.init(status: 200, json: listJSON, match: "argv=list")) } let queries = try #require(model.state.value) await model.setPinned(queries[1], true) await model.setPinned(queries[0], false) await model.remove(queries[1]) #expect(try posted(stub) == [["query", "pin", "bugs"], ["query", "unpin", "mine"], ["query", "remove", "bugs"]]) } } @MainActor struct QueryResultsTests { @Test func resultsPageThroughQueryRun() async throws { let (client, stub) = try makeClient() stub.enqueue(.init(status: 200, json: """ {"protocol_version":1,"data":{"items":[\ {"kind":"mr","repo":"krz/gitbay","number":12,"title":"Add it","state":"open","draft":true,\ "author":"cmc","created_at":"2026-08-20T10:00:00.000Z","updated_at":"2026-08-21T10:00:00.000Z"},\ {"kind":"issue","repo":"krz/app","number":3,"title":"Bug","state":"closed",\ "author":"dee","milestone":"m1","created_at":"2026-08-20T10:00:00.000Z","updated_at":"2026-08-21T10:00:00.000Z"}],\ "next":"c1"},"exit_code":0} """)) let model = QueryResultsViewModel(client: client, name: "mine") await model.load() #expect(argv(try #require(stub.seen.first).url) == ["query", "run", "mine", "--limit", "50"]) let items = try #require(model.list.state.value) #expect(items.map(\.ref) == ["krz/gitbay!12", "krz/app#3"]) #expect(items.first?.draft == true) #expect(model.list.hasMore) } } struct DashboardQueryTests { @Test func pinnedQueriesDecodeWithTheirRowsAndCount() throws { let json = """ {"queries":[{"name":"mine","query":"is:open author:@me","count":7,"items":[\ {"kind":"issue","repo":"krz/app","number":3,"title":"Bug","state":"open",\ "author":"dee","created_at":"2026-08-20T10:00:00.000Z","updated_at":"2026-08-21T10:00:00.000Z"}]},\ {"name":"broken","query":"label:","count":0,"items":[],"error":"label: needs a value"}]} """ let data = try GitbayClient.decoder().decode(DashboardData.self, from: Data(json.utf8)) #expect(data.queries.map(\.name) == ["mine", "broken"]) #expect(data.queries.first?.count == 7) #expect(data.queries.last?.error == "label: needs a value") // A server from before saved queries sends none. let old = try GitbayClient.decoder().decode(DashboardData.self, from: Data("{}".utf8)) #expect(old.queries.isEmpty) } }