diff --git a/Tests/ApolloCodegenTests/CodeGenIR/IRRootFieldBuilderTests.swift b/Tests/ApolloCodegenTests/CodeGenIR/IRRootFieldBuilderTests.swift index f5686e5f8..27829d8b8 100644 --- a/Tests/ApolloCodegenTests/CodeGenIR/IRRootFieldBuilderTests.swift +++ b/Tests/ApolloCodegenTests/CodeGenIR/IRRootFieldBuilderTests.swift @@ -40,6 +40,8 @@ class IRRootFieldBuilderTests: XCTestCase { // MARK: - Helpers func buildSubjectRootField(operationName: String? = nil) async throws { + addDeferDirective() + ir = try await IRBuilderTestWrapper(.mock(schema: schemaSDL, document: document)) if let operationName { operation = try XCTUnwrap(ir.compilationResult.operations.first( @@ -52,6 +54,19 @@ class IRRootFieldBuilderTests: XCTestCase { subject = result.rootField } + // This function will only be needed until @defer is merged into the GraphQL spec and is + // considered a first-class directive in graphql-js. Right now it is a valid directive but must + // be 'enabled' through explicit declaration in the schema. + fileprivate func addDeferDirective() { + guard let schemaSDL = self.schemaSDL else { return } + + self.schemaSDL = """ + directive @defer(label: String, if: Boolean! = true) on FRAGMENT_SPREAD | INLINE_FRAGMENT + + \(schemaSDL) + """ + } + // MARK: - Children Computation // MARK: Children - Fragment Type @@ -4384,9 +4399,11 @@ class IRRootFieldBuilderTests: XCTestCase { expect(Array(self.computedReferencedFragments)).to(equal(expected)) } - // MARK: - Deferred Fragments - hasDeferredFragments property + // MARK: - Deferred Fragments - containsDeferredFragment property + + #warning("Need tests for deferredFragments property") - func test__deferredFragments__givenNoDeferredFragment_hasDeferredFragmentsFalse() async throws { + func test__deferredFragments__givenNoDeferredFragment_containsDeferredFragmentFalse() async throws { // given schemaSDL = """ type Query { @@ -4426,9 +4443,7 @@ class IRRootFieldBuilderTests: XCTestCase { expect(self.result.containsDeferredFragment).to(beFalse()) } - func test__deferredFragments__givenDeferredInlineFragment_hasDeferredFragmentsTrue() async throws { - throw XCTSkip("Skipped in PR #235 - must be reverted when the feature/defer-execution-networking branch is merged into main!") - + func test__deferredFragments__givenDeferredInlineFragment_containsDeferredFragmentTrue() async throws { // given schemaSDL = """ type Query { @@ -4468,9 +4483,7 @@ class IRRootFieldBuilderTests: XCTestCase { expect(self.result.containsDeferredFragment).to(beTrue()) } - func test__deferredFragments__givenDeferredInlineFragmentWithCondition_hasDeferredFragmentsTrue() async throws { - throw XCTSkip("Skipped in PR #235 - must be reverted when the feature/defer-execution-networking branch is merged into main!") - + func test__deferredFragments__givenDeferredInlineFragmentWithCondition_containsDeferredFragmentTrue() async throws { // given schemaSDL = """ type Query { @@ -4510,9 +4523,7 @@ class IRRootFieldBuilderTests: XCTestCase { expect(self.result.containsDeferredFragment).to(beTrue()) } - func test__deferredFragments__givenDeferredInlineFragmentWithConditionFalse_hasDeferredFragmentsFalse() async throws { - throw XCTSkip("Skipped in PR #235 - must be reverted when the feature/defer-execution-networking branch is merged into main!") - + func test__deferredFragments__givenDeferredInlineFragmentWithConditionFalse_containsDeferredFragmentFalse() async throws { // given schemaSDL = """ type Query { @@ -4552,9 +4563,7 @@ class IRRootFieldBuilderTests: XCTestCase { expect(self.result.containsDeferredFragment).to(beFalse()) } - func test__deferredFragments__givenDeferredNamedFragment_onDifferentTypeCase_hasDeferredFragmentsTrue() async throws { - throw XCTSkip("Skipped in PR #235 - must be reverted when the feature/defer-execution-networking branch is merged into main!") - + func test__deferredFragments__givenDeferredNamedFragment_onDifferentTypeCase_containsDeferredFragmentTrue() async throws { // given schemaSDL = """ type Query { @@ -4593,9 +4602,7 @@ class IRRootFieldBuilderTests: XCTestCase { expect(self.result.containsDeferredFragment).to(beTrue()) } - func test__deferredFragments__givenDeferredInlineFragment_withinNamedFragment_hasDeferredFragmentsTrue() async throws { - throw XCTSkip("Skipped in PR #235 - must be reverted when the feature/defer-execution-networking branch is merged into main!") - + func test__deferredFragments__givenDeferredInlineFragment_withinNamedFragment_containsDeferredFragmentTrue() async throws { // given schemaSDL = """ type Query { @@ -4639,9 +4646,7 @@ class IRRootFieldBuilderTests: XCTestCase { expect(self.result.containsDeferredFragment).to(beTrue()) } - func test__deferredFragments__givenDeferredNamedFragment_withSelectionOnDifferentTypeCase_hasDeferredFragmentsTrue() async throws { - throw XCTSkip("Skipped in PR #235 - must be reverted when the feature/defer-execution-networking branch is merged into main!") - + func test__deferredFragments__givenDeferredNamedFragment_withSelectionOnDifferentTypeCase_containsDeferredFragmentTrue() async throws { // given schemaSDL = """ type Query { diff --git a/Tests/ApolloCodegenTests/CodeGeneration/Templates/OperationDefinitionTemplateTests.swift b/Tests/ApolloCodegenTests/CodeGeneration/Templates/OperationDefinitionTemplateTests.swift index 75fdec1a3..1312722b0 100644 --- a/Tests/ApolloCodegenTests/CodeGeneration/Templates/OperationDefinitionTemplateTests.swift +++ b/Tests/ApolloCodegenTests/CodeGeneration/Templates/OperationDefinitionTemplateTests.swift @@ -308,131 +308,7 @@ class OperationDefinitionTemplateTests: XCTestCase { // MARK: - Defer Properties - func test__generate__givenQueryWithDeferredInlineFragment_generatesDeferredPropertyTrue() async throws { - throw XCTSkip("Skipped in PR #235 - must be reverted when the feature/defer-execution-networking branch is merged into main!") - - // given - schemaSDL = """ - type Query { - allAnimals: [Animal!] - } - - interface Animal { - species: String! - } - - type Dog implements Animal { - species: String! - } - """ - - document = """ - query TestOperation { - allAnimals { - ... on Dog @defer(label: "root") { - species - } - } - } - """ - - let expected = """ - public static let hasDeferredFragments: Bool = true - """ - - // when - try await buildSubjectAndOperation() - let actual = renderSubject() - - // then - expect(actual).to(equalLineByLine(expected, atLine: 8, ignoringExtraLines: true)) - } - - func test__generate__givenQueryWithDeferredNamedFragment_generatesDeferredPropertyTrue() async throws { - throw XCTSkip("Skipped in PR #235 - must be reverted when the feature/defer-execution-networking branch is merged into main!") - - // given - schemaSDL = """ - type Query { - allAnimals: [Animal!] - } - - interface Animal { - species: String! - } - - type Dog implements Animal { - species: String! - } - """ - - document = """ - query TestOperation { - allAnimals { - ... DogFragment @defer(label: "root") - } - } - - fragment DogFragment on Dog { - species - } - """ - - let expected = """ - public static let hasDeferredFragments: Bool = true - """ - - // when - try await buildSubjectAndOperation() - let actual = renderSubject() - - // then - expect(actual).to(equalLineByLine(expected, atLine: 9, ignoringExtraLines: true)) - } - - func test__generate__givenQueryWithNamedFragment_withDeferredTypeCase_generatesDeferredPropertyTrue() async throws { - throw XCTSkip("Skipped in PR #235 - must be reverted when the feature/defer-execution-networking branch is merged into main!") - - // given - schemaSDL = """ - type Query { - allAnimals: [Animal!] - } - - interface Animal { - species: String! - } - - type Dog implements Animal { - species: String! - } - """ - - document = """ - query TestOperation { - allAnimals { - ... DogFragment - } - } - - fragment DogFragment on Animal { - ... on Dog @defer(label: "root") { - species - } - } - """ - - let expected = """ - public static let hasDeferredFragments: Bool = true - """ - - // when - try await buildSubjectAndOperation() - let actual = renderSubject() - - // then - expect(actual).to(equalLineByLine(expected, atLine: 9, ignoringExtraLines: true)) - } + #warning("Need tests for deferredFragments property") // MARK: - Selection Set Declaration diff --git a/Tests/ApolloCodegenTests/CodeGeneration/Templates/SelectionSet/SelectionSetTemplateTests.swift b/Tests/ApolloCodegenTests/CodeGeneration/Templates/SelectionSet/SelectionSetTemplateTests.swift index 71e01a9da..3211481bf 100644 --- a/Tests/ApolloCodegenTests/CodeGeneration/Templates/SelectionSet/SelectionSetTemplateTests.swift +++ b/Tests/ApolloCodegenTests/CodeGeneration/Templates/SelectionSet/SelectionSetTemplateTests.swift @@ -7407,7 +7407,7 @@ class SelectionSetTemplateTests: XCTestCase { expect(rendered_allAnimals_deferredAsRoot).to(equalLineByLine( """ /// AllAnimal.Root - public struct Root: TestSchema.InlineFragment, ApolloAPI.Deferrable { + public struct Root: TestSchema.InlineFragment { public let __data: DataDict public init(_dataDict: DataDict) { __data = _dataDict } @@ -7461,7 +7461,7 @@ class SelectionSetTemplateTests: XCTestCase { expect(rendered_allAnimals_deferredAsRoot).to(equalLineByLine( """ /// AllAnimal.Root - public struct Root: TestSchema.InlineFragment, ApolloAPI.Deferrable { + public struct Root: TestSchema.InlineFragment { public let __data: DataDict public init(_dataDict: DataDict) { __data = _dataDict } @@ -7520,7 +7520,7 @@ class SelectionSetTemplateTests: XCTestCase { expect(rendered_allAnimals_asDog_deferredAsRoot).to(equalLineByLine( """ /// AllAnimal.AsDog.Root - public struct Root: TestSchema.InlineFragment, ApolloAPI.Deferrable { + public struct Root: TestSchema.InlineFragment { public let __data: DataDict public init(_dataDict: DataDict) { __data = _dataDict } diff --git a/Tests/ApolloInternalTestHelpers/InterceptorTester.swift b/Tests/ApolloInternalTestHelpers/InterceptorTester.swift index 4cdf758a0..98337b127 100644 --- a/Tests/ApolloInternalTestHelpers/InterceptorTester.swift +++ b/Tests/ApolloInternalTestHelpers/InterceptorTester.swift @@ -1,4 +1,5 @@ import Apollo +import ApolloAPI import Foundation /// Use this interceptor tester to isolate a single `ApolloInterceptor` vs. having to create an @@ -14,9 +15,9 @@ public class InterceptorTester { public func intercept( request: Apollo.HTTPRequest, response: Apollo.HTTPResponse? = nil, - completion: @escaping (Result) -> Void + completion: @escaping (Result?, Error>) -> Void ) { - let requestChain = ResponseCaptureRequestChain({ result in + let requestChain = ResponseCaptureRequestChain({ result in completion(result) }) @@ -27,11 +28,11 @@ public class InterceptorTester { } } -fileprivate class ResponseCaptureRequestChain: RequestChain { +fileprivate class ResponseCaptureRequestChain: RequestChain { var isCancelled: Bool = false - let completion: (Result) -> Void + let completion: (Result?, Error>) -> Void - init(_ completion: @escaping (Result) -> Void) { + init(_ completion: @escaping (Result?, Error>) -> Void) { self.completion = completion } @@ -45,7 +46,7 @@ fileprivate class ResponseCaptureRequestChain: RequestChain { response: Apollo.HTTPResponse?, completion: @escaping (Result, Error>) -> Void ) { - self.completion(.success(response?.rawData)) + self.completion(.success(response as? HTTPResponse)) } func proceedAsync( @@ -54,7 +55,7 @@ fileprivate class ResponseCaptureRequestChain: RequestChain { interceptor: any ApolloInterceptor, completion: @escaping (Result, Error>) -> Void ) { - self.completion(.success(response?.rawData)) + self.completion(.success(response as? HTTPResponse)) } func cancel() {} diff --git a/Tests/ApolloInternalTestHelpers/MockOperation.swift b/Tests/ApolloInternalTestHelpers/MockOperation.swift index faef67c5c..80c35395f 100644 --- a/Tests/ApolloInternalTestHelpers/MockOperation.swift +++ b/Tests/ApolloInternalTestHelpers/MockOperation.swift @@ -5,8 +5,6 @@ open class MockOperation: GraphQLOperation { open class var operationType: GraphQLOperationType { .query } - open class var hasDeferredFragments: Bool { false } - open class var operationName: String { "MockOperationName" } open class var operationDocument: OperationDocument { @@ -17,6 +15,8 @@ open class MockOperation: GraphQLOperation { public init() {} + open class var deferredFragments: [DeferredFragmentIdentifier : any ApolloAPI.SelectionSet.Type]? { return nil } + } open class MockQuery: MockOperation, GraphQLQuery { diff --git a/Tests/ApolloPerformanceTests/ParsingPerformanceTests.swift b/Tests/ApolloPerformanceTests/ParsingPerformanceTests.swift index 648776600..f6dfe9437 100644 --- a/Tests/ApolloPerformanceTests/ParsingPerformanceTests.swift +++ b/Tests/ApolloPerformanceTests/ParsingPerformanceTests.swift @@ -79,7 +79,7 @@ class ParsingPerformanceTests: XCTestCase { measure { subject.intercept(request: request, response: response) { result in XCTAssertSuccessResult(result) - XCTAssertEqual(try! result.get(), expectedData) + XCTAssertEqual(try! result.get()?.rawData, expectedData) } } } diff --git a/Tests/ApolloTests/DataDictMergingTests.swift b/Tests/ApolloTests/DataDictMergingTests.swift new file mode 100644 index 000000000..0a3ef7898 --- /dev/null +++ b/Tests/ApolloTests/DataDictMergingTests.swift @@ -0,0 +1,326 @@ +import XCTest +@testable import Apollo +import ApolloAPI +import ApolloInternalTestHelpers +import Nimble + +class DataDictMergingTests: XCTestCase { + + class Data: MockSelectionSet { + class Animal: MockSelectionSet { + class Predator: MockSelectionSet { } + } + } + + let subject = DataDict( + data: [ + "animals": [ + DataDict( + data: [ + "__typename": "Animal", + "name": "Dog" + ], + fulfilledFragments: [ + ObjectIdentifier(Data.Animal.self), + ], + deferredFragments: [ + ObjectIdentifier(Data.Animal.Predator.self) + ] + ), + DataDict( + data: [ + "__typename": "Animal", + "name": "Cat" + ], + fulfilledFragments: [ + ObjectIdentifier(Data.Animal.self), + ], + deferredFragments: [ + ObjectIdentifier(Data.Animal.Predator.self) + ] + ) + ] + ], + fulfilledFragments: [ + ObjectIdentifier(Data.self), + ] + ) + + // MARK: Errors + + func test__merging__givenEmptyPathComponent_throwsError() throws { + // given + let mergePath: [PathComponent] = [] + + // then + expect( + try self.subject.merging(DataDict.empty(), at: mergePath) + ).to(throwError(DataDict.MergeError.emptyMergePath)) + } + + func test__merging__givenIndexPathForDataDictType_throwsError() throws { + // given + let mergePath: [PathComponent] = [ + .index(0), + ] + + // then + expect( + try self.subject.merging(DataDict.empty(), at: mergePath) + ).to(throwError(DataDict.MergeError.invalidPathComponentForDataType(.index(0), "DataDict"))) + } + + func test__merging__givenFieldPathForArrayType_throwsError() throws { + // given + let mergePath: [PathComponent] = [ + .field("animals"), + .field("first"), + ] + + // then + expect( + try self.subject.merging(DataDict.empty(), at: mergePath) + ).to(throwError(DataDict.MergeError.invalidPathComponentForDataType(.field("first"), "Array"))) + } + + func test__merging__givenInvalidFieldPath_throwsError() throws { + // given + let mergePath: [PathComponent] = [ + .field("nonexistent"), + ] + + // then + expect( + try self.subject.merging(DataDict.empty(), at: mergePath) + ).to(throwError(DataDict.MergeError.cannotFindPathComponent(.field("nonexistent")))) + } + + func test__merging__givenInvalidIndexPath_throwsError() throws { + // given + let mergePath: [PathComponent] = [ + .field("animals"), + .index(3), + ] + + // then + expect( + try self.subject.merging(DataDict.empty(), at: mergePath) + ).to(throwError(DataDict.MergeError.cannotFindPathComponent(.index(3)))) + } + + func test__merging__givenPathToNonDataDictType_throwsError() throws { + // given + let mergePath: [PathComponent] = [ + .field("animals"), + ] + + // then + expect( + try self.subject.merging(DataDict.empty(), at: mergePath) + ).to(throwError(DataDict.MergeError.incrementalMergeNeedsDataDict)) + } + + func test__merging__givenPathToExistingFieldData_withNewValue_throwsError() throws { + // given + let mergePath: [PathComponent] = [ + .field("animals"), + .index(0), + ] + + let mergeDataDict = DataDict( + data: [ + "__typename": "NewValue" + ], + fulfilledFragments: [] + ) + + // then + expect( + try self.subject.merging(mergeDataDict, at: mergePath) + ).to(throwError(DataDict.MergeError.cannotOverwriteFieldData("Animal", "NewValue"))) + } + + func test__merging__givenPathToExistingFieldData_withSameValue_doesNotThrowError() throws { + // given + let mergePath: [PathComponent] = [ + .field("animals"), + .index(0), + ] + + let mergeDataDict = DataDict( + data: [ + "__typename": "Animal" + ], + fulfilledFragments: [] + ) + + // then + expect( + try self.subject.merging(mergeDataDict, at: mergePath) + ).notTo(throwError()) + } + + // MARK: Merging + + func test__merging__givenFulfilledFragments_shouldAddFulfilledFragments_andRemoveMatchingDeferredFragments() throws { + // given + let mergePath: [PathComponent] = [ + .field("animals"), + .index(0), + ] + + let mergeDataDict = DataDict( + data: [ + "__typename": "Animal", + "predators": [ + DataDict( + data: [ + "__typename": "Animal", + "name": "Coyote" + ], + fulfilledFragments: [] + ) + ] + ], + fulfilledFragments: [ + ObjectIdentifier(Data.Animal.Predator.self) + ] + ) + + // then + let data: DataDict = try self.subject.merging(mergeDataDict, at: mergePath) + + expect(data._fulfilledFragments).to(equal([ + ObjectIdentifier(Data.self) + ])) + + let animals = data._data["animals"] as! [DataDict] + let mergedAnimal = animals[0] + let unmergedAnimal = animals[1] + + expect(mergedAnimal._fulfilledFragments).to(equal([ + ObjectIdentifier(Data.Animal.self), + ObjectIdentifier(Data.Animal.Predator.self), + ])) + expect(mergedAnimal._deferredFragments).to(beEmpty()) + + expect(unmergedAnimal._fulfilledFragments).to(equal([ + ObjectIdentifier(Data.Animal.self) + ])) + expect(unmergedAnimal._deferredFragments).to(equal([ + ObjectIdentifier(Data.Animal.Predator.self), + ])) + } + + func test__merging__givenSimpleMergePath_shouldMergeData() throws { + // given + let subject = DataDict( + data: [ + "animal": DataDict( + data: [ + "__typename": "Animal", + "name": "Cat" + ], + fulfilledFragments: [] + ) + ], + fulfilledFragments: [] + ) + + let mergePath: [PathComponent] = [ + .field("animal"), + ] + + let mergeDataDict = DataDict( + data: [ + "__typename": "Animal", + "colour": "Orange", + "predators": [ + DataDict( + data: [ + "__typename": "Animal", + "name": "Coyote" + ], + fulfilledFragments: [] + ) + ] + ], + fulfilledFragments: [] + ) + + // then + let data: DataDict = try subject.merging(mergeDataDict, at: mergePath) + let animal = data._data["animal"] as! DataDict + + expect(animal).to(equal(DataDict( + data: [ + "__typename": "Animal", + "name": "Cat", + "colour": "Orange", + "predators": [ + DataDict( + data: [ + "__typename": "Animal", + "name": "Coyote" + ], + fulfilledFragments: [] + ) + ] + ], + fulfilledFragments: [] + ))) + } + + func test__merging__givenMixedNestedMergePath_shouldMergeInNestedData() throws { + // given + let mergePath: [PathComponent] = [ + .field("animals"), + .index(1), + ] + + let mergeDataDict = DataDict( + data: [ + "__typename": "Animal", + "predators": [ + DataDict( + data: [ + "__typename": "Animal", + "name": "Coyote" + ], + fulfilledFragments: [] + ) + ] + ], + fulfilledFragments: [] + ) + + // then + let data: DataDict = try self.subject.merging(mergeDataDict, at: mergePath) + let animals = data._data["animals"] as! [DataDict] + + expect(animals).to(haveCount(2)) + + let unmergedAnimal = animals[0] + let mergedAnimal = animals[1] + + expect(unmergedAnimal._data).to(equal([ + "__typename": "Animal", + "name": "Dog", + ])) + + expect(mergedAnimal._data).to(equal([ + "__typename": "Animal", + "name": "Cat", + "predators": [ + DataDict( + data: [ + "__typename": "Animal", + "name": "Coyote" + ], + fulfilledFragments: [] + ) + ] + ])) + } + +} diff --git a/Tests/ApolloTests/DefaultInterceptorProviderTests.swift b/Tests/ApolloTests/DefaultInterceptorProviderTests.swift index 9029b64b6..c9597066e 100644 --- a/Tests/ApolloTests/DefaultInterceptorProviderTests.swift +++ b/Tests/ApolloTests/DefaultInterceptorProviderTests.swift @@ -2,7 +2,6 @@ import XCTest import Apollo import ApolloAPI import ApolloInternalTestHelpers -import StarWarsAPI class DefaultInterceptorProviderTests: XCTestCase { @@ -112,4 +111,75 @@ class DefaultInterceptorProviderTests: XCTestCase { self.wait(for: [secondLoadExpectation], timeout: 10) } + + func test__interceptors__givenOperationWithoutDeferredFragments_shouldUseJSONParsingInterceptor() throws { + // given + class GivenSelectionSet: MockSelectionSet { + override class var __selections: [Selection] { [ + .field("hero", Hero.self) + ]} + + class Hero: MockSelectionSet { + override class var __selections: [Selection] {[ + .field("__typename", String.self), + .field("name", String.self) + ]} + } + } + + // when + let actual = DefaultInterceptorProvider(client: URLSessionClient(), store: client.store) + .interceptors(for: MockQuery()) + + // then + XCTAssertTrue(actual.contains { interceptor in + interceptor is JSONResponseParsingInterceptor + }) + XCTAssertFalse(actual.contains { interceptor in + interceptor is IncrementalJSONResponseParsingInterceptor + }) + } + + func test__interceptors__givenOperationWithDeferredFragments_shouldUseIncrementalJSONParsingInterceptor() throws { + // given + class DeferredQuery: MockQuery { + class Animal: AbstractMockSelectionSet { + override class var __selections: [Selection] {[ + .deferred(DeferredName.self, label: "deferredName"), + ]} + + struct Fragments: FragmentContainer { + public let __data: DataDict + public init(_dataDict: DataDict) { + __data = _dataDict + _deferredName = Deferred(_dataDict: _dataDict) + } + + @Deferred var deferredName: DeferredName? + } + + class DeferredName: MockTypeCase { + override class var __selections: [Selection] {[ + .field("name", String.self), + ]} + } + } + + override class var deferredFragments: [DeferredFragmentIdentifier : any SelectionSet.Type]? {[ + DeferredFragmentIdentifier(label: "deferredName", fieldPath: []): Animal.DeferredName.self, + ]} + } + + // when + let actual = DefaultInterceptorProvider(client: URLSessionClient(), store: client.store) + .interceptors(for: DeferredQuery()) + + // then + XCTAssertTrue(actual.contains { interceptor in + interceptor is IncrementalJSONResponseParsingInterceptor + }) + XCTAssertFalse(actual.contains { interceptor in + interceptor is JSONResponseParsingInterceptor + }) + } } diff --git a/Tests/ApolloTests/DeferTests.swift b/Tests/ApolloTests/DeferTests.swift new file mode 100644 index 000000000..69317abed --- /dev/null +++ b/Tests/ApolloTests/DeferTests.swift @@ -0,0 +1,445 @@ +import XCTest +import Nimble +@testable import Apollo +import ApolloAPI +import ApolloInternalTestHelpers + +final class DeferTests: XCTestCase { + + private class TVShowQuery: MockQuery { + class Data: MockSelectionSet { + override class var __selections: [Selection] {[ + .field("show", Show.self), + ]} + + var show: Show { __data["show"] } + + class Show: AbstractMockSelectionSet { + override class var __selections: [Selection] {[ + .field("__typename", String.self), + .field("name", String.self), + .field("characters", [Character].self), + .deferred(DeferredGenres.self, label: "deferredGenres"), + ]} + + var name: String { __data["name"] } + var characters: [Character] { __data["characters"] } + + struct Fragments: FragmentContainer { + public let __data: DataDict + public init(_dataDict: DataDict) { + __data = _dataDict + _deferredGenres = Deferred(_dataDict: _dataDict) + } + + @Deferred var deferredGenres: DeferredGenres? + } + + class DeferredGenres: MockTypeCase { + override class var __selections: [Selection] {[ + .field("genres", [String].self), + ]} + } + + class Character: AbstractMockSelectionSet { + override class var __selections: [Selection] {[ + .field("__typename", String.self), + .field("name", String.self), + .deferred(DeferredFriend.self, label: "deferredFriend"), + ]} + + var name: String { __data["name"] } + + struct Fragments: FragmentContainer { + public let __data: DataDict + public init(_dataDict: DataDict) { + __data = _dataDict + _deferredFriend = Deferred(_dataDict: _dataDict) + } + + @Deferred var deferredFriend: DeferredFriend? + } + + class DeferredFriend: MockTypeCase { + override class var __selections: [Selection] {[ + .field("friend", String.self), + ]} + + var friend: String { __data["friend"] } + } + } + } + } + + override class var deferredFragments: [DeferredFragmentIdentifier : any SelectionSet.Type]? {[ + DeferredFragmentIdentifier(label: "deferredGenres", fieldPath: ["show"]): Data.Show.DeferredGenres.self, + DeferredFragmentIdentifier(label: "deferredFriend", fieldPath: ["show", "characters"]): Data.Show.Character.DeferredFriend.self, + ]} + } + + let defaultTimeout = 0.5 + + // MARK: Parsing tests + + private func buildNetworkTransport( + responseData: Data + ) -> RequestChainNetworkTransport { + let client = MockURLSessionClient( + response: .mock(headerFields: ["Content-Type": "multipart/mixed;boundary=graphql;deferSpec=20220824"]), + data: responseData + ) + + let provider = MockInterceptorProvider([ + NetworkFetchInterceptor(client: client), + MultipartResponseParsingInterceptor(), + IncrementalJSONResponseParsingInterceptor() + ]) + + return RequestChainNetworkTransport( + interceptorProvider: provider, + endpointURL: TestURL.mockServer.url + ) + } + + func test__parsing__givenPartialResponse_shouldReturnSingleSuccess() throws { + let network = buildNetworkTransport(responseData: """ + --graphql + content-type: application/json + + { + "hasNext": true, + "data": { + "show" : { + "__typename": "show", + "name": "The Scooby-Doo Show", + "characters": [ + { + "__typename": "Character", + "name": "Scooby-Doo" + }, + { + "__typename": "Character", + "name": "Shaggy Rogers" + }, + { + "__typename": "Character", + "name": "Velma Dinkley" + } + ] + } + } + } + --graphql-- + """.crlfFormattedData() + ) + + let expectation = expectation(description: "Result received") + + _ = network.send(operation: TVShowQuery()) { result in + expect(result).to(beSuccess()) + + let data = try? result.get().data + expect(data?.__data._fulfilledFragments).to(equal([ + ObjectIdentifier(TVShowQuery.Data.self), + ])) + expect(data?.__data._deferredFragments).to(beEmpty()) + + let show = data?.show + expect(show?.name).to(equal("The Scooby-Doo Show")) + expect(show?.fragments.$deferredGenres).to(equal(.pending)) + expect(show?.fragments.deferredGenres).to(beNil()) + expect(show?.__data._fulfilledFragments).to(equal([ + ObjectIdentifier(TVShowQuery.Data.Show.self), + ])) + expect(show?.__data._deferredFragments).to(equal([ + ObjectIdentifier(TVShowQuery.Data.Show.DeferredGenres.self), + ])) + + let scoobyDoo = show?.characters[0] + expect(scoobyDoo?.name).to(equal("Scooby-Doo")) + expect(scoobyDoo?.fragments.$deferredFriend).to(equal(.pending)) + expect(scoobyDoo?.__data._fulfilledFragments).to(equal([ + ObjectIdentifier(TVShowQuery.Data.Show.Character.self), + ])) + expect(scoobyDoo?.__data._deferredFragments).to(equal([ + ObjectIdentifier(TVShowQuery.Data.Show.Character.DeferredFriend.self), + ])) + + let shaggyRogers = show?.characters[1] + expect(shaggyRogers?.name).to(equal("Shaggy Rogers")) + expect(shaggyRogers?.fragments.$deferredFriend).to(equal(.pending)) + expect(shaggyRogers?.__data._fulfilledFragments).to(equal([ + ObjectIdentifier(TVShowQuery.Data.Show.Character.self), + ])) + expect(shaggyRogers?.__data._deferredFragments).to(equal([ + ObjectIdentifier(TVShowQuery.Data.Show.Character.DeferredFriend.self), + ])) + + let velmaDinkley = show?.characters[2] + expect(velmaDinkley?.name).to(equal("Velma Dinkley")) + expect(velmaDinkley?.fragments.$deferredFriend).to(equal(.pending)) + expect(velmaDinkley?.__data._fulfilledFragments).to(equal([ + ObjectIdentifier(TVShowQuery.Data.Show.Character.self), + ])) + expect(velmaDinkley?.__data._deferredFragments).to(equal([ + ObjectIdentifier(TVShowQuery.Data.Show.Character.DeferredFriend.self), + ])) + + expectation.fulfill() + } + + wait(for: [expectation], timeout: defaultTimeout) + } + + func test__parsing__givenPartialAndIncrementalResponses_withRootMerge_shouldReturnMultipleSuccesses() throws { + let network = buildNetworkTransport(responseData: """ + --graphql + content-type: application/json + + { + "hasNext": true, + "data": { + "show" : { + "__typename": "show", + "name": "The Scooby-Doo Show", + "characters": [ + { + "__typename": "Character", + "name": "Scooby-Doo" + }, + { + "__typename": "Character", + "name": "Shaggy Rogers" + }, + { + "__typename": "Character", + "name": "Velma Dinkley" + } + ] + } + } + } + --graphql + content-type: application/json + + { + "hasNext": true, + "incremental": [ + { + "label": "deferredGenres", + "path": [ + "show" + ], + "data": { + "genres": [ + "Comedy", + "Mystery", + "Adventure" + ] + } + } + ] + } + --graphql + """.crlfFormattedData() + ) + + let expectation = expectation(description: "Result received") + expectation.expectedFulfillmentCount = 2 + + _ = network.send(operation: TVShowQuery()) { result in + defer { + expectation.fulfill() + } + + expect(result).to(beSuccess()) + + let data = try? result.get().data + expect(data?.__data._fulfilledFragments).to(equal([ + ObjectIdentifier(TVShowQuery.Data.self), + ])) + expect(data?.__data._deferredFragments).to(beEmpty()) + + let show = data?.show + if expectation.numberOfFulfillments == 0 { // Partial data + expect(show?.name).to(equal("The Scooby-Doo Show")) + expect(show?.fragments.$deferredGenres).to(equal(.pending)) + expect(show?.fragments.deferredGenres).to(beNil()) + expect(show?.__data._fulfilledFragments).to(equal([ + ObjectIdentifier(TVShowQuery.Data.Show.self), + ])) + expect(show?.__data._deferredFragments).to(equal([ + ObjectIdentifier(TVShowQuery.Data.Show.DeferredGenres.self), + ])) + + } else { // Incremental data + expect(show?.name).to(equal("The Scooby-Doo Show")) + expect(show?.fragments.deferredGenres?.genres).to(equal([ + "Comedy", + "Mystery", + "Adventure" + ])) + expect(show?.__data._fulfilledFragments).to(equal([ + ObjectIdentifier(TVShowQuery.Data.Show.self), + ObjectIdentifier(TVShowQuery.Data.Show.DeferredGenres.self), + ])) + expect(show?.__data._deferredFragments).to(beEmpty()) + } + } + + wait(for: [expectation], timeout: defaultTimeout) + } + + func test__parsing__givenPartialAndIncrementalResponses_withNestedMerge_shouldReturnMultipleSuccesses() throws { + let network = buildNetworkTransport(responseData: """ + --graphql + content-type: application/json + + { + "hasNext": true, + "data": { + "show" : { + "__typename": "show", + "name": "The Scooby-Doo Show", + "characters": [ + { + "__typename": "Character", + "name": "Scooby-Doo" + }, + { + "__typename": "Character", + "name": "Shaggy Rogers" + }, + { + "__typename": "Character", + "name": "Velma Dinkley" + } + ] + } + } + } + --graphql + content-type: application/json + + { + "hasNext": false, + "incremental": [ + { + "label": "deferredFriend", + "path": [ + "show", "characters", 0 + ], + "data": { + "friend": "Scrappy-Doo" + } + }, + { + "label": "deferredFriend", + "path": [ + "show", "characters", 1 + ], + "data": { + "friend": "Scooby-Doo" + } + }, + { + "label": "deferredFriend", + "path": [ + "show", "characters", 2 + ], + "data": { + "friend": "Daphne Blake" + } + } + ] + } + --graphql + """.crlfFormattedData() + ) + + let expectation = expectation(description: "Result received") + expectation.expectedFulfillmentCount = 2 + + _ = network.send(operation: TVShowQuery()) { result in + defer { + expectation.fulfill() + } + + expect(result).to(beSuccess()) + + let data = try? result.get().data + expect(data?.__data._fulfilledFragments).to(equal([ + ObjectIdentifier(TVShowQuery.Data.self), + ])) + expect(data?.__data._deferredFragments).to(beEmpty()) + + let show = data?.show + if expectation.numberOfFulfillments == 0 { // Partial data + expect(show?.name).to(equal("The Scooby-Doo Show")) + + let scoobyDoo = show?.characters[0] + expect(scoobyDoo?.name).to(equal("Scooby-Doo")) + expect(scoobyDoo?.fragments.$deferredFriend).to(equal(.pending)) + expect(scoobyDoo?.__data._fulfilledFragments).to(equal([ + ObjectIdentifier(TVShowQuery.Data.Show.Character.self), + ])) + expect(scoobyDoo?.__data._deferredFragments).to(equal([ + ObjectIdentifier(TVShowQuery.Data.Show.Character.DeferredFriend.self), + ])) + + let shaggyRogers = show?.characters[1] + expect(shaggyRogers?.name).to(equal("Shaggy Rogers")) + expect(shaggyRogers?.fragments.$deferredFriend).to(equal(.pending)) + expect(shaggyRogers?.__data._fulfilledFragments).to(equal([ + ObjectIdentifier(TVShowQuery.Data.Show.Character.self), + ])) + expect(shaggyRogers?.__data._deferredFragments).to(equal([ + ObjectIdentifier(TVShowQuery.Data.Show.Character.DeferredFriend.self), + ])) + + let velmaDinkley = show?.characters[2] + expect(velmaDinkley?.name).to(equal("Velma Dinkley")) + expect(velmaDinkley?.fragments.$deferredFriend).to(equal(.pending)) + expect(velmaDinkley?.__data._fulfilledFragments).to(equal([ + ObjectIdentifier(TVShowQuery.Data.Show.Character.self), + ])) + expect(velmaDinkley?.__data._deferredFragments).to(equal([ + ObjectIdentifier(TVShowQuery.Data.Show.Character.DeferredFriend.self), + ])) + + } else { // Incremental data + expect(show?.name).to(equal("The Scooby-Doo Show")) + + let scoobyDoo = show?.characters[0] + expect(scoobyDoo?.name).to(equal("Scooby-Doo")) + expect(scoobyDoo?.fragments.deferredFriend?.friend).to(equal("Scrappy-Doo")) + expect(scoobyDoo?.__data._fulfilledFragments).to(equal([ + ObjectIdentifier(TVShowQuery.Data.Show.Character.self), + ObjectIdentifier(TVShowQuery.Data.Show.Character.DeferredFriend.self), + ])) + expect(scoobyDoo?.__data._deferredFragments).to(beEmpty()) + + let shaggyRogers = show?.characters[1] + expect(shaggyRogers?.name).to(equal("Shaggy Rogers")) + expect(shaggyRogers?.fragments.deferredFriend?.friend).to(equal("Scooby-Doo")) + expect(shaggyRogers?.__data._fulfilledFragments).to(equal([ + ObjectIdentifier(TVShowQuery.Data.Show.Character.self), + ObjectIdentifier(TVShowQuery.Data.Show.Character.DeferredFriend.self), + ])) + expect(scoobyDoo?.__data._deferredFragments).to(beEmpty()) + + let velmaDinkley = show?.characters[2] + expect(velmaDinkley?.name).to(equal("Velma Dinkley")) + expect(velmaDinkley?.fragments.deferredFriend?.friend).to(equal("Daphne Blake")) + expect(velmaDinkley?.__data._fulfilledFragments).to(equal([ + ObjectIdentifier(TVShowQuery.Data.Show.Character.self), + ObjectIdentifier(TVShowQuery.Data.Show.Character.DeferredFriend.self), + ])) + expect(scoobyDoo?.__data._deferredFragments).to(beEmpty()) + } + } + + wait(for: [expectation], timeout: defaultTimeout) + } + +} diff --git a/Tests/ApolloTests/GraphQLExecutor_SelectionSetMapper_FromResponse_Tests.swift b/Tests/ApolloTests/GraphQLExecutor_SelectionSetMapper_FromResponse_Tests.swift index d34e01e7a..7b831cd2d 100644 --- a/Tests/ApolloTests/GraphQLExecutor_SelectionSetMapper_FromResponse_Tests.swift +++ b/Tests/ApolloTests/GraphQLExecutor_SelectionSetMapper_FromResponse_Tests.swift @@ -895,6 +895,117 @@ class GraphQLExecutor_SelectionSetMapper_FromResponse_Tests: XCTestCase { XCTAssertEqual(data.child?.name, "Han Solo") } + func test__inlineFragment__givenDataForDeferredSelection_doesNotSelectDeferredFields() throws { + // given + class AnAnimal: MockSelectionSet { + typealias Schema = MockSchemaMetadata + + override class var __selections: [Selection] {[ + .field("animal", Animal.self), + ]} + + var animal: Animal { __data["animal"] } + + class Animal: AbstractMockSelectionSet { + override class var __selections: [Selection] {[ + .field("__typename", String.self), + .field("name", String.self), + .deferred(DeferredSpecies.self, label: "deferreSpecies"), + ]} + + struct Fragments: FragmentContainer { + public let __data: DataDict + public init(_dataDict: DataDict) { + __data = _dataDict + _deferredSpecies = Deferred(_dataDict: _dataDict) + } + + @Deferred var deferredSpecies: DeferredSpecies? + } + + class DeferredSpecies: MockTypeCase { + override class var __selections: [Selection] {[ + .field("species", String.self), + ]} + } + } + } + + let object: JSONObject = [ + "animal": [ + "__typename": "Animal", + "name": "Dog", + "species": "Canis familiaris", + ] + ] + + // when + let data = try readValues(AnAnimal.self, from: object) + + // then + XCTAssertEqual(data.animal.__typename, "Animal") + XCTAssertEqual(data.animal.name, "Dog") + + XCTAssertEqual(data.animal.fragments.$deferredSpecies, .pending) + XCTAssertNil(data.animal.fragments.deferredSpecies?.species) + } + + // MARK: Deferred Inline Fragments + + func test__deferredInlineFragment__whenExecutingOnDeferredInlineFragment_selectsFieldsAndFulfillsFragment() throws { + // given + class AnAnimal: MockSelectionSet { + typealias Schema = MockSchemaMetadata + + override class var __selections: [Selection] {[ + .field("animal", Animal.self), + ]} + + var animal: Animal { __data["animal"] } + + class Animal: AbstractMockSelectionSet { + override class var __selections: [Selection] {[ + .field("__typename", String.self), + .field("name", String.self), + .deferred(DeferredSpecies.self, label: "deferreSpecies"), + ]} + + struct Fragments: FragmentContainer { + public let __data: DataDict + public init(_dataDict: DataDict) { + __data = _dataDict + _deferredSpecies = Deferred(_dataDict: _dataDict) + } + + @Deferred var deferredSpecies: DeferredSpecies? + } + + class DeferredSpecies: MockTypeCase { + override class var __selections: [Selection] {[ + .field("species", String.self), + ]} + } + } + } + + let object: JSONObject = [ + "species": "Canis familiaris", + ] + + // when + let data = try GraphQLExecutor_SelectionSetMapper_FromResponse_Tests.executor.execute( + selectionSet: AnAnimal.Animal.DeferredSpecies.self, + in: MockQuery.self, + on: object, + accumulator: GraphQLSelectionSetMapper() + ) + + // then + XCTAssertEqual(data.species, "Canis familiaris") + + XCTAssertEqual(data.__data._fulfilledFragments, [ObjectIdentifier(AnAnimal.Animal.DeferredSpecies.self)]) + } + // MARK: - Fragments func test__fragment__asObjectType_matchingParentType_selectsFragmentFields() throws { diff --git a/Tests/ApolloTests/GraphQLResultTests.swift b/Tests/ApolloTests/GraphQLResultTests.swift index 3547f4e59..e57e4efb5 100644 --- a/Tests/ApolloTests/GraphQLResultTests.swift +++ b/Tests/ApolloTests/GraphQLResultTests.swift @@ -1,27 +1,61 @@ import XCTest -import Apollo +@testable import Apollo import ApolloAPI import ApolloInternalTestHelpers -import StarWarsAPI final class GraphQLResultTests: XCTestCase { - - override func setUpWithError() throws { - try super.setUpWithError() - } - override func tearDownWithError() throws { - try super.tearDownWithError() + // given + private class MockHeroQuery: MockQuery { + class Data: MockSelectionSet { + override class var __selections: [Selection] { [ + .field("hero", Hero?.self) + ]} + + public var hero: Hero? { __data["hero"] } + + class Hero: AbstractMockSelectionSet { + override class var __selections: [Selection] {[ + .field("__typename", String.self), + .field("name", String.self), + .deferred(DeferredFriends.self, label: "deferredFriends"), + ]} + + var name: String { __data["name"] } + + struct Fragments: FragmentContainer { + public let __data: DataDict + public init(_dataDict: DataDict) { + __data = _dataDict + _deferredFriends = Deferred(_dataDict: _dataDict) + } + + @Deferred var deferredFriends: DeferredFriends? + } + + class DeferredFriends: MockTypeCase { + override class var __selections: [Selection] {[ + .field("friends", [String].self) + ]} + + var friends: [String] { __data["friends"] } + } + } + } } - + + // MARK: JSON conversion tests + func test__result__givenResponseWithData_convertsToJSON() throws { - let jsonObj: [String: AnyHashable] = [ + // given + let heroData = try MockHeroQuery.Data(data: [ "hero": [ "name": "Luke Skywalker", "__typename": "Human" ] - ] - let heroData = try StarWarsAPI.HeroNameQuery.Data(data: jsonObj) + ]) + + // when let result = GraphQLResult( data: heroData, extensions: nil, @@ -29,7 +63,7 @@ final class GraphQLResultTests: XCTestCase { source: .server, dependentKeys: nil ) - + let expectedJSON: [String: Any] = [ "data": [ "hero": [ @@ -38,16 +72,19 @@ final class GraphQLResultTests: XCTestCase { ] ] ] - + + // then let convertedJSON = result.asJSONDictionary() XCTAssertEqual(convertedJSON, expectedJSON) } func test__result__givenResponseWithNullData_convertsToJSON() throws { - let jsonObj: [String: AnyHashable] = [ + // given + let heroData = try MockHeroQuery.Data(data: [ "hero": NSNull() - ] - let heroData = try StarWarsAPI.HeroNameQuery.Data(data: jsonObj) + ]) + + // when let result = GraphQLResult( data: heroData, extensions: nil, @@ -55,19 +92,21 @@ final class GraphQLResultTests: XCTestCase { source: .server, dependentKeys: nil ) - + let expectedJSON: [String: Any] = [ "data": [ "hero": NSNull() ] ] - + + // then let convertedJSON = result.asJSONDictionary() XCTAssertEqual(convertedJSON, expectedJSON) } func test__result__givenResponseWithErrors_convertsToJSON() throws { - let jsonObj: [String: AnyHashable] = [ + // given + let error = GraphQLError([ "message": "Sample error message", "locations": [ "line": 1, @@ -79,17 +118,17 @@ final class GraphQLResultTests: XCTestCase { "extensions": [ "test": "extension" ] - ] - - let error = GraphQLError(jsonObj) - let result = GraphQLResult( + ]) + + // when + let result = GraphQLResult( data: nil, extensions: nil, errors: [error], source: .server, dependentKeys: nil ) - + let expectedJSON: [String: Any] = [ "errors": [ [ @@ -107,9 +146,206 @@ final class GraphQLResultTests: XCTestCase { ] ] ] - + + // then let convertedJSON = result.asJSONDictionary() XCTAssertEqual(convertedJSON, expectedJSON) } + // MARK: Incremental merging tests + + func test__merging__givenIncrementalData_shouldMergeData() throws { + // given + let resultData = try MockHeroQuery.Data(data: [ + "hero": [ + "__typename": "Human", + "name": "Luke Skywalker", + ] + ]) + + let incrementalData = try MockHeroQuery.Data.Hero.DeferredFriends( + data: [ + "friends": [ + "Obi-Wan Kenobi", + "Han Solo", + ], + ], + in: MockHeroQuery.self + ) + + // when + let result = GraphQLResult( + data: resultData, + extensions: nil, + errors: nil, + source: .server, + dependentKeys: nil + ) + + let incremental = IncrementalGraphQLResult( + label: "deferredFriends", + path: [.field("hero")], + data: incrementalData, + extensions: nil, + errors: nil, + dependentKeys: nil + ) + + let merged = try result.merging(incremental) + + let expected = [ + "data": [ + "hero": [ + "__typename": "Human", + "name": "Luke Skywalker", + "friends": [ + "Obi-Wan Kenobi", + "Han Solo", + ], + ] + ] + ] + + // then + XCTAssertEqual(merged.asJSONDictionary(), expected) + XCTAssertEqual(merged.source, GraphQLResult.Source.server) + + XCTAssertNil(merged.extensions) + XCTAssertNil(merged.errors) + XCTAssertNil(merged.dependentKeys) + } + + func test__merging__givenIncrementalErrors_shouldMergeErrors() throws { + // given + let result = GraphQLResult( + data: nil, + extensions: nil, + errors: [GraphQLError("Base Error")], + source: .server, + dependentKeys: nil + ) + + let incremental = IncrementalGraphQLResult( + label: "deferredFriends", + path: [], + data: nil, + extensions: nil, + errors: [GraphQLError("Incremental Error")], + dependentKeys: nil + ) + + // when + let merged = try result.merging(incremental) + + let expected = [ + GraphQLError("Base Error"), + GraphQLError("Incremental Error"), + ] + + // then + XCTAssertEqual(merged.errors, expected) + + XCTAssertNil(merged.data) + XCTAssertNil(merged.extensions) + XCTAssertNil(merged.dependentKeys) + } + + func test__merging__givenIncrementalExtensions_shouldMergeExtensions() throws { + // given + let result = GraphQLResult( + data: nil, + extensions: ["FeatureA": true], + errors: nil, + source: .server, + dependentKeys: nil + ) + + let incremental = IncrementalGraphQLResult( + label: "deferredFriends", + path: [], + data: nil, + extensions: ["FeatureZ": false], + errors: nil, + dependentKeys: nil + ) + + let merged = try result.merging(incremental) + + let expected = [ + "FeatureA": true, + "FeatureZ": false, + ] + + // then + XCTAssertEqual(merged.extensions, expected) + + XCTAssertNil(merged.data) + XCTAssertNil(merged.errors) + XCTAssertNil(merged.dependentKeys) + } + + func test__merging__givenIncrementalDependentKeys_shouldMergeDependentKeys() throws { + // given + let result = GraphQLResult( + data: nil, + extensions: nil, + errors: nil, + source: .server, + dependentKeys: [try CacheKey(_jsonValue: "SomeKey")] + ) + + let incremental = IncrementalGraphQLResult( + label: "deferredFriends", + path: [], + data: nil, + extensions: nil, + errors: nil, + dependentKeys: [try CacheKey(_jsonValue: "AnotherKey")] + ) + + let merged = try result.merging(incremental) + + let expected: Set = [ + try CacheKey(_jsonValue: "SomeKey"), + try CacheKey(_jsonValue: "AnotherKey"), + ] + + // then + XCTAssertEqual(merged.dependentKeys, expected) + + XCTAssertNil(merged.data) + XCTAssertNil(merged.errors) + XCTAssertNil(merged.extensions) + } + +} + +extension Deferrable { + + /// Initializes a `Deferrable` `SelectionSet` with a raw JSON response object. + /// + /// The process of converting a JSON response into `SelectionSetData` is done by using a + /// `GraphQLExecutor` with a`GraphQLSelectionSetMapper` to parse, validate, and transform + /// the JSON response data into the format expected by the `Deferrable` `SelectionSet`. + /// + /// - Parameters: + /// - data: A dictionary representing a JSON response object for a GraphQL object. + /// - operation: The operation which contains `data`. + fileprivate init( + data: JSONObject, + in operation: any GraphQLOperation.Type + ) throws { + let accumulator = GraphQLSelectionSetMapper( + handleMissingValues: .allowForOptionalFields + ) + let executor = GraphQLExecutor(executionSource: NetworkResponseExecutionSource()) + + self = try executor.execute( + selectionSet: Self.self, + in: operation, + on: data, + accumulator: accumulator + ) + } + } diff --git a/Tests/ApolloTests/IncrementalGraphQLResponseTests.swift b/Tests/ApolloTests/IncrementalGraphQLResponseTests.swift new file mode 100644 index 000000000..1b8397d26 --- /dev/null +++ b/Tests/ApolloTests/IncrementalGraphQLResponseTests.swift @@ -0,0 +1,155 @@ +import XCTest +@testable import Apollo +import ApolloAPI +import ApolloInternalTestHelpers +import Nimble + +final class IncrementalGraphQLResponseTests: XCTestCase { + + class DeferredQuery: MockQuery { + class Data: MockSelectionSet { + override class var __selections: [Selection] {[ + .field("animal", Animal.self), + ]} + + class Animal: AbstractMockSelectionSet { + override class var __selections: [Selection] {[ + .field("__typename", String.self), + .field("name", String.self), + .field("species", String.self), + .deferred(DeferredFriend.self, label: "deferredFriend"), + .deferred(DeliberatelyMissing.self, label: "deliberatelyMissing"), + ]} + + struct Fragments: FragmentContainer { + public let __data: DataDict + public init(_dataDict: DataDict) { + __data = _dataDict + _deferredFriend = Deferred(_dataDict: _dataDict) + _deliberatelyMissing = Deferred(_dataDict: _dataDict) + } + + @Deferred var deferredFriend: DeferredFriend? + @Deferred var deliberatelyMissing: DeliberatelyMissing? + } + + class DeferredFriend: MockTypeCase { + override class var __selections: [Selection] {[ + .field("friend", String.self), + ]} + } + + class DeliberatelyMissing: MockTypeCase { + override class var __selections: [Selection] {[ + .field("key", String.self), + ]} + } + } + } + + override class var deferredFragments: [DeferredFragmentIdentifier : any SelectionSet.Type]? {[ + DeferredFragmentIdentifier(label: "deferredFriend", fieldPath: ["animal"]): Data.Animal.DeferredFriend.self, + // Data.Animal.DeliberatelyMissing is intentionally not here for error testing + ]} + } + + // MARK: Error Tests + + func test__error__givenBodyWithMissingPath_whenInitializing_shouldThrow() throws { + // given + let jsonObject: JSONObject = [:] + + // when + then + expect(try IncrementalGraphQLResponse(operation: DeferredQuery(), body: jsonObject)).to( + throwError(IncrementalGraphQLResponse.ResponseError.missingPath) + ) + } + + func test__error__givenBodyWithMissingLabel_whenParsing_shouldThrow() throws { + // given + let jsonObject: JSONObject = ["path": ["something"]] + + let subject = try IncrementalGraphQLResponse(operation: DeferredQuery(), body: jsonObject) + + // when + then + expect(try subject.parseIncrementalResult()).to( + throwError(IncrementalGraphQLResponse.ResponseError.missingLabel) + ) + } + + func test__error__givenValidIncrementalBody_whenParsingWithMissingDeferredSelectionSet_shouldThrow() throws { + // given + let jsonObject: JSONObject = [ + "label": "deliberatelyMissing", + "path": ["one", "two", "three"], + "data": [ + "key": "value" + ] + ] + + let subject = try IncrementalGraphQLResponse(operation: DeferredQuery(), body: jsonObject) + + // when + then + expect(try subject.parseIncrementalResult()).to( + throwError(IncrementalGraphQLResponse.ResponseError.missingDeferredSelectionSetType("deliberatelyMissing", "one.two.three")) + ) + } + + // MARK: Cache Reference Tests + + func test__cacheReference__givenIncrementalBody_whenParsed_shouldAppendPathToRootCacheReference() throws { + // given + let jsonObject: JSONObject = [ + "label": "deferredFriend", + "path": ["animal"], + "data": [ + "friend": "Buster" + ] + ] + + let subject = try IncrementalGraphQLResponse(operation: DeferredQuery(), body: jsonObject) + + // when + then + let actual = try subject.parseIncrementalResult() + + expect(actual.dependentKeys).to(equal([CacheKey("QUERY_ROOT.animal.friend")])) + } + + // MARK: Parsing Tests + + func test__parsing__givenIncrementalBody_shouldSucceed() throws { + // given + let jsonObject: JSONObject = [ + "label": "deferredFriend", + "path": ["animal"], + "data": [ + "friend": "Buster" + ], + "errors": [ + [ + "message": "Forced error!" + ] + ], + "extensions": [ + "key": "value" + ] + ] + + let subject = try IncrementalGraphQLResponse(operation: DeferredQuery(), body: jsonObject) + + // when + then + let actual = try subject.parseIncrementalResult() + + expect(actual.label).to(equal("deferredFriend")) + expect(actual.path).to(equal([PathComponent("animal")])) + expect(actual.data as? DeferredQuery.Data.Animal.DeferredFriend).to( + equal(try DeferredQuery.Data.Animal.DeferredFriend(data: ["friend": "Buster"])) + ) + expect(actual.errors).to(equal([ + GraphQLError(["message": "Forced error!"]) + ])) + expect(actual.extensions).to(equal([ + "key": "value", + ])) + } +} diff --git a/Tests/ApolloTests/Interceptors/IncrementalJSONResponseParsingInterceptorTests.swift b/Tests/ApolloTests/Interceptors/IncrementalJSONResponseParsingInterceptorTests.swift new file mode 100644 index 000000000..287b64e60 --- /dev/null +++ b/Tests/ApolloTests/Interceptors/IncrementalJSONResponseParsingInterceptorTests.swift @@ -0,0 +1,357 @@ +import Apollo +import ApolloAPI +import ApolloInternalTestHelpers +import XCTest +import Nimble + +class IncrementalJSONResponseParsingInterceptorTests: XCTestCase { + + class CatQuery: MockQuery { + class CatSelectionSet: MockSelectionSet { + override class var __selections: [Selection] { [ + .field("isJellicle", Bool.self) + ]} + } + } + + class DogQuery: MockQuery { + class DogSelectionSet: MockSelectionSet { + override class var __selections: [Selection] { [ + .field("favouriteToy", String.self) + ]} + } + } + + let defaultTimeout = 0.5 + + // MARK: Errors + + func test__errors__givenNoResponse_shouldThrow() { + // given + let subject = InterceptorTester(interceptor: IncrementalJSONResponseParsingInterceptor()) + + let expectation = expectation(description: "Received callback") + + // when + subject.intercept( + request: .mock(operation: MockQuery.mock()), + response: nil + ) { result in + defer { + expectation.fulfill() + } + + // then + expect(result).to(beFailure { error in + expect(error).to( + matchError(IncrementalJSONResponseParsingInterceptor.ParsingError.noResponseToParse) + ) + }) + } + + wait(for: [expectation], timeout: defaultTimeout) + } + + func test_errors_givenEmptyDataResponse_shouldThrow() { + // given + let subject = InterceptorTester(interceptor: IncrementalJSONResponseParsingInterceptor()) + + let expectation = expectation(description: "Received callback") + + // when + subject.intercept( + request: .mock(operation: MockQuery.mock()), + response: .mock(data: Data()) + ) { result in + defer { + expectation.fulfill() + } + + // then + expect(result).to(beFailure { error in + expect(error).to( + matchError( + IncrementalJSONResponseParsingInterceptor.ParsingError.couldNotParseToJSON(data: Data()) + ) + ) + }) + } + + wait(for: [expectation], timeout: defaultTimeout) + } + + func test_errors_givenIncrementalResponse_withMismatchedPartialResult_shouldThrow() { + // given + let subject = InterceptorTester(interceptor: IncrementalJSONResponseParsingInterceptor()) + + let expectation = expectation(description: "Received callback") + expectation.expectedFulfillmentCount = 2 + + // when + subject.intercept( + request: .mock(operation: CatQuery()), + response: .mock(data: #"{"data":{"isJellicle":false}}"#.data(using: .utf8)!) + ) { result in + + // then + expect(result).to(beSuccess()) + expectation.fulfill() + + subject.intercept( + request: .mock(operation: DogQuery()), + response: .mock(data: #"{"incremental":{"favouriteToy":"Stick"}}"#.data(using: .utf8)!) + ) { result in + + expect(result).to(beFailure { error in + expect(error).to(matchError( + IncrementalJSONResponseParsingInterceptor.ParsingError.mismatchedCurrentResultType + )) + expectation.fulfill() + }) + } + } + + wait(for: [expectation], timeout: defaultTimeout) + } + + func test_errors_givenResponse_withMissingIncrementalKey_shouldThrow() { + // given + let subject = InterceptorTester(interceptor: IncrementalJSONResponseParsingInterceptor()) + + let expectation = expectation(description: "Received callback") + expectation.expectedFulfillmentCount = 2 + + // when + subject.intercept( + request: .mock(operation: CatQuery()), + response: .mock(data: #"{"data":{"isJellicle":false}}"#.data(using: .utf8)!) + ) { result in + + // then + expect(result).to(beSuccess()) + expectation.fulfill() + + subject.intercept( + request: .mock(operation: CatQuery()), + response: .mock(data: #"{"data":{"isJellicle":false}}"#.data(using: .utf8)!) + ) { result in + + expect(result).to(beFailure { error in + expect(error).to(matchError( + IncrementalJSONResponseParsingInterceptor.ParsingError.couldNotParseIncrementalJSON( + json: ["data": ["isJellicle": false]] + ) + )) + expectation.fulfill() + }) + } + } + + wait(for: [expectation], timeout: defaultTimeout) + } + + // MARK: Parsing tests + + class AnimalQuery: MockQuery { + class AnAnimal: MockSelectionSet { + typealias Schema = MockSchemaMetadata + + override class var __selections: [Selection] {[ + .field("animal", Animal.self), + ]} + + var animal: Animal { __data["animal"] } + + class Animal: AbstractMockSelectionSet { + override class var __selections: [Selection] {[ + .field("__typename", String.self), + .field("species", String.self), + .deferred(DeferredGenus.self, label: "deferredGenus"), + .deferred(DeferredFriend.self, label: "deferredFriend"), + ]} + + var species: String { __data["species"] } + + struct Fragments: FragmentContainer { + public let __data: DataDict + public init(_dataDict: DataDict) { + __data = _dataDict + _deferredGenus = Deferred(_dataDict: _dataDict) + _deferredFriend = Deferred(_dataDict: _dataDict) + } + + @Deferred var deferredGenus: DeferredGenus? + @Deferred var deferredFriend: DeferredFriend? + } + + class DeferredGenus: MockTypeCase { + override class var __selections: [Selection] {[ + .field("genus", String.self), + ]} + + var genus: String { __data["genus"] } + } + + class DeferredFriend: MockTypeCase { + override class var __selections: [Selection] {[ + .field("friend", String.self), + ]} + + var friend: String { __data["friend"] } + } + } + } + + override class var deferredFragments: [DeferredFragmentIdentifier : any SelectionSet.Type]? {[ + DeferredFragmentIdentifier(label: "deferredGenus", fieldPath: ["animal"]): AnAnimal.Animal.DeferredGenus.self, + DeferredFragmentIdentifier(label: "deferredFriend", fieldPath: ["animal"]): AnAnimal.Animal.DeferredFriend.self, + ]} + } + + func test__parsing__givenSingleIncrementalResult_shouldMergeResult() throws { + // given + let subject = InterceptorTester(interceptor: IncrementalJSONResponseParsingInterceptor()) + + let partialExpectation = expectation(description: "Received partial response callback") + let incrementalExpectation = expectation(description: "Received incremental response callback") + + // when + subject.intercept( + request: .mock(operation: AnimalQuery()), + response: .mock(data: """ + { + "data": { + "animal": { + "__typename": "Animal", + "species": "Canis Familiaris" + } + } + } + """.data(using: .utf8)!) + ) { result in + defer { + partialExpectation.fulfill() + } + + // then + expect(result).to(beSuccess()) + + let graphQLResult = try? result.get()?.parsedResponse + expect(graphQLResult?.data?.animal.species).to(equal("Canis Familiaris")) + expect(graphQLResult?.data?.animal.fragments.deferredGenus?.genus).to(beNil()) + expect(graphQLResult?.data?.animal.fragments.deferredFriend?.friend).to(beNil()) + } + + wait(for: [partialExpectation], timeout: defaultTimeout) + + subject.intercept( + request: .mock(operation: AnimalQuery()), + response: .mock(data: """ + { + "incremental": [{ + "label": "deferredGenus", + "data": { + "genus": "Canis" + }, + "path": [ + "animal" + ] + }] + } + """.data(using: .utf8)! + ) + ) { result in + defer { + incrementalExpectation.fulfill() + } + + expect(result).to(beSuccess()) + + let graphQLResult = try? result.get()?.parsedResponse + expect(graphQLResult?.data?.animal.species).to(equal("Canis Familiaris")) + expect(graphQLResult?.data?.animal.fragments.deferredGenus?.genus).to(equal("Canis")) + expect(graphQLResult?.data?.animal.fragments.deferredFriend?.friend).to(beNil()) + } + + wait(for: [incrementalExpectation], timeout: defaultTimeout) + } + + func test__parsing__givenMultipleIncrementalResults_shouldMergeResults() throws { + // given + let subject = InterceptorTester(interceptor: IncrementalJSONResponseParsingInterceptor()) + + let partialExpectation = expectation(description: "Received partial response callback") + let incrementalExpectation = expectation(description: "Received incremental response callback") + + // when + subject.intercept( + request: .mock(operation: AnimalQuery()), + response: .mock(data: """ + { + "data": { + "animal": { + "__typename": "Animal", + "species": "Canis Familiaris" + } + } + } + """.data(using: .utf8)!) + ) { result in + defer { + partialExpectation.fulfill() + } + + // then + expect(result).to(beSuccess()) + + let graphQLResult = try? result.get()?.parsedResponse + expect(graphQLResult?.data?.animal.species).to(equal("Canis Familiaris")) + expect(graphQLResult?.data?.animal.fragments.deferredGenus?.genus).to(beNil()) + expect(graphQLResult?.data?.animal.fragments.deferredFriend?.friend).to(beNil()) + } + + wait(for: [partialExpectation], timeout: defaultTimeout) + + subject.intercept( + request: .mock(operation: AnimalQuery()), + response: .mock(data: """ + { + "incremental": [ + { + "label": "deferredGenus", + "data": { + "genus": "Canis" + }, + "path": [ + "animal" + ] + }, + { + "label": "deferredFriend", + "data": { + "friend": "Buster" + }, + "path": [ + "animal" + ] + } + ] + } + """.data(using: .utf8)! + ) + ) { result in + defer { + incrementalExpectation.fulfill() + } + + expect(result).to(beSuccess()) + + let graphQLResult = try? result.get()?.parsedResponse + expect(graphQLResult?.data?.animal.species).to(equal("Canis Familiaris")) + expect(graphQLResult?.data?.animal.fragments.deferredGenus?.genus).to(equal("Canis")) + expect(graphQLResult?.data?.animal.fragments.deferredFriend?.friend).to(equal("Buster")) + } + + wait(for: [incrementalExpectation], timeout: defaultTimeout) + } +} diff --git a/Tests/ApolloTests/Interceptors/MultipartResponseDeferParserTests.swift b/Tests/ApolloTests/Interceptors/MultipartResponseDeferParserTests.swift index 57c37d88b..d291b2cdd 100644 --- a/Tests/ApolloTests/Interceptors/MultipartResponseDeferParserTests.swift +++ b/Tests/ApolloTests/Interceptors/MultipartResponseDeferParserTests.swift @@ -24,9 +24,10 @@ final class MultipartResponseDeferParserTests: XCTestCase { content-type: test/custom { - "data" : {Ï + "data" : { "key" : "value" - } + }, + "hasNext": true } --graphql """.crlfFormattedData() @@ -78,7 +79,7 @@ final class MultipartResponseDeferParserTests: XCTestCase { wait(for: [expectation], timeout: defaultTimeout) } - func test__error__givenChunk_withMissingPayload_shouldReturnError() throws { + func test__error__givenChunk_withMissingPartialOrIncrementalData_shouldReturnError() throws { let subject = InterceptorTester(interceptor: MultipartResponseParsingInterceptor()) let expectation = expectation(description: "Received callback") @@ -114,5 +115,145 @@ final class MultipartResponseDeferParserTests: XCTestCase { // MARK: Parsing tests - #warning("Need parsing tests - to be done after #3147") + func test__parsing__givenSingleChunk_shouldReturnSuccess() throws { + let subject = InterceptorTester(interceptor: MultipartResponseParsingInterceptor()) + + let expectation = expectation(description: "Received callback") + + let expected: JSONObject = [ + "data": [ + "key": "value" + ], + "hasNext": true + ] + + subject.intercept( + request: .mock(operation: MockQuery.mock()), + response: .mock( + headerFields: ["Content-Type": "multipart/mixed;boundary=graphql;deferSpec=20220824"], + data: """ + --graphql + content-type: application/json + + { + "data" : { + "key" : "value" + }, + "hasNext": true + } + --graphql + """.crlfFormattedData() + ) + ) { result in + defer { + expectation.fulfill() + } + + expect(result).to(beSuccess()) + + guard + let response = try! result.get(), + let deserialized = try! JSONSerialization.jsonObject(with: response.rawData) as? JSONObject + else { + return fail("data could not be deserialized!") + } + + expect(deserialized).to(equal(expected)) + } + + wait(for: [expectation], timeout: defaultTimeout) + } + + func test__parsing__givenMultipleChunks_shouldReturnMultipleSuccess() throws { + let subject = InterceptorTester(interceptor: MultipartResponseParsingInterceptor()) + + let expectation = expectation(description: "Received callback") + expectation.expectedFulfillmentCount = 2 + + var expected: [JSONObject] = [ + [ + "data": [ + "__typename": "AnAnimal", + "animal": [ + "__typename": "Animal", + "species": "Canis familiaris" + ] + ], + "hasNext": true + ], + [ + "incremental": [ + [ + "label": "deferredGenus", + "data": [ + "genus": "Canis" + ], + "path": [ + "animal" + ] + ] + ], + "hasNext": false + ] + ] + + subject.intercept( + request: .mock(operation: MockQuery.mock()), + response: .mock( + headerFields: ["Content-Type": "multipart/mixed;boundary=graphql;deferSpec=20220824"], + data: """ + --graphql + content-type: application/json + + { + "data": { + "__typename": "AnAnimal", + "animal": { + "__typename": "Animal", + "species": "Canis familiaris" + } + }, + "hasNext": true + } + --graphql + content-type: application/json + + { + "incremental": [ + { + "label": "deferredGenus", + "data": { + "genus": "Canis" + }, + "path": [ + "animal" + ] + } + ], + "hasNext": false + } + --graphql + """.crlfFormattedData() + ) + ) { result in + defer { + expectation.fulfill() + } + + expect(result).to(beSuccess()) + + guard + let response = try! result.get(), + let deserialized = try! JSONSerialization.jsonObject(with: response.rawData) as? JSONObject + else { + return fail("data could not be deserialized!") + } + + expect(expected).to(contain(deserialized)) + expected.removeAll(where: { $0 == deserialized }) + } + + wait(for: [expectation], timeout: defaultTimeout) + expect(expected).to(beEmpty()) + } } diff --git a/Tests/ApolloTests/Interceptors/MultipartResponseSubscriptionParserTests.swift b/Tests/ApolloTests/Interceptors/MultipartResponseSubscriptionParserTests.swift index b63964f8b..8be916e90 100644 --- a/Tests/ApolloTests/Interceptors/MultipartResponseSubscriptionParserTests.swift +++ b/Tests/ApolloTests/Interceptors/MultipartResponseSubscriptionParserTests.swift @@ -310,11 +310,23 @@ final class MultipartResponseSubscriptionParserTests: XCTestCase { expectation.expectedFulfillmentCount = 2 _ = network.send(operation: MockSubscription