gitbayTests/SavedQueryTests.swift
153 lines · 6724 bytes
15 symbols in this file
makeClientpostedargvSavedQueriesViewModelTestsloadedlistsSavedQueriessavingSendsTheNameAndTheQueryAsOneWordreplacingAddsForceaRefusedSaveKeepsTheSheetAndSaysWhyaQueryTheServerCannotParseShowsItsMessagepinUnpinAndRemoveNameTheQueryQueryResultsTestsresultsPageThroughQueryRunDashboardQueryTestspinnedQueriesDecodeWithTheirRowsAndCount
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 posted(_ stub: StubProtocol.Box) throws -> [[String]] {
16 try stub.seen.filter { $0.method == "POST" }.map { seen in
17 let body = try #require(try JSONSerialization.jsonObject(with: seen.body) as? [String: Any])
18 return try #require(body["argv"] as? [String])
19 }
20}
21
22private func argv(_ url: URL) -> [String] {
23 URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems?.compactMap(\.value) ?? []
24}
25
26private let listJSON = """
27 {"protocol_version":1,"data":[{"name":"mine","query":"is:open author:@me","pinned":true},\
28 {"name":"bugs","query":"is:issue label:bug","pinned":false}],"exit_code":0}
29 """
30
31private let ok = #"{"protocol_version":1,"data":{},"exit_code":0}"#
32
33@MainActor
34struct SavedQueriesViewModelTests {
35
36 private func loaded() async throws -> (SavedQueriesViewModel, StubProtocol.Box) {
37 let (client, stub) = try makeClient()
38 stub.enqueue(.init(status: 200, json: listJSON, match: "argv=list"))
39 let model = SavedQueriesViewModel(client: client)
40 await model.load()
41 return (model, stub)
42 }
43
44 @Test func listsSavedQueries() async throws {
45 let (model, stub) = try await loaded()
46 #expect(model.state.value?.map(\.name) == ["mine", "bugs"])
47 #expect(model.state.value?.first?.pinned == true)
48 #expect(argv(try #require(stub.seen.first).url) == ["query", "list"])
49 }
50
51 @Test func savingSendsTheNameAndTheQueryAsOneWord() async throws {
52 let (model, stub) = try await loaded()
53 stub.enqueue(.init(status: 200, json: ok, match: "cmd"))
54 stub.enqueue(.init(status: 200, json: listJSON, match: "argv=list"))
55
56 let saved = await model.save(name: " review ", query: #" is:mr label:"needs review" "#, replace: false)
57
58 #expect(saved)
59 #expect(try posted(stub) == [["query", "save", "--", "review", #"is:mr label:"needs review""#]])
60 }
61
62 @Test func replacingAddsForce() async throws {
63 let (model, stub) = try await loaded()
64 stub.enqueue(.init(status: 200, json: ok, match: "cmd"))
65 stub.enqueue(.init(status: 200, json: listJSON, match: "argv=list"))
66
67 _ = await model.save(name: "bugs", query: "is:issue label:bug is:open", replace: true)
68
69 #expect(try posted(stub) == [["query", "save", "--force", "--", "bugs", "is:issue label:bug is:open"]])
70 }
71
72 @Test func aRefusedSaveKeepsTheSheetAndSaysWhy() async throws {
73 let (model, stub) = try await loaded()
74 // A failure is retried once before it surfaces.
75 for _ in 0..<2 {
76 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"))
77 }
78
79 let saved = await model.save(name: "bugs", query: "x", replace: false)
80
81 #expect(!saved)
82 #expect(model.actionError == "you already have a query named bugs; pass --force to replace it")
83 }
84
85 /// The query is typed, so a syntax refusal says what to fix rather
86 /// than reading as the app's own mistake.
87 @Test func aQueryTheServerCannotParseShowsItsMessage() async throws {
88 let (model, stub) = try await loaded()
89 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"))
90
91 #expect(!(await model.save(name: "x", query: "colour:red", replace: false)))
92 #expect(model.actionError?.hasPrefix("unknown qualifier") == true)
93 }
94
95 @Test func pinUnpinAndRemoveNameTheQuery() async throws {
96 let (model, stub) = try await loaded()
97 for _ in 0..<3 {
98 stub.enqueue(.init(status: 200, json: ok, match: "cmd"))
99 stub.enqueue(.init(status: 200, json: listJSON, match: "argv=list"))
100 }
101 let queries = try #require(model.state.value)
102
103 await model.setPinned(queries[1], true)
104 await model.setPinned(queries[0], false)
105 await model.remove(queries[1])
106
107 #expect(try posted(stub) == [["query", "pin", "bugs"], ["query", "unpin", "mine"], ["query", "remove", "bugs"]])
108 }
109}
110
111@MainActor
112struct QueryResultsTests {
113
114 @Test func resultsPageThroughQueryRun() async throws {
115 let (client, stub) = try makeClient()
116 stub.enqueue(.init(status: 200, json: """
117 {"protocol_version":1,"data":{"items":[\
118 {"kind":"mr","repo":"krz/gitbay","number":12,"title":"Add it","state":"open","draft":true,\
119 "author":"cmc","created_at":"2026-08-20T10:00:00.000Z","updated_at":"2026-08-21T10:00:00.000Z"},\
120 {"kind":"issue","repo":"krz/app","number":3,"title":"Bug","state":"closed",\
121 "author":"dee","milestone":"m1","created_at":"2026-08-20T10:00:00.000Z","updated_at":"2026-08-21T10:00:00.000Z"}],\
122 "next":"c1"},"exit_code":0}
123 """))
124 let model = QueryResultsViewModel(client: client, name: "mine")
125
126 await model.load()
127
128 #expect(argv(try #require(stub.seen.first).url) == ["query", "run", "mine", "--limit", "50"])
129 let items = try #require(model.list.state.value)
130 #expect(items.map(\.ref) == ["krz/gitbay!12", "krz/app#3"])
131 #expect(items.first?.draft == true)
132 #expect(model.list.hasMore)
133 }
134}
135
136struct DashboardQueryTests {
137
138 @Test func pinnedQueriesDecodeWithTheirRowsAndCount() throws {
139 let json = """
140 {"queries":[{"name":"mine","query":"is:open author:@me","count":7,"items":[\
141 {"kind":"issue","repo":"krz/app","number":3,"title":"Bug","state":"open",\
142 "author":"dee","created_at":"2026-08-20T10:00:00.000Z","updated_at":"2026-08-21T10:00:00.000Z"}]},\
143 {"name":"broken","query":"label:","count":0,"items":[],"error":"label: needs a value"}]}
144 """
145 let data = try GitbayClient.decoder().decode(DashboardData.self, from: Data(json.utf8))
146 #expect(data.queries.map(\.name) == ["mine", "broken"])
147 #expect(data.queries.first?.count == 7)
148 #expect(data.queries.last?.error == "label: needs a value")
149 // A server from before saved queries sends none.
150 let old = try GitbayClient.decoder().decode(DashboardData.self, from: Data("{}".utf8))
151 #expect(old.queries.isEmpty)
152 }
153}