gitbayTests/SearchTests.swift

v1.11.0
gitbay-ios/gitbayTests/SearchTests.swift history · blame · raw

211 lines · 7489 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    /// 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}