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

grain.social/grain-ios

1 collaborator
0
1
main
194 lines · 6.9 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 this @MainActor class.
private let galleryBody = """
{"gallery": {
  "uri": "at://did:plc:a/social.grain.gallery/1",
  "cid": "bafyg",
  "title": "A gallery",
  "creator": {"cid": "c1", "did": "did:plc:a", "handle": "alice.test"},
  "indexedAt": "2025-01-02T00:00:00Z"
}}
"""

private func commentsBody(ids: [Int], cursor: String?) -> String {
    let comments = ids.map { id in
        """
        {
          "uri": "at://did:plc:a/social.grain.comment/\(id)",
          "cid": "bafyc\(id)",
          "author": {"cid": "c1", "did": "did:plc:a", "handle": "alice.test"},
          "text": "Comment \(id)",
          "createdAt": "2025-01-03T00:00:00Z"
        }
        """
    }.joined(separator: ",")
    let cursorJSON = cursor.map { "\"\($0)\"" } ?? "null"
    return "{\"comments\": [\(comments)], \"cursor\": \(cursorJSON)}"
}

/// The detail screen fetches the gallery and its first page of comments
/// together, then pages the comments. Its cursor bookkeeping is the part that
/// misbehaves quietly: a stale cursor either re-appends a page or stops paging
/// a thread that still has more.
@MainActor
final class GalleryDetailViewModelTests: GrainTestCase {
    private var client: XRPCClient!
    private var vm: GalleryDetailViewModel!

    private let galleryUri = "at://did:plc:a/social.grain.gallery/1"

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

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

    /// Serves the gallery to the gallery endpoint and comments to the thread
    /// endpoint, so one `load()` can be driven end to end.
    private func respond(comments: [Int], cursor: String?) {
        MockURLProtocol.handler = { request in
            let body = request.url!.path.contains("getGallery")
                ? galleryBody
                : commentsBody(ids: comments, cursor: cursor)
            let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!
            return (Data(body.utf8), response)
        }
    }

    // MARK: - load

    func testLoadFillsTheGalleryAndItsComments() async {
        respond(comments: [1, 2], cursor: nil)

        await vm.load(uri: galleryUri)

        XCTAssertEqual(vm.gallery?.title, "A gallery")
        XCTAssertEqual(vm.comments.map(\.text), ["Comment 1", "Comment 2"])
        XCTAssertNil(vm.error)
        XCTAssertFalse(vm.isLoading)
    }

    func testAFailedLoadRecordsTheErrorAndStopsLoading() async {
        MockURLProtocol.respondWithError(statusCode: 404)

        await vm.load(uri: galleryUri)

        XCTAssertNil(vm.gallery)
        XCTAssertNotNil(vm.error)
        XCTAssertFalse(vm.isLoading)
    }

    /// Pull-to-refresh runs `load` again after a failure, and the stale error
    /// has to go or the screen keeps showing it under fresh content.
    func testASuccessfulReloadClearsAPreviousError() async {
        MockURLProtocol.respondWithError(statusCode: 500)
        await vm.load(uri: galleryUri)
        XCTAssertNotNil(vm.error)

        respond(comments: [1], cursor: nil)
        await vm.load(uri: galleryUri)

        XCTAssertNil(vm.error)
        XCTAssertNotNil(vm.gallery)
    }

    func testReloadingReplacesTheCommentsRatherThanAppending() async {
        respond(comments: [1, 2], cursor: nil)
        await vm.load(uri: galleryUri)

        respond(comments: [3], cursor: nil)
        await vm.load(uri: galleryUri)

        XCTAssertEqual(vm.comments.map(\.text), ["Comment 3"])
    }

    // MARK: - loadMoreComments

    func testLoadMoreAppendsTheNextPage() async {
        respond(comments: [1], cursor: "page2")
        await vm.load(uri: galleryUri)

        MockURLProtocol.respondWithJSON(commentsBody(ids: [2, 3], cursor: nil))
        await vm.loadMoreComments(galleryUri: galleryUri)

        XCTAssertEqual(vm.comments.map(\.text), ["Comment 1", "Comment 2", "Comment 3"])
    }

    /// Scroll-triggered paging fires repeatedly at the bottom of a thread, so
    /// the last page has to be terminal.
    func testPagingStopsOnceTheServerRunsOut() async {
        respond(comments: [1], cursor: "page2")
        await vm.load(uri: galleryUri)

        MockURLProtocol.respondWithJSON(commentsBody(ids: [2], cursor: nil))
        await vm.loadMoreComments(galleryUri: galleryUri)

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

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

    /// A first page that already exhausted the thread leaves no cursor to page
    /// with at all.
    func testThereIsNothingToPageWhenTheFirstPageWasTheWholeThread() async {
        respond(comments: [1], cursor: nil)
        await vm.load(uri: galleryUri)

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

        XCTAssertFalse(requestMade)
        XCTAssertEqual(vm.comments.count, 1)
    }

    /// The initial load and a paging request share the flag, so paging must
    /// stand down while the first load is still in flight.
    func testPagingIsSkippedWhileAnotherLoadIsRunning() async {
        respond(comments: [1], cursor: "page2")
        await vm.load(uri: galleryUri)
        vm.isLoading = true

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

        XCTAssertFalse(requestMade)
    }

    /// A failed page must leave what's already on screen alone.
    func testAFailedPageKeepsTheCommentsAlreadyLoaded() async {
        respond(comments: [1], cursor: "page2")
        await vm.load(uri: galleryUri)

        MockURLProtocol.respondWithError(statusCode: 500)
        await vm.loadMoreComments(galleryUri: galleryUri)

        XCTAssertEqual(vm.comments.map(\.text), ["Comment 1"])
        XCTAssertNotNil(vm.error)
        XCTAssertFalse(vm.isLoading)
    }
}