~ knotless Search/
↑↓ move↵ open⇧↵ all results

grain.social/grain-ios

1 collaborator
0
1
main
239 lines · 8.3 KB Raw History
@testable import Grain
import XCTest

/// Search and discovery both swallow their errors so the tab degrades to an
/// empty state rather than an alert. That makes the failure paths worth pinning
/// down explicitly — a regression there is silent by design.
@MainActor
final class SearchViewModelTests: GrainTestCase {
    private var client: XRPCClient!
    private var vm: SearchViewModel!

    override func setUp() async throws {
        try await super.setUp()
        client = XRPCClient(baseURL: URL(string: "https://test.local")!, session: MockURLProtocol.mockSession())
        vm = SearchViewModel(client: client)
    }

    override func tearDown() async throws {
        MockURLProtocol.handler = nil
        try await super.tearDown()
    }

    private static let galleryResults = """
    {
      "items": [{
        "uri": "at://did:plc:a/social.grain.gallery/1",
        "cid": "bafyg",
        "title": "Found gallery",
        "creator": {"cid": "c1", "did": "did:plc:a", "handle": "alice.test"},
        "indexedAt": "2025-01-02T00:00:00Z"
      }],
      "cursor": null
    }
    """

    private static let profileResults = """
    {
      "items": [
        {"did": "did:plc:a", "handle": "alice.test", "displayName": "Alice"},
        {"did": "did:plc:b", "handle": "bob.test"}
      ],
      "cursor": null
    }
    """

    // MARK: - Tabs

    func testItStartsOnGalleriesWithBothTabsAvailable() {
        XCTAssertEqual(vm.selectedTab, .galleries)
        XCTAssertEqual(SearchViewModel.SearchTab.allCases, [.galleries, .profiles])
        XCTAssertEqual(SearchViewModel.SearchTab.galleries.rawValue, "Galleries")
        XCTAssertEqual(SearchViewModel.SearchTab.profiles.rawValue, "Profiles")
    }

    // MARK: - search

    func testSearchingOnTheGalleriesTabFillsGalleryResults() async {
        MockURLProtocol.respondWithJSON(Self.galleryResults)
        vm.searchText = "film"

        await vm.search()

        XCTAssertEqual(vm.galleryResults.map(\.title), ["Found gallery"])
        XCTAssertTrue(vm.profileResults.isEmpty, "The galleries tab shouldn't populate the profiles tab")
        XCTAssertFalse(vm.isSearching)
    }

    func testSearchingOnTheProfilesTabFillsProfileResults() async {
        MockURLProtocol.respondWithJSON(Self.profileResults)
        vm.selectedTab = .profiles
        vm.searchText = "ali"

        await vm.search()

        XCTAssertEqual(vm.profileResults.map(\.did), ["did:plc:a", "did:plc:b"])
        XCTAssertTrue(vm.galleryResults.isEmpty)
        XCTAssertFalse(vm.isSearching)
    }

    /// Whitespace is what a user leaves behind after clearing the field, and it
    /// must not cost a request.
    func testAnEmptyOrBlankQueryNeverHitsTheNetwork() async {
        var requestMade = false
        MockURLProtocol.handler = { request in
            requestMade = true
            return (Data("{}".utf8), HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!)
        }

        vm.searchText = ""
        await vm.search()
        vm.searchText = "   \n "
        await vm.search()

        XCTAssertFalse(requestMade)
        XCTAssertFalse(vm.isSearching, "Bailing early must not leave the spinner up")
    }

    func testAFailedSearchLeavesNoResultsAndStopsTheSpinner() async {
        MockURLProtocol.respondWithError(statusCode: 500)
        vm.searchText = "film"

        await vm.search()

        XCTAssertTrue(vm.galleryResults.isEmpty)
        XCTAssertFalse(vm.isSearching)
    }

    /// A search that comes back with nothing has to clear the previous results,
    /// or the tab keeps showing matches for a query that is no longer on screen.
    func testASearchWithNoMatchesClearsThePreviousResults() async {
        MockURLProtocol.respondWithJSON(Self.galleryResults)
        vm.searchText = "film"
        await vm.search()
        XCTAssertEqual(vm.galleryResults.count, 1)

        MockURLProtocol.respondWithJSON(#"{"items": [], "cursor": null}"#)
        vm.searchText = "nothing matches this"
        await vm.search()

        XCTAssertTrue(vm.galleryResults.isEmpty)
    }

    /// A response that omits `items` entirely is not the same as one that sends
    /// an empty array, and both have to end up as no results.
    func testAResponseWithNoItemsKeyIsTreatedAsNoResults() async {
        MockURLProtocol.respondWithJSON("{}")
        vm.searchText = "film"

        await vm.search()

        XCTAssertTrue(vm.galleryResults.isEmpty)
    }

    // MARK: - As you type

    func testASearchThatAnswersMarksItselfSearchedAndClearingUnmarksIt() async {
        MockURLProtocol.respondWithJSON(Self.galleryResults)
        vm.searchText = "film"
        XCTAssertFalse(vm.hasSearched)

        await vm.search()

        XCTAssertTrue(vm.hasSearched)
        XCTAssertEqual(vm.galleryResults.count, 1)

        vm.clearResults()

        XCTAssertFalse(vm.hasSearched)
        XCTAssertFalse(vm.isSearching)
        XCTAssertTrue(vm.galleryResults.isEmpty)
    }

    /// Two searches in flight, the first answering last: the field has moved
    /// on, and the slow answer must not overwrite the one that matches it.
    func testAnOvertakenSearchDoesNotOverwriteTheNewerAnswer() async {
        let calls = CallCounter()
        MockURLProtocol.handler = { request in
            let call = calls.next()
            if call == 1 {
                // The first request is slow; the second overtakes it.
                Thread.sleep(forTimeInterval: 0.3)
            }
            let body = call == 1 ? Self.galleryResults : Self.galleryResults.replacingOccurrences(of: "Found gallery", with: "Newer gallery")
            return (Data(body.utf8), HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!)
        }

        vm.searchText = "fil"
        let slow = Task { await vm.search() }
        try? await Task.sleep(for: .milliseconds(50))
        vm.searchText = "film"
        await vm.search()
        await slow.value

        XCTAssertEqual(vm.galleryResults.map(\.title), ["Newer gallery"])
        XCTAssertFalse(vm.isSearching)
    }

    // MARK: - loadDiscovery

    func testDiscoveryLoadsLocationsAndCameras() async {
        MockURLProtocol.handler = { request in
            let body = request.url!.path.contains("getLocations")
                ? #"{"locations": [{"name": "Lisboa", "h3Index": "8a2a", "galleryCount": 12}]}"#
                : #"{"cameras": [{"camera": "Fujifilm X100V", "photoCount": 40}]}"#
            let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!
            return (Data(body.utf8), response)
        }

        await vm.loadDiscovery()

        XCTAssertEqual(vm.locations.map(\.name), ["Lisboa"])
        XCTAssertEqual(vm.locations.first?.id, "8a2a", "Locations are identified by their H3 index")
        XCTAssertEqual(vm.cameras.map(\.camera), ["Fujifilm X100V"])
        XCTAssertEqual(vm.cameras.first?.id, "Fujifilm X100V")
    }

    /// Discovery is decoration on the search tab; failing it must leave the tab
    /// usable rather than half-populated.
    func testAFailedDiscoveryLeavesBothListsEmpty() async {
        MockURLProtocol.respondWithError(statusCode: 500)

        await vm.loadDiscovery()

        XCTAssertTrue(vm.locations.isEmpty)
        XCTAssertTrue(vm.cameras.isEmpty)
    }

    /// Cameras are fetched after locations, so a failure on the second call
    /// must not silently keep the first call's results either — they're set
    /// together or not at all.
    func testCamerasFailingLeavesLocationsUnset() async {
        MockURLProtocol.handler = { request in
            guard request.url!.path.contains("getLocations") else {
                throw URLError(.timedOut)
            }
            let body = #"{"locations": [{"name": "Lisboa", "h3Index": "8a2a", "galleryCount": 12}]}"#
            let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!
            return (Data(body.utf8), response)
        }

        await vm.loadDiscovery()

        XCTAssertTrue(vm.locations.isEmpty)
        XCTAssertTrue(vm.cameras.isEmpty)
    }
}

/// Counts the mock's calls from the URL loading thread.
private final class CallCounter: @unchecked Sendable {
    private let lock = NSLock()
    private var count = 0

    func next() -> Int {
        lock.withLock {
            count += 1
            return count
        }
    }
}