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

grain.social/grain-ios

1 collaborator
0
1
main
284 lines · 10.6 KB Raw History
@testable import Grain
import XCTest

/// File scope, not a static member: `MockURLProtocol.handler` runs outside the
/// main actor, so anything it reads has to live outside a @MainActor class.
private func feedBody(ids: [Int], cursor: String?) -> String {
    let items = ids.map { id in
        """
        {
          "uri": "at://did:plc:a/social.grain.gallery/\(id)",
          "cid": "bafyg\(id)",
          "title": "Gallery \(id)",
          "creator": {"cid": "c1", "did": "did:plc:a", "handle": "alice.test"},
          "indexedAt": "2025-01-02T00:00:00Z"
        }
        """
    }.joined(separator: ",")
    let cursorJSON = cursor.map { "\"\($0)\"" } ?? "null"
    return "{\"items\": [\(items)], \"cursor\": \(cursorJSON)}"
}

/// The feed is the first screen and its view model has to survive a cold cache,
/// a failed fetch, and a tab switch mid-request. The cursor and the disk cache
/// are where it goes wrong quietly: a stale cursor re-appends a page, and a
/// cache key collision shows one account another's galleries.
@MainActor
final class FeedViewModelTests: GrainTestCase {
    private var client: XRPCClient!

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

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

    private func makeViewModel(feedName: String = "recent", cacheKey: String? = nil) -> FeedViewModel {
        FeedViewModel(client: client, feedName: feedName, cacheKey: cacheKey)
    }

    // MARK: - loadInitial

    func testLoadingFillsTheFeed() async {
        MockURLProtocol.respondWithJSON(feedBody(ids: [1, 2], cursor: nil))
        let vm = makeViewModel()

        await vm.loadInitial()

        XCTAssertEqual(vm.galleries.map(\.title), ["Gallery 1", "Gallery 2"])
        XCTAssertFalse(vm.isLoading)
        XCTAssertNil(vm.error)
        XCTAssertTrue(vm.hasFetchedInitial)
    }

    /// The flag drives "we've heard from the server at least once", so a
    /// failure has to set it too or the screen retries forever.
    func testAFailedLoadStillCountsAsHavingFetched() async {
        MockURLProtocol.respondWithError(statusCode: 500)
        let vm = makeViewModel()

        await vm.loadInitial()

        XCTAssertTrue(vm.hasFetchedInitial)
        XCTAssertNotNil(vm.error)
        XCTAssertFalse(vm.isLoading)
        XCTAssertTrue(vm.galleries.isEmpty)
    }

    func testASuccessfulReloadClearsAPreviousError() async {
        MockURLProtocol.respondWithError(statusCode: 500)
        let vm = makeViewModel()
        await vm.loadInitial()
        XCTAssertNotNil(vm.error)

        MockURLProtocol.respondWithJSON(feedBody(ids: [1], cursor: nil))
        await vm.loadInitial()

        XCTAssertNil(vm.error)
        XCTAssertEqual(vm.galleries.count, 1)
    }

    func testReloadingReplacesRatherThanAppends() async {
        MockURLProtocol.respondWithJSON(feedBody(ids: [1, 2], cursor: nil))
        let vm = makeViewModel()
        await vm.loadInitial()

        MockURLProtocol.respondWithJSON(feedBody(ids: [3], cursor: nil))
        await vm.loadInitial()

        XCTAssertEqual(vm.galleries.map(\.title), ["Gallery 3"])
    }

    /// Pull-to-refresh after paging has to forget where it had got to, or the
    /// next page continues from a cursor into the old result set.
    func testAReloadForgetsThePagingCursor() async {
        MockURLProtocol.respondWithJSON(feedBody(ids: [1], cursor: "page2"))
        let vm = makeViewModel()
        await vm.loadInitial()

        MockURLProtocol.respondWithJSON(feedBody(ids: [9], cursor: nil))
        await vm.loadInitial()

        var requestMade = false
        MockURLProtocol.handler = { request in
            requestMade = true
            let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!
            return (Data(feedBody(ids: [99], cursor: nil).utf8), response)
        }
        await vm.loadMore()

        XCTAssertFalse(requestMade, "The exhausted second response should have ended paging")
        XCTAssertEqual(vm.galleries.map(\.title), ["Gallery 9"])
    }

    // MARK: - loadMore

    func testPagingAppendsTheNextPage() async {
        MockURLProtocol.respondWithJSON(feedBody(ids: [1], cursor: "page2"))
        let vm = makeViewModel()
        await vm.loadInitial()

        MockURLProtocol.respondWithJSON(feedBody(ids: [2, 3], cursor: nil))
        await vm.loadMore()

        XCTAssertEqual(vm.galleries.map(\.title), ["Gallery 1", "Gallery 2", "Gallery 3"])
    }

    /// Scrolling to the bottom fires this repeatedly.
    func testPagingStopsOnceTheServerRunsOut() async {
        MockURLProtocol.respondWithJSON(feedBody(ids: [1], cursor: "page2"))
        let vm = makeViewModel()
        await vm.loadInitial()

        MockURLProtocol.respondWithJSON(feedBody(ids: [2], cursor: nil))
        await vm.loadMore()

        var extraRequests = 0
        MockURLProtocol.handler = { request in
            extraRequests += 1
            let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!
            return (Data(feedBody(ids: [99], cursor: nil).utf8), response)
        }
        await vm.loadMore()

        XCTAssertEqual(extraRequests, 0)
        XCTAssertEqual(vm.galleries.count, 2)
    }

    func testThereIsNothingToPageBeforeTheFirstLoad() async {
        var requestMade = false
        MockURLProtocol.handler = { request in
            requestMade = true
            let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!
            return (Data(feedBody(ids: [1], cursor: nil).utf8), response)
        }

        await makeViewModel().loadMore()

        XCTAssertFalse(requestMade)
    }

    func testAFailedPageKeepsWhatIsAlreadyOnScreen() async {
        MockURLProtocol.respondWithJSON(feedBody(ids: [1], cursor: "page2"))
        let vm = makeViewModel()
        await vm.loadInitial()

        MockURLProtocol.respondWithError(statusCode: 500)
        await vm.loadMore()

        XCTAssertEqual(vm.galleries.map(\.title), ["Gallery 1"])
        XCTAssertNotNil(vm.error)
        XCTAssertFalse(vm.isLoading)
    }

    // MARK: - Disk cache

    /// The cache exists so the feed renders before the first network response —
    /// a new view model on the same key has to see it without fetching.
    func testACachedFeedIsAvailableBeforeAnyRequest() async {
        // The cache files entries under the active account, so pointing that at
        // a synthetic DID keeps this out of the installed app's real cache.
        let did = "did:plc:feedvm-\(UUID().uuidString)"
        let savedAccount = AccountScopedStorage.activeAccountID
        AccountScopedStorage.activeAccountID = did
        defer {
            FeedCache.shared.purge(did: did)
            AccountScopedStorage.activeAccountID = savedAccount
        }
        let key = "feedvm-\(UUID().uuidString)"

        MockURLProtocol.respondWithJSON(feedBody(ids: [1, 2], cursor: nil))
        await makeViewModel(cacheKey: key).loadInitial()

        // The write is handed to a detached task, so give it a moment to land.
        try? await Task.sleep(for: .milliseconds(300))

        let restored = makeViewModel(cacheKey: key)
        XCTAssertEqual(restored.galleries.map(\.title), ["Gallery 1", "Gallery 2"])
        XCTAssertFalse(restored.hasFetchedInitial, "Cached content isn't a fetch")
    }

    func testAViewModelWithNoCacheKeyStartsEmpty() {
        XCTAssertTrue(makeViewModel().galleries.isEmpty)
    }

    // MARK: - Pinned feed convenience init

    /// A following or for-you feed is scoped to the viewer; the others aren't,
    /// and sending an actor on those would silently filter the feed.
    func testOnlyThePersonalFeedsCarryTheViewersDID() async {
        for (feed, expectsActor) in [
            (PinnedFeed.defaults[0], false),
            (PinnedFeed.defaults[1], true),
            (PinnedFeed.defaults[2], true),
        ] {
            let seen = ParamRecorder()
            MockURLProtocol.handler = { request in
                seen.record(request.url)
                let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!
                return (Data(feedBody(ids: [], cursor: nil).utf8), response)
            }

            let vm = FeedViewModel(client: client, pinnedFeed: feed, userDID: "did:plc:me")
            await vm.loadInitial()

            XCTAssertEqual(
                seen.value(for: "actor"), expectsActor ? "did:plc:me" : nil,
                "\(feed.id) sent the wrong actor"
            )
            XCTAssertEqual(seen.value(for: "feed"), feed.feedName)
        }
    }

    func testACameraFeedSendsTheCameraName() async {
        let seen = ParamRecorder()
        MockURLProtocol.handler = { request in
            seen.record(request.url)
            let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!
            return (Data(feedBody(ids: [], cursor: nil).utf8), response)
        }

        let feed = PinnedFeed(id: "camera:Leica M6", label: "Leica M6", type: "camera", path: "/c/leica")
        await FeedViewModel(client: client, pinnedFeed: feed).loadInitial()

        XCTAssertEqual(seen.value(for: "feed"), "camera")
        XCTAssertEqual(seen.value(for: "camera"), "Leica M6")
    }

    func testAHashtagFeedSendsTheTag() async {
        let seen = ParamRecorder()
        MockURLProtocol.handler = { request in
            seen.record(request.url)
            let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!
            return (Data(feedBody(ids: [], cursor: nil).utf8), response)
        }

        let feed = PinnedFeed(id: "hashtag:film", label: "#film", type: "hashtag", path: "/t/film")
        await FeedViewModel(client: client, pinnedFeed: feed).loadInitial()

        XCTAssertEqual(seen.value(for: "feed"), "hashtag")
        XCTAssertEqual(seen.value(for: "tag"), "film")
    }
}

/// Captures the URL a request went to, from off the main actor.
private final class ParamRecorder: @unchecked Sendable {
    private let lock = NSLock()
    private var url: URL?

    func record(_ url: URL?) {
        lock.withLock { self.url = url }
    }

    func value(for name: String) -> String? {
        let captured = lock.withLock { url }
        guard let captured,
              let items = URLComponents(url: captured, resolvingAgainstBaseURL: false)?.queryItems
        else { return nil }
        return items.first { $0.name == name }?.value
    }
}