diff --git a/SwiftyStoreKit.xcodeproj/project.pbxproj b/SwiftyStoreKit.xcodeproj/project.pbxproj index 4ab6842f..bfcace7f 100644 --- a/SwiftyStoreKit.xcodeproj/project.pbxproj +++ b/SwiftyStoreKit.xcodeproj/project.pbxproj @@ -47,6 +47,7 @@ 65BB6CE81DDB018900218A0B /* SwiftyStoreKit+Types.swift in Sources */ = {isa = PBXBuildFile; fileRef = 65BB6CE71DDB018900218A0B /* SwiftyStoreKit+Types.swift */; }; 65BB6CE91DDB018900218A0B /* SwiftyStoreKit+Types.swift in Sources */ = {isa = PBXBuildFile; fileRef = 65BB6CE71DDB018900218A0B /* SwiftyStoreKit+Types.swift */; }; 65BB6CEA1DDB018900218A0B /* SwiftyStoreKit+Types.swift in Sources */ = {isa = PBXBuildFile; fileRef = 65BB6CE71DDB018900218A0B /* SwiftyStoreKit+Types.swift */; }; + 65BF8E301F4AEEBA00CBFC00 /* ProductsInfoControllerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 65BF8E2F1F4AEEBA00CBFC00 /* ProductsInfoControllerTests.swift */; }; 65CEF0F41ECC80D9007DC3B6 /* InAppReceiptVerificatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 65CEF0F31ECC80D9007DC3B6 /* InAppReceiptVerificatorTests.swift */; }; 65E9E0791ECADF5E005CF7B4 /* InAppReceiptVerificator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 65E9E0781ECADF5E005CF7B4 /* InAppReceiptVerificator.swift */; }; 65E9E07A1ECADF5E005CF7B4 /* InAppReceiptVerificator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 65E9E0781ECADF5E005CF7B4 /* InAppReceiptVerificator.swift */; }; @@ -183,6 +184,7 @@ 658A084B1E2EC5960074A98F /* PaymentQueueSpy.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PaymentQueueSpy.swift; sourceTree = ""; }; 65B8C9281EC0BE62009439D9 /* InAppReceiptTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = InAppReceiptTests.swift; sourceTree = ""; }; 65BB6CE71DDB018900218A0B /* SwiftyStoreKit+Types.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "SwiftyStoreKit+Types.swift"; sourceTree = ""; }; + 65BF8E2F1F4AEEBA00CBFC00 /* ProductsInfoControllerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductsInfoControllerTests.swift; sourceTree = ""; }; 65CEF0F31ECC80D9007DC3B6 /* InAppReceiptVerificatorTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = InAppReceiptVerificatorTests.swift; sourceTree = ""; }; 65E9E0781ECADF5E005CF7B4 /* InAppReceiptVerificator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = InAppReceiptVerificator.swift; sourceTree = ""; }; 65F70AC61E2ECBB300BF040D /* PaymentTransactionObserverFake.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PaymentTransactionObserverFake.swift; sourceTree = ""; }; @@ -345,6 +347,7 @@ 65F70AC61E2ECBB300BF040D /* PaymentTransactionObserverFake.swift */, C3099C081E2FCE3A00392A54 /* TestProduct.swift */, C3099C0A1E2FD13200392A54 /* TestPaymentTransaction.swift */, + 65BF8E2F1F4AEEBA00CBFC00 /* ProductsInfoControllerTests.swift */, ); path = SwiftyStoreKitTests; sourceTree = ""; @@ -774,6 +777,7 @@ files = ( C3099C071E2FCDAA00392A54 /* PaymentsControllerTests.swift in Sources */, 650307F21E3163AA001332A4 /* RestorePurchasesControllerTests.swift in Sources */, + 65BF8E301F4AEEBA00CBFC00 /* ProductsInfoControllerTests.swift in Sources */, 65CEF0F41ECC80D9007DC3B6 /* InAppReceiptVerificatorTests.swift in Sources */, C3099C0B1E2FD13200392A54 /* TestPaymentTransaction.swift in Sources */, 65F70AC71E2ECBB300BF040D /* PaymentTransactionObserverFake.swift in Sources */, diff --git a/SwiftyStoreKit/InAppProductQueryRequest.swift b/SwiftyStoreKit/InAppProductQueryRequest.swift index 40134148..880a2262 100644 --- a/SwiftyStoreKit/InAppProductQueryRequest.swift +++ b/SwiftyStoreKit/InAppProductQueryRequest.swift @@ -26,24 +26,16 @@ import StoreKit typealias InAppProductRequestCallback = (RetrieveResults) -> Void -protocol InAppProductRetriever: class { - func retrieveProducts(productIds: Set, callback: @escaping InAppProductRequestCallback) -> InAppProductQueryRequest +protocol InAppProductRequest: class { + func start() + func cancel() } -class InAppProductQueryRetriever: InAppProductRetriever { - - func retrieveProducts(productIds: Set, callback: @escaping InAppProductRequestCallback) -> InAppProductQueryRequest { - let request = InAppProductQueryRequest(productIds: productIds, callback: callback) - request.start() - return request - } -} - -class InAppProductQueryRequest: NSObject, SKProductsRequestDelegate { +class InAppProductQueryRequest: NSObject, InAppProductRequest, SKProductsRequestDelegate { private let callback: InAppProductRequestCallback private let request: SKProductsRequest - // http://stackoverflow.com/questions/24011575/what-is-the-difference-between-a-weak-reference-and-an-unowned-reference + deinit { request.delegate = nil } diff --git a/SwiftyStoreKit/ProductsInfoController.swift b/SwiftyStoreKit/ProductsInfoController.swift index 2590790d..cb5bff48 100644 --- a/SwiftyStoreKit/ProductsInfoController.swift +++ b/SwiftyStoreKit/ProductsInfoController.swift @@ -25,36 +25,51 @@ import Foundation import StoreKit +protocol InAppProductRequestBuilder: class { + func request(productIds: Set, callback: @escaping InAppProductRequestCallback) -> InAppProductRequest +} + +class InAppProductQueryRequestBuilder: InAppProductRequestBuilder { + + func request(productIds: Set, callback: @escaping InAppProductRequestCallback) -> InAppProductRequest { + return InAppProductQueryRequest(productIds: productIds, callback: callback) + } +} + class ProductsInfoController: NSObject { struct InAppProductQuery { - let request: InAppProductQueryRequest + let request: InAppProductRequest var completionHandlers: [InAppProductRequestCallback] } - let inAppProductRetriever: InAppProductRetriever - init(inAppProductRetriever: InAppProductRetriever = InAppProductQueryRetriever()) { - self.inAppProductRetriever = inAppProductRetriever + let inAppProductRequestBuilder: InAppProductRequestBuilder + init(inAppProductRequestBuilder: InAppProductRequestBuilder = InAppProductQueryRequestBuilder()) { + self.inAppProductRequestBuilder = inAppProductRequestBuilder } - // As we can have multiple inflight queries and purchases, we store them in a dictionary by product id - private var inflightQueries: [Set: InAppProductQuery] = [:] + // As we can have multiple inflight requests, we store them in a dictionary by product ids + private var inflightRequests: [Set: InAppProductQuery] = [:] func retrieveProductsInfo(_ productIds: Set, completion: @escaping (RetrieveResults) -> Void) { - if inflightQueries[productIds] == nil { - let request = self.inAppProductRetriever.retrieveProducts(productIds: productIds) { results in + if inflightRequests[productIds] == nil { + let request = inAppProductRequestBuilder.request(productIds: productIds) { results in - if let query = self.inflightQueries[productIds] { + if let query = self.inflightRequests[productIds] { for completion in query.completionHandlers { completion(results) } - self.inflightQueries[productIds] = nil + self.inflightRequests[productIds] = nil + } else { + // should not get here, but if it does it seems reasonable to call the outer completion block + completion(results) } } - inflightQueries[productIds] = InAppProductQuery(request: request, completionHandlers: [completion]) + inflightRequests[productIds] = InAppProductQuery(request: request, completionHandlers: [completion]) + request.start() } else { - inflightQueries[productIds]!.completionHandlers.append(completion) + inflightRequests[productIds]!.completionHandlers.append(completion) } } } diff --git a/SwiftyStoreKitTests/ProductsInfoControllerTests.swift b/SwiftyStoreKitTests/ProductsInfoControllerTests.swift new file mode 100644 index 00000000..64a92106 --- /dev/null +++ b/SwiftyStoreKitTests/ProductsInfoControllerTests.swift @@ -0,0 +1,120 @@ +// +// ProductsInfoControllerTests.swift +// SwiftyStoreKit +// +// Copyright (c) 2017 Andrea Bizzotto (bizz84@gmail.com) +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. + +import XCTest +@testable import SwiftyStoreKit + +class TestInAppProductRequest: InAppProductRequest { + + private let productIds: Set + private let callback: InAppProductRequestCallback + + init(productIds: Set, callback: @escaping InAppProductRequestCallback) { + self.productIds = productIds + self.callback = callback + } + + func start() { + + } + func cancel() { + + } + + func fireCallback() { + callback(RetrieveResults(retrievedProducts: [], invalidProductIDs: [], error: nil)) + } +} + +class TestInAppProductRequestBuilder: InAppProductRequestBuilder { + + var requests: [ TestInAppProductRequest ] = [] + + func request(productIds: Set, callback: @escaping InAppProductRequestCallback) -> InAppProductRequest { + let request = TestInAppProductRequest(productIds: productIds, callback: callback) + requests.append(request) + return request + } + + func fireCallbacks() { + requests.forEach { + $0.fireCallback() + } + requests = [] + } +} + +class ProductsInfoControllerTests: XCTestCase { + + let sampleProductIdentifiers: Set = ["com.iap.purchase1"] + + func testRetrieveProductsInfo_when_calledOnce_then_completionCalledOnce() { + + let requestBuilder = TestInAppProductRequestBuilder() + let productInfoController = ProductsInfoController(inAppProductRequestBuilder: requestBuilder) + + var completionCount = 0 + productInfoController.retrieveProductsInfo(sampleProductIdentifiers) { _ in + completionCount += 1 + } + requestBuilder.fireCallbacks() + + XCTAssertEqual(completionCount, 1) + } + + func testRetrieveProductsInfo_when_calledTwiceConcurrently_then_eachCompletionCalledOnce() { + + let requestBuilder = TestInAppProductRequestBuilder() + let productInfoController = ProductsInfoController(inAppProductRequestBuilder: requestBuilder) + + var completionCount = 0 + productInfoController.retrieveProductsInfo(sampleProductIdentifiers) { _ in + completionCount += 1 + } + productInfoController.retrieveProductsInfo(sampleProductIdentifiers) { _ in + completionCount += 1 + } + requestBuilder.fireCallbacks() + + XCTAssertEqual(completionCount, 2) + } + func testRetrieveProductsInfo_when_calledTwiceNotConcurrently_then_eachCompletionCalledOnce() { + + let requestBuilder = TestInAppProductRequestBuilder() + let productInfoController = ProductsInfoController(inAppProductRequestBuilder: requestBuilder) + + var completionCount = 0 + productInfoController.retrieveProductsInfo(sampleProductIdentifiers) { _ in + completionCount += 1 + } + requestBuilder.fireCallbacks() + XCTAssertEqual(completionCount, 1) + + productInfoController.retrieveProductsInfo(sampleProductIdentifiers) { _ in + completionCount += 1 + } + requestBuilder.fireCallbacks() + XCTAssertEqual(completionCount, 2) + } +}