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