gitbayTests/SearchTests.swift

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

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}