ソースを参照

Merge pull request #259 from bizz84/bugfix/ProductsInfoController-multiple-completion-blocks

ProductsInfoController: Keep track of multiple completion blocks for the same request
Andrea Bizzotto 8 年 前
コミット
5cf1ba9b7e

+ 4 - 0
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 = "<group>"; };
 		65B8C9281EC0BE62009439D9 /* InAppReceiptTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = InAppReceiptTests.swift; sourceTree = "<group>"; };
 		65BB6CE71DDB018900218A0B /* SwiftyStoreKit+Types.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "SwiftyStoreKit+Types.swift"; sourceTree = "<group>"; };
+		65BF8E2F1F4AEEBA00CBFC00 /* ProductsInfoControllerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductsInfoControllerTests.swift; sourceTree = "<group>"; };
 		65CEF0F31ECC80D9007DC3B6 /* InAppReceiptVerificatorTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = InAppReceiptVerificatorTests.swift; sourceTree = "<group>"; };
 		65E9E0781ECADF5E005CF7B4 /* InAppReceiptVerificator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = InAppReceiptVerificator.swift; sourceTree = "<group>"; };
 		65F70AC61E2ECBB300BF040D /* PaymentTransactionObserverFake.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PaymentTransactionObserverFake.swift; sourceTree = "<group>"; };
@@ -345,6 +347,7 @@
 				65F70AC61E2ECBB300BF040D /* PaymentTransactionObserverFake.swift */,
 				C3099C081E2FCE3A00392A54 /* TestProduct.swift */,
 				C3099C0A1E2FD13200392A54 /* TestPaymentTransaction.swift */,
+				65BF8E2F1F4AEEBA00CBFC00 /* ProductsInfoControllerTests.swift */,
 			);
 			path = SwiftyStoreKitTests;
 			sourceTree = "<group>";
@@ -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 */,

+ 11 - 11
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<String>, callback: @escaping RequestCallback) {
+    init(productIds: Set<String>, 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<String>, callback: @escaping RequestCallback) -> InAppProductQueryRequest {
-        let request = InAppProductQueryRequest(productIds: productIds, callback: callback)
-        request.start()
-        return request
-    }
-
     func start() {
         request.start()
     }

+ 40 - 6
SwiftyStoreKit/ProductsInfoController.swift

@@ -25,17 +25,51 @@
 import Foundation
 import StoreKit
 
+protocol InAppProductRequestBuilder: class {
+    func request(productIds: Set<String>, callback: @escaping InAppProductRequestCallback) -> InAppProductRequest
+}
+
+class InAppProductQueryRequestBuilder: InAppProductRequestBuilder {
+    
+    func request(productIds: Set<String>, 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<String>: 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<String>: InAppProductQuery] = [:]
 
     func retrieveProductsInfo(_ productIds: Set<String>, 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)
         }
     }
 }

+ 120 - 0
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<String>
+    private let callback: InAppProductRequestCallback
+
+    init(productIds: Set<String>, 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<String>, 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<String> = ["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)
+    }
+}