Skip to content

Commit

Permalink
[Apollo Pagination] Improve ReversePagination support, implement `loa…
Browse files Browse the repository at this point in the history
…dAll` support, Bidirectional Pagination (#115)

Co-authored-by: Jerrad Thramer <[email protected]>
Co-authored-by: Anthony Miller <[email protected]>
  • Loading branch information
3 people authored Jan 24, 2024
1 parent 428a62f commit 3af5cdf
Show file tree
Hide file tree
Showing 30 changed files with 2,931 additions and 670 deletions.
50 changes: 36 additions & 14 deletions Tests/ApolloInternalTestHelpers/MockGraphQLServer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -32,22 +32,23 @@ import XCTest
public class MockGraphQLServer {
enum ServerError: Error, CustomStringConvertible {
case unexpectedRequest(String)

public var description: String {
switch self {
case .unexpectedRequest(let requestDescription):
return "Mock GraphQL server received an unexpected request: \(requestDescription)"
}
}
}


public var customDelay: DispatchTimeInterval?
public typealias RequestHandler<Operation: GraphQLOperation> = (HTTPRequest<Operation>) -> JSONObject

private class RequestExpectation<Operation: GraphQLOperation>: XCTestExpectation {
let file: StaticString
let line: UInt
let handler: RequestHandler<Operation>

init(description: String, file: StaticString = #filePath, line: UInt = #line, handler: @escaping RequestHandler<Operation>) {
self.file = file
self.line = line
Expand All @@ -56,43 +57,64 @@ public class MockGraphQLServer {
super.init(description: description)
}
}

private let queue = DispatchQueue(label: "com.apollographql.MockGraphQLServer")

public init() { }

// Since RequestExpectation is generic over a specific GraphQLOperation, we can't store these in the dictionary
// directly. Moreover, there is no way to specify the type relationship that holds between the key and value.
// To work around this, we store values as Any and use a generic subscript as a type-safe way to access them.
private var requestExpectations: [AnyHashable: Any] = [:]

private subscript<Operation: GraphQLOperation>(_ operationType: Operation.Type) -> RequestExpectation<Operation>? {
get {
requestExpectations[ObjectIdentifier(operationType)] as! RequestExpectation<Operation>?
}

set {
requestExpectations[ObjectIdentifier(operationType)] = newValue
}
}


private subscript<Operation: GraphQLOperation>(_ operationType: Operation) -> RequestExpectation<Operation>? {
get {
requestExpectations[operationType] as! RequestExpectation<Operation>?
}

set {
requestExpectations[operationType] = newValue
}
}

public func expect<Operation: GraphQLOperation>(_ operationType: Operation.Type, file: StaticString = #filePath, line: UInt = #line, requestHandler: @escaping (HTTPRequest<Operation>) -> JSONObject) -> XCTestExpectation {
return queue.sync {
let expectation = RequestExpectation<Operation>(description: "Served request for \(String(describing: operationType))", file: file, line: line, handler: requestHandler)
expectation.assertForOverFulfill = true

self[operationType] = expectation

return expectation
}
}


public func expect<Operation: GraphQLOperation>(_ operation: Operation, file: StaticString = #filePath, line: UInt = #line, requestHandler: @escaping (HTTPRequest<Operation>) -> JSONObject) -> XCTestExpectation {
return queue.sync {
let expectation = RequestExpectation<Operation>(description: "Served request for \(String(describing: operation.self))", file: file, line: line, handler: requestHandler)
expectation.assertForOverFulfill = true

self[operation] = expectation

return expectation
}
}

func serve<Operation>(request: HTTPRequest<Operation>, completionHandler: @escaping (Result<JSONObject, Error>) -> Void) where Operation: GraphQLOperation {
let operationType = type(of: request.operation)

if let expectation = self[operationType] {
if let expectation = self[request.operation] ?? self[operationType] {
// Dispatch after a small random delay to spread out concurrent requests and simulate somewhat real-world conditions.
queue.asyncAfter(deadline: .now() + .milliseconds(Int.random(in: 10...50))) {
queue.asyncAfter(deadline: .now() + (customDelay ?? .milliseconds(Int.random(in: 10...50)))) {
completionHandler(.success(expectation.handler(request)))
expectation.fulfill()
}
Expand Down
13 changes: 7 additions & 6 deletions Tests/ApolloInternalTestHelpers/MockNetworkTransport.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,12 @@ public final class MockNetworkTransport: RequestChainNetworkTransport {
endpointURL: TestURL.mockServer.url)
self.clientName = clientName
self.clientVersion = clientVersion
}
}

struct TestInterceptorProvider: InterceptorProvider {
let store: ApolloStore
let server: MockGraphQLServer

func interceptors<Operation>(
for operation: Operation
) -> [any ApolloInterceptor] where Operation: GraphQLOperation {
Expand All @@ -45,18 +45,18 @@ private class MockGraphQLServerInterceptor: ApolloInterceptor {
let server: MockGraphQLServer

public var id: String = UUID().uuidString

init(server: MockGraphQLServer) {
self.server = server
}

public func interceptAsync<Operation>(chain: RequestChain, request: HTTPRequest<Operation>, response: HTTPResponse<Operation>?, completion: @escaping (Result<GraphQLResult<Operation.Data>, Error>) -> Void) where Operation: GraphQLOperation {
server.serve(request: request) { result in
let httpResponse = HTTPURLResponse(url: TestURL.mockServer.url,
statusCode: 200,
httpVersion: nil,
headerFields: nil)!

switch result {
case .failure(let error):
chain.handleErrorAsync(error,
Expand All @@ -68,6 +68,7 @@ private class MockGraphQLServerInterceptor: ApolloInterceptor {
let response = HTTPResponse<Operation>(response: httpResponse,
rawData: data,
parsedResponse: nil)
guard !chain.isCancelled else { return }
chain.proceedAsync(request: request,
response: response,
interceptor: self,
Expand Down
152 changes: 152 additions & 0 deletions Tests/ApolloPaginationTests/AnyAsyncGraphQLQueryPagerTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
import Apollo
import ApolloAPI
import ApolloInternalTestHelpers
import XCTest

@testable import ApolloPagination

final class AnyAsyncGraphQLQueryPagerTests: XCTestCase {
private typealias Query = MockQuery<Mocks.Hero.FriendsQuery>

private var store: ApolloStore!
private var server: MockGraphQLServer!
private var networkTransport: MockNetworkTransport!
private var client: ApolloClient!

override func setUp() {
super.setUp()
store = ApolloStore(cache: InMemoryNormalizedCache())
server = MockGraphQLServer()
networkTransport = MockNetworkTransport(server: server, store: store)
client = ApolloClient(networkTransport: networkTransport, store: store)
}

func test_concatenatesPages_matchingInitialAndPaginated() async throws {
struct ViewModel {
let name: String
}

let anyPager = await createPager().eraseToAnyPager { data in
data.hero.friendsConnection.friends.map {
ViewModel(name: $0.name)
}
}

let fetchExpectation = expectation(description: "Initial Fetch")
fetchExpectation.assertForOverFulfill = false
let subscriptionExpectation = expectation(description: "Subscription")
subscriptionExpectation.expectedFulfillmentCount = 2
var expectedViewModels: [ViewModel]?
anyPager.subscribe { (result: Result<([ViewModel], UpdateSource), Error>) in
switch result {
case .success((let viewModels, _)):
expectedViewModels = viewModels
fetchExpectation.fulfill()
subscriptionExpectation.fulfill()
default:
XCTFail("Failed to get view models from pager.")
}
}

await fetchFirstPage(pager: anyPager)
await fulfillment(of: [fetchExpectation], timeout: 1)
try await fetchSecondPage(pager: anyPager)

await fulfillment(of: [subscriptionExpectation], timeout: 1.0)
let results = try XCTUnwrap(expectedViewModels)
XCTAssertEqual(results.count, 3)
XCTAssertEqual(results.map(\.name), ["Luke Skywalker", "Han Solo", "Leia Organa"])
}

func test_passesBackSeparateData() async throws {
let anyPager = await createPager().eraseToAnyPager { _, initial, next in
if let latestPage = next.last {
return latestPage.hero.friendsConnection.friends.last?.name
}
return initial.hero.friendsConnection.friends.last?.name
}

let initialExpectation = expectation(description: "Initial")
let secondExpectation = expectation(description: "Second")
var expectedViewModel: String?
anyPager.subscribe { (result: Result<(String?, UpdateSource), Error>) in
switch result {
case .success((let viewModel, _)):
let oldValue = expectedViewModel
expectedViewModel = viewModel
if oldValue == nil {
initialExpectation.fulfill()
} else {
secondExpectation.fulfill()
}
default:
XCTFail("Failed to get view models from pager.")
}
}

await fetchFirstPage(pager: anyPager)
await fulfillment(of: [initialExpectation], timeout: 1.0)
XCTAssertEqual(expectedViewModel, "Han Solo")

try await fetchSecondPage(pager: anyPager)
await fulfillment(of: [secondExpectation], timeout: 1.0)
XCTAssertEqual(expectedViewModel, "Leia Organa")
}

func test_loadAll() async throws {
let pager = createPager()

let firstPageExpectation = Mocks.Hero.FriendsQuery.expectationForFirstPage(server: server)
let lastPageExpectation = Mocks.Hero.FriendsQuery.expectationForSecondPage(server: server)
let loadAllExpectation = expectation(description: "Load all pages")
let subscriber = await pager.subscribe { _ in
loadAllExpectation.fulfill()
}
try await pager.loadAll()
await fulfillment(of: [firstPageExpectation, lastPageExpectation, loadAllExpectation], timeout: 5)
subscriber.cancel()
}

// MARK: - Test helpers

private func createPager() -> AsyncGraphQLQueryPager<Query, Query> {
let initialQuery = Query()
initialQuery.__variables = ["id": "2001", "first": 2, "after": GraphQLNullable<String>.null]
return AsyncGraphQLQueryPager<Query, Query>(
client: client,
initialQuery: initialQuery,
watcherDispatchQueue: .main,
extractPageInfo: { data in
switch data {
case .initial(let data), .paginated(let data):
return CursorBasedPagination.Forward(
hasNext: data.hero.friendsConnection.pageInfo.hasNextPage,
endCursor: data.hero.friendsConnection.pageInfo.endCursor
)
}
},
pageResolver: { pageInfo, direction in
guard direction == .next else { return nil }
let nextQuery = Query()
nextQuery.__variables = [
"id": "2001",
"first": 2,
"after": pageInfo.endCursor,
]
return nextQuery
}
)
}

private func fetchFirstPage<T>(pager: AnyAsyncGraphQLQueryPager<T>) async {
let serverExpectation = Mocks.Hero.FriendsQuery.expectationForFirstPage(server: server)
await pager.fetch()
await fulfillment(of: [serverExpectation], timeout: 1.0)
}

private func fetchSecondPage<T>(pager: AnyAsyncGraphQLQueryPager<T>) async throws {
let serverExpectation = Mocks.Hero.FriendsQuery.expectationForSecondPage(server: server)
try await pager.loadNext()
await fulfillment(of: [serverExpectation], timeout: 1.0)
}
}
Loading

0 comments on commit 3af5cdf

Please sign in to comment.