gitbayTests/SearchTests.swift
213 lines · 7577 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 argvFrom(_ url: URL) -> [String] {
16 URLComponents(url: url, resolvingAgainstBaseURL: false)?
17 .queryItems?.filter { $0.name == "argv" }.compactMap(\.value) ?? []
18}
19
20private let mixedResults = """
21 {"protocol_version":1,"data":[\
22 {"kind":"repo","repo":"krz/gitbay","title":"krz/gitbay"},\
23 {"kind":"issue","repo":"krz/gitbay","number":168,"title":"a bug",\
24 "author":"cmc","state":"closed","updated_at":"2026-09-06T16:53:52.595Z"},\
25 {"kind":"mr","repo":"krz/gitbay","number":282,"title":"a change",\
26 "author":"cmc","state":"open","updated_at":"2026-09-06T15:17:13.062Z"}\
27 ],"exit_code":0}
28 """
29
30struct SearchResultDecodingTests {
31
32 /// Matches the established direct-decode pattern in
33 /// `DiscoveryTests.swift`: a plain decoder with `.iso8601` dates.
34 private func decode(_ json: String) throws -> [SearchResult] {
35 let decoder = JSONDecoder()
36 decoder.dateDecodingStrategy = .iso8601
37 return try decoder.decode([SearchResult].self, from: Data(json.utf8))
38 }
39
40 private let rows = """
41 [{"kind":"repo","repo":"krz/gitbay","title":"krz/gitbay"},\
42 {"kind":"issue","repo":"krz/gitbay","number":168,"title":"a bug",\
43 "author":"cmc","state":"closed","updated_at":"2026-09-06T16:53:52Z"},\
44 {"kind":"mr","repo":"krz/gitbay","number":282,"title":"a change",\
45 "author":"cmc","state":"open","updated_at":"2026-09-06T15:17:13Z"}]
46 """
47
48 @Test func decodesAllThreeKinds() throws {
49 let results = try decode(rows)
50 #expect(results.map(\.kind) == ["repo", "issue", "mr"])
51 #expect(results[1].number == 168)
52 #expect(results[1].author == "cmc")
53 #expect(results[1].state == "closed")
54 }
55
56 /// A repository result carries no number, author, state or time —
57 /// they are omitempty and absent, not null.
58 @Test func aRepositoryResultHasNoNumberOrAuthor() throws {
59 let repo = try #require(try decode(rows).first)
60 #expect(repo.number == nil)
61 #expect(repo.author == nil)
62 #expect(repo.state == nil)
63 #expect(repo.updatedAt == nil)
64 }
65
66 /// A repo row and an issue row in the same repository must not
67 /// collide, or SwiftUI reuses one for the other.
68 @Test func idsAreUniqueAcrossKinds() throws {
69 let ids = try decode(rows).map(\.id)
70 #expect(Set(ids).count == ids.count)
71 }
72}
73
74struct SearchKindTests {
75
76 @Test func allSendsNoFlag() {
77 #expect(SearchKind.all.flag == [])
78 }
79
80 @Test func theOtherThreeSendTheirWireSpelling() {
81 #expect(SearchKind.repo.flag == ["--kind", "repo"])
82 #expect(SearchKind.issue.flag == ["--kind", "issue"])
83 #expect(SearchKind.mr.flag == ["--kind", "mr"])
84 }
85
86 @Test func everyKindTheCommandAcceptsIsOffered() {
87 #expect(SearchKind.allCases.map(\.rawValue) == ["all", "repo", "issue", "mr"])
88 }
89}
90
91@MainActor
92struct SearchViewModelTests {
93
94 @Test func anEmptyQuerySearchesNothing() async throws {
95 let (client, stub) = try makeClient()
96 let model = SearchViewModel(client: client)
97
98 model.query = " "
99 try await Task.sleep(for: .milliseconds(500))
100
101 #expect(stub.seen.isEmpty)
102 #expect(model.isSearching == false)
103 }
104
105 @Test func aQueryReachesTheSearchCommand() async throws {
106 let (client, stub) = try makeClient()
107 stub.enqueue(.init(status: 200, json: mixedResults))
108 let model = SearchViewModel(client: client)
109
110 model.query = "parity"
111 try await Task.sleep(for: .milliseconds(600))
112
113 let read = try #require(stub.seen.last)
114 #expect(argvFrom(read.url) == ["search", "parity"])
115 #expect(model.state.value?.count == 3)
116 #expect(model.isSearching)
117 }
118
119 @Test func typingDebouncesIntoOneRequest() async throws {
120 let (client, stub) = try makeClient()
121 stub.enqueue(.init(status: 200, json: mixedResults))
122 let model = SearchViewModel(client: client)
123
124 for text in ["p", "pa", "par", "pari", "parity"] { model.query = text }
125 try await Task.sleep(for: .milliseconds(700))
126
127 #expect(stub.seen.count == 1)
128 #expect(argvFrom(try #require(stub.seen.last).url) == ["search", "parity"])
129 }
130
131 @Test func narrowingByKindAddsTheFlag() async throws {
132 let (client, stub) = try makeClient()
133 stub.enqueue(.init(status: 200, json: mixedResults))
134 let model = SearchViewModel(client: client)
135 model.query = "parity"
136 try await Task.sleep(for: .milliseconds(600))
137
138 stub.enqueue(.init(status: 200, json: mixedResults))
139 model.kind = .issue
140 await until { stub.seen.count == 2 }
141
142 #expect(argvFrom(try #require(stub.seen.last).url)
143 == ["search", "parity", "--kind", "issue"])
144 // A picker, not typing: two requests by now, not one.
145 #expect(stub.seen.count == 2)
146 }
147
148 /// Changing the kind with no query must not search.
149 @Test func kindAloneSearchesNothing() async throws {
150 let (client, stub) = try makeClient()
151 let model = SearchViewModel(client: client)
152
153 model.kind = .mr
154 try await Task.sleep(for: .milliseconds(400))
155
156 #expect(stub.seen.isEmpty)
157 }
158
159 /// The server quotes FTS5 terms itself, so an operator is a word.
160 @Test func theQueryIsPassedThroughUnquoted() async throws {
161 let (client, stub) = try makeClient()
162 stub.enqueue(.init(status: 200, json: mixedResults))
163 let model = SearchViewModel(client: client)
164
165 model.query = "c++"
166 try await Task.sleep(for: .milliseconds(600))
167
168 #expect(argvFrom(try #require(stub.seen.last).url) == ["search", "c++"])
169 }
170
171 @Test func nothingFoundIsAnEmptyStateNotAFailure() async throws {
172 let (client, stub) = try makeClient()
173 stub.enqueue(.init(status: 200, json: """
174 {"protocol_version":1,"data":[],"exit_code":0}
175 """))
176 let model = SearchViewModel(client: client)
177
178 model.query = "zzzznothing"
179 try await Task.sleep(for: .milliseconds(600))
180
181 guard case .empty = model.state else {
182 Testing.Issue.record("expected empty, got \(model.state)")
183 return
184 }
185 }
186
187 @Test func aServerFailureLandsInFailedNotEmpty() async throws {
188 let (client, stub) = try makeClient()
189 stub.enqueue(.init(status: 200, json: """
190 {"protocol_version":1,"error":"boom","exit_code":1}
191 """))
192 let model = SearchViewModel(client: client)
193
194 model.query = "parity"
195 try await Task.sleep(for: .milliseconds(600))
196
197 guard case .failed = model.state else {
198 Testing.Issue.record("expected failed, got \(model.state)")
199 return
200 }
201 }
202
203 @Test func theQueryIsTrimmedBeforeBeingSent() async throws {
204 let (client, stub) = try makeClient()
205 stub.enqueue(.init(status: 200, json: mixedResults))
206 let model = SearchViewModel(client: client)
207
208 model.query = " parity "
209 try await Task.sleep(for: .milliseconds(600))
210
211 #expect(argvFrom(try #require(stub.seen.last).url) == ["search", "parity"])
212 }
213}