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 0a8ae22a..880a2262 100644 --- a/SwiftyStoreKit/InAppProductQueryRequest.swift +++ b/SwiftyStoreKit/InAppProductQueryRequest.swift @@ -24,16 +24,22 @@ import StoreKit -class InAppProductQueryRequest: NSObject, SKProductsRequestDelegate { +typealias InAppProductRequestCallback = (RetrieveResults) -> Void - typealias RequestCallback = (RetrieveResults) -> Void - private let callback: RequestCallback +protocol InAppProductRequest: class { + func start() + func cancel() +} + +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 } - private init(productIds: Set, callback: @escaping RequestCallback) { + init(productIds: Set, callback: @escaping InAppProductRequestCallback) { self.callback = callback request = SKProductsRequest(productIdentifiers: productIds) @@ -41,12 +47,6 @@ class InAppProductQueryRequest: NSObject, SKProductsRequestDelegate { request.delegate = self } - class func startQuery(_ productIds: Set, callback: @escaping RequestCallback) -> InAppProductQueryRequest { - let request = InAppProductQueryRequest(productIds: productIds, callback: callback) - request.start() - return request - } - func start() { request.start() } diff --git a/SwiftyStoreKit/ProductsInfoController.swift b/SwiftyStoreKit/ProductsInfoController.swift index 01d49e0c..cb5bff48 100644 --- a/SwiftyStoreKit/ProductsInfoController.swift +++ b/SwiftyStoreKit/ProductsInfoController.swift @@ -25,17 +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 { - // As we can have multiple inflight queries and purchases, we store them in a dictionary by product id - private var inflightQueries: [Set: InAppProductQueryRequest] = [:] + struct InAppProductQuery { + let request: InAppProductRequest + var completionHandlers: [InAppProductRequestCallback] + } + + let inAppProductRequestBuilder: InAppProductRequestBuilder + init(inAppProductRequestBuilder: InAppProductRequestBuilder = InAppProductQueryRequestBuilder()) { + self.inAppProductRequestBuilder = inAppProductRequestBuilder + } + + // 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) { - inflightQueries[productIds] = InAppProductQueryRequest.startQuery(productIds) { result in - - self.inflightQueries[productIds] = nil - completion(result) + if inflightRequests[productIds] == nil { + let request = inAppProductRequestBuilder.request(productIds: productIds) { results in + + if let query = self.inflightRequests[productIds] { + for completion in query.completionHandlers { + completion(results) + } + self.inflightRequests[productIds] = nil + } else { + // should not get here, but if it does it seems reasonable to call the outer completion block + completion(results) + } + } + inflightRequests[productIds] = InAppProductQuery(request: request, completionHandlers: [completion]) + request.start() + } else { + 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) + } +}