From c85696c593cf7e8aa342643f1b33ebd22d18ce7f Mon Sep 17 00:00:00 2001 From: Mel <78050250+mludowise-stripe@users.noreply.github.com> Date: Tue, 16 Nov 2021 13:33:45 -0800 Subject: [PATCH] IDPROD-2742 part 4: Upload ID Document Images (#492) Now Identity document images are uploaded to our file upload endpoint as soon as the images are captured. When the user hits "continue" to finish the final step in document capture, the ViewController will: 1. Wait until the images are finished uploading (if they haven't finished yet) 2. Update the data store with the images and the resulting file ID 3. Save the IDs of the uploaded files to the `VerificationSessionData` endpoint #### Saving Images When testing on the simulator, I observed that it takes ~7 seconds for the files to finish uploading. We likely will need to downscale the images prior to uploading so it doesn't take so long (part of IDPROD-2482). We begin to upload the image as soon as we get a valid image capture from the camera feed, before the user confirms the image. In the event that the images are not done uploading by the time the user hits the final "Continue" button to go to the next screen, I added a `.saving` state to the view controller that will disable the button until it's done uploading and saving the data. We'll need to add a better loading state when we cleanup the UI to match the design (IDPROD-2756). #### Misc - Also added a `StripeFileMock` to `StripeCoreTestingUtils` to mock a file upload response. - Updated `VerificationSheetControllerTest` to load JSON mocks during setup as opposed to at the top of the file, since this effects our test env --- .../StripeCore.xcodeproj/project.pbxproj | 14 +++- .../StripeCore/Source/Helpers/Async.swift | 8 +- .../Mock Files/File_IdentityDocument.json | 7 ++ .../StripeCoreTestUtils/Mocks/MockData.swift | 10 +++ .../API Bindings/STPAPIClient+Identity.swift | 6 ++ .../API Mocking/MockIdentityAPIClient.swift | 8 ++ .../VerificationSheetAPIContent.swift | 2 +- .../VerificationSheetController.swift | 10 +++ .../DocumentCaptureViewController.swift | 83 +++++++++++++------ .../Helpers/IdentityAPIClientTestMock.swift | 6 ++ .../VerificationSheetControllerMock.swift | 17 +++- .../VerificationSheetControllerTest.swift | 79 +++++++++++++----- .../DocumentCaptureViewControllerTest.swift | 80 ++++++++++++++---- ...DocumentTypeSelectViewControllerTest.swift | 3 +- 14 files changed, 265 insertions(+), 68 deletions(-) create mode 100644 StripeCore/StripeCoreTestUtils/Mock Files/File_IdentityDocument.json diff --git a/StripeCore/StripeCore.xcodeproj/project.pbxproj b/StripeCore/StripeCore.xcodeproj/project.pbxproj index c7b86d51cc..d771510f74 100644 --- a/StripeCore/StripeCore.xcodeproj/project.pbxproj +++ b/StripeCore/StripeCore.xcodeproj/project.pbxproj @@ -56,6 +56,7 @@ E66784B126980677005F7CC8 /* StripeCoreBundleLocator.swift in Sources */ = {isa = PBXBuildFile; fileRef = E66784AF26980677005F7CC8 /* StripeCoreBundleLocator.swift */; }; E66784B226980677005F7CC8 /* BundleLocatorProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = E66784B026980677005F7CC8 /* BundleLocatorProtocol.swift */; }; E6752D7826F413A00062B821 /* String+StripeCore.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6752D7726F413A00062B821 /* String+StripeCore.swift */; }; + E67A1E632740525500977F63 /* File_IdentityDocument.json in Resources */ = {isa = PBXBuildFile; fileRef = E67A1E6027404FD500977F63 /* File_IdentityDocument.json */; }; E681E5662698EC9700692E45 /* NSError+StripeCore.swift in Sources */ = {isa = PBXBuildFile; fileRef = E681E5652698EC9700692E45 /* NSError+StripeCore.swift */; }; E69D640526855B260090B43D /* StripeCore.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = E69D63FB26855B250090B43D /* StripeCore.framework */; }; E69D640C26855B260090B43D /* StripeCore.h in Headers */ = {isa = PBXBuildFile; fileRef = E69D63FE26855B250090B43D /* StripeCore.h */; settings = {ATTRIBUTES = (Public, ); }; }; @@ -192,6 +193,7 @@ E66784AF26980677005F7CC8 /* StripeCoreBundleLocator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = StripeCoreBundleLocator.swift; sourceTree = ""; }; E66784B026980677005F7CC8 /* BundleLocatorProtocol.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BundleLocatorProtocol.swift; sourceTree = ""; }; E6752D7726F413A00062B821 /* String+StripeCore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+StripeCore.swift"; sourceTree = ""; }; + E67A1E6027404FD500977F63 /* File_IdentityDocument.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = File_IdentityDocument.json; sourceTree = ""; }; E681E5652698EC9700692E45 /* NSError+StripeCore.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "NSError+StripeCore.swift"; sourceTree = ""; }; E69D63FB26855B250090B43D /* StripeCore.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = StripeCore.framework; sourceTree = BUILT_PRODUCTS_DIR; }; E69D63FE26855B250090B43D /* StripeCore.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = StripeCore.h; sourceTree = ""; }; @@ -393,6 +395,14 @@ name = "Recovered References"; sourceTree = ""; }; + E67A1E5F27404FB500977F63 /* Mock Files */ = { + isa = PBXGroup; + children = ( + E67A1E6027404FD500977F63 /* File_IdentityDocument.json */, + ); + path = "Mock Files"; + sourceTree = ""; + }; E69D63F126855B250090B43D = { isa = PBXGroup; children = ( @@ -493,9 +503,10 @@ isa = PBXGroup; children = ( E6548EDE2728AEBE00F399B2 /* APIStubbedTestCase.swift */, - E6FB9BA9268EA7BD000FDB4F /* Mocks */, E61ADAA2270B925D004ED998 /* Categories */, E6FB9BBB268EA95F000FDB4F /* Info.plist */, + E67A1E5F27404FB500977F63 /* Mock Files */, + E6FB9BA9268EA7BD000FDB4F /* Mocks */, E6FB9BBA268EA95F000FDB4F /* StripeCoreTestUtils.h */, E6548EE52728AFB400F399B2 /* TestConstants.swift */, E6548F7E27339FB100F399B2 /* XCTestCase+Stripe.swift */, @@ -720,6 +731,7 @@ isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( + E67A1E632740525500977F63 /* File_IdentityDocument.json in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/StripeCore/StripeCore/Source/Helpers/Async.swift b/StripeCore/StripeCore/Source/Helpers/Async.swift index 91e665f679..18a361140f 100644 --- a/StripeCore/StripeCore/Source/Helpers/Async.swift +++ b/StripeCore/StripeCore/Source/Helpers/Async.swift @@ -93,12 +93,16 @@ } @_spi(STP) public class Promise: Future { - public init(value: Value? = nil) { + public override init() { super.init() + } + + public convenience init(value: Value) { + self.init() // If the value was already known at the time the promise // was constructed, we can report it directly: - result = value.map(Result.success) + result = .success(value) } public func resolve(with value: Value) { diff --git a/StripeCore/StripeCoreTestUtils/Mock Files/File_IdentityDocument.json b/StripeCore/StripeCoreTestUtils/Mock Files/File_IdentityDocument.json new file mode 100644 index 0000000000..bae03c3244 --- /dev/null +++ b/StripeCore/StripeCoreTestUtils/Mock Files/File_IdentityDocument.json @@ -0,0 +1,7 @@ +{ + "created": 1636833390, + "id": "file_id", + "purpose": "identity_document", + "size": 100, + "type": "jpg" +} diff --git a/StripeCore/StripeCoreTestUtils/Mocks/MockData.swift b/StripeCore/StripeCoreTestUtils/Mocks/MockData.swift index 9cc04458a6..e5cd5feb5a 100644 --- a/StripeCore/StripeCoreTestUtils/Mocks/MockData.swift +++ b/StripeCore/StripeCoreTestUtils/Mocks/MockData.swift @@ -33,3 +33,13 @@ public extension MockData { } } } + +// Dummy class to determine this bundle +private class ClassForBundle { } + +@_spi(STP) public enum FileMock: String, MockData { + public typealias ResponseType = StripeFile + public var bundle: Bundle { return Bundle(for: ClassForBundle.self) } + + case identityDocument = "File_IdentityDocument" +} diff --git a/StripeIdentity/StripeIdentity/Source/API Bindings/STPAPIClient+Identity.swift b/StripeIdentity/StripeIdentity/Source/API Bindings/STPAPIClient+Identity.swift index ece334b111..ca353df7ca 100644 --- a/StripeIdentity/StripeIdentity/Source/API Bindings/STPAPIClient+Identity.swift +++ b/StripeIdentity/StripeIdentity/Source/API Bindings/STPAPIClient+Identity.swift @@ -6,6 +6,7 @@ // import Foundation +import UIKit @_spi(STP) import StripeCore protocol IdentityAPIClient { @@ -18,6 +19,11 @@ protocol IdentityAPIClient { updating verificationData: VerificationSessionDataUpdate, ephemeralKeySecret: String ) -> Promise + + func uploadImage( + _ image: UIImage, + purpose: StripeFile.Purpose + ) -> Promise } extension STPAPIClient: IdentityAPIClient { diff --git a/StripeIdentity/StripeIdentity/Source/API Mocking/MockIdentityAPIClient.swift b/StripeIdentity/StripeIdentity/Source/API Mocking/MockIdentityAPIClient.swift index b4713881fd..58fc073b80 100644 --- a/StripeIdentity/StripeIdentity/Source/API Mocking/MockIdentityAPIClient.swift +++ b/StripeIdentity/StripeIdentity/Source/API Mocking/MockIdentityAPIClient.swift @@ -6,6 +6,7 @@ // import Foundation +import UIKit @_spi(STP) import StripeCore /** @@ -188,4 +189,11 @@ extension MockIdentityAPIClient: IdentityAPIClient { } ) } + + func uploadImage( + _ image: UIImage, + purpose: StripeFile.Purpose + ) -> Promise { + return STPAPIClient.shared.uploadImage(image, purpose: purpose) + } } diff --git a/StripeIdentity/StripeIdentity/Source/NativeComponents/Coordinators/VerificationSheetAPIContent.swift b/StripeIdentity/StripeIdentity/Source/NativeComponents/Coordinators/VerificationSheetAPIContent.swift index 3af3038c48..cec40722d1 100644 --- a/StripeIdentity/StripeIdentity/Source/NativeComponents/Coordinators/VerificationSheetAPIContent.swift +++ b/StripeIdentity/StripeIdentity/Source/NativeComponents/Coordinators/VerificationSheetAPIContent.swift @@ -27,7 +27,7 @@ struct VerificationSheetAPIContent { /// Server response from the last time the user's data was saved private(set) var sessionData: VerificationSessionData? = nil - private(set) var lastError: Error? = nil + var lastError: Error? = nil /// Status of the associated VerificationSession. var status: VerificationPage.Status? { diff --git a/StripeIdentity/StripeIdentity/Source/NativeComponents/Coordinators/VerificationSheetController.swift b/StripeIdentity/StripeIdentity/Source/NativeComponents/Coordinators/VerificationSheetController.swift index 82638bfaad..3b69ba002b 100644 --- a/StripeIdentity/StripeIdentity/Source/NativeComponents/Coordinators/VerificationSheetController.swift +++ b/StripeIdentity/StripeIdentity/Source/NativeComponents/Coordinators/VerificationSheetController.swift @@ -17,6 +17,8 @@ protocol VerificationSheetControllerProtocol: AnyObject { clientSecret: String ) + func uploadDocument(image: UIImage) -> Future + func saveData( completion: @escaping (VerificationSheetAPIContent) -> Void ) @@ -105,4 +107,12 @@ final class VerificationSheetController: VerificationSheetControllerProtocol { } } } + + /// Uploads a document image and returns a Future containing the ID of the uploaded file + func uploadDocument(image: UIImage) -> Future { + // TODO(mludowise|IDPROD-2482): Crop and downscale image for faster upload times + return apiClient.uploadImage(image, purpose: .identityDocument).chained { file in + return Promise(value: file.id) + } + } } diff --git a/StripeIdentity/StripeIdentity/Source/NativeComponents/ViewControllers/DocumentCaptureViewController.swift b/StripeIdentity/StripeIdentity/Source/NativeComponents/ViewControllers/DocumentCaptureViewController.swift index 8da75f586a..ccefd27d83 100644 --- a/StripeIdentity/StripeIdentity/Source/NativeComponents/ViewControllers/DocumentCaptureViewController.swift +++ b/StripeIdentity/StripeIdentity/Source/NativeComponents/ViewControllers/DocumentCaptureViewController.swift @@ -24,6 +24,8 @@ final class DocumentCaptureViewController: IdentityFlowViewController { case scanning(DocumentScanner.Classification) /// Successfully scanned the camera feed for the specified classification case scanned(DocumentScanner.Classification, UIImage) + /// Saving the captured data + case saving(lastImage: UIImage) } private(set) var state: State { @@ -79,7 +81,9 @@ final class DocumentCaptureViewController: IdentityFlowViewController { state: .videoPreview, instructionalText: "Position your passport in the center of the frame" ) - case .scanned(_, let image): + case .scanned(_, let image), + .saving(let image): + // TODO(mludowise|IDPROD-2756): Display some sort of loading indicator during "Saving" while we wait for the files to finish uploading return .init( state: .staticImage(image, contentMode: .scaleAspectFill), instructionalText: "✓ Scanned" @@ -107,6 +111,8 @@ final class DocumentCaptureViewController: IdentityFlowViewController { return true case .scanned: return false + case .saving: + return true } } @@ -133,9 +139,8 @@ final class DocumentCaptureViewController: IdentityFlowViewController { // The captured front document images to be saved to the API when continuing // from this screen - - var frontDocument: UIImage? - var backDocument: UIImage? + var frontUploadFuture: Future = Promise(value: nil) + var backUploadFuture: Future = Promise(value: nil) // MARK: Init @@ -208,22 +213,35 @@ extension DocumentCaptureViewController { } } + /// Starts uploading an image as soon as it's been scanned func handleScannedImage(pixelBuffer: CVPixelBuffer) { let ciImage = CIImage(cvPixelBuffer: pixelBuffer) let uiImage = UIImage(ciImage: ciImage) - switch state { - case .scanning(let classification): - if classification.isFront { - frontDocument = uiImage - } else { - backDocument = uiImage - } - state = .scanned(classification, uiImage) - default: + guard case let .scanning(classification) = state else { assertionFailure("state is '\(state)' but expected 'scanning'") return } + + // Set state back to scanned when we're done + defer { + state = .scanned(classification, uiImage) + } + + guard let sheetController = sheetController else { + return + } + + // Transform Future to return a `DocumentImage` containing the file ID and UIImage + let imageUploadFuture: Future = sheetController.uploadDocument(image: uiImage).chained { fileId in + return Promise(value: .init(image: uiImage, fileId: fileId)) + } + + if classification.isFront { + frontUploadFuture = imageUploadFuture + } else { + backUploadFuture = imageUploadFuture + } } func didTapButton() { @@ -231,26 +249,39 @@ extension DocumentCaptureViewController { case .interstitial(let classification): // TODO(mludowise|IDPROD-2775): Check camera permissions state = .scanning(classification) - case .scanning: - assertionFailure("Button should be disabled in state 'scanning'.") - case .scanned(let classification, _): + case .scanning, + .saving: + assertionFailure("Button should be disabled in state '\(state)'.") + case .scanned(let classification, let image): if let nextClassification = classification.nextClassification { state = .interstitial(nextClassification) } else { - saveDataAndTransition() + state = .saving(lastImage: image) + saveDataAndTransition(lastClassification: classification, lastImage: image) } } } - func saveDataAndTransition() { - // TODO: save image to uploads.stripe.com and use returned FileID - // Blocked by https://github.com/stripe-ios/stripe-ios/pull/479 - sheetController?.dataStore.frontDocumentImage = frontDocument.map { .init(image: $0, fileId: "") } - sheetController?.dataStore.backDocumentImage = backDocument.map { .init(image: $0, fileId: "") } - sheetController?.saveData(completion: { [weak sheetController] apiContent in - guard let sheetController = sheetController else { return } - sheetController.flowController.transitionToNextScreen(apiContent: apiContent, sheetController: sheetController) - }) + func saveDataAndTransition(lastClassification: DocumentScanner.Classification, lastImage: UIImage) { + frontUploadFuture.chained { [weak self] frontImage in + // Front upload is complete, update dataStore + self?.sheetController?.dataStore.frontDocumentImage = frontImage + return self?.backUploadFuture ?? Promise(value: nil) + }.chained { [weak sheetController] (backImage: VerificationSessionDataStore.DocumentImage?) -> Future<()> in + // Back upload is complete, update dataStore + sheetController?.dataStore.backDocumentImage = backImage + return Promise(value: ()) + }.observe { [weak self] _ in + // Both front & back uploads are complete, save data + guard let sheetController = self?.sheetController else { return } + sheetController.saveData { apiContent in + self?.state = .scanned(lastClassification, lastImage) + sheetController.flowController.transitionToNextScreen( + apiContent: apiContent, + sheetController: sheetController + ) + } + } } } diff --git a/StripeIdentity/StripeIdentityTests/Helpers/IdentityAPIClientTestMock.swift b/StripeIdentity/StripeIdentityTests/Helpers/IdentityAPIClientTestMock.swift index 8ad89902c0..d968c28e7a 100644 --- a/StripeIdentity/StripeIdentityTests/Helpers/IdentityAPIClientTestMock.swift +++ b/StripeIdentity/StripeIdentityTests/Helpers/IdentityAPIClientTestMock.swift @@ -6,6 +6,7 @@ // import Foundation +import UIKit @_spi(STP) import StripeCore @testable import StripeIdentity @@ -13,6 +14,7 @@ final class IdentityAPIClientTestMock: IdentityAPIClient { let verificationPage = MockAPIRequests() let verificationSessionData = MockAPIRequests<(id: String, data: VerificationSessionDataUpdate, ephemeralKey: String), VerificationSessionData>() + let imageUpload = MockAPIRequests<(image: UIImage, purpose: StripeFile.Purpose), StripeFile>() func postIdentityVerificationPage(clientSecret: String) -> Promise { return verificationPage.makeRequest(with: clientSecret) @@ -29,6 +31,10 @@ final class IdentityAPIClientTestMock: IdentityAPIClient { ephemeralKey: ephemeralKeySecret )) } + + func uploadImage(_ image: UIImage, purpose: StripeFile.Purpose) -> Promise { + return imageUpload.makeRequest(with: (image, purpose)) + } } class MockAPIRequests { diff --git a/StripeIdentity/StripeIdentityTests/Helpers/VerificationSheetControllerMock.swift b/StripeIdentity/StripeIdentityTests/Helpers/VerificationSheetControllerMock.swift index bd3417cc43..3d4a2b7acc 100644 --- a/StripeIdentity/StripeIdentityTests/Helpers/VerificationSheetControllerMock.swift +++ b/StripeIdentity/StripeIdentityTests/Helpers/VerificationSheetControllerMock.swift @@ -6,6 +6,9 @@ // import Foundation +import XCTest +import UIKit +@_spi(STP) import StripeCore @testable import StripeIdentity final class VerificationSheetControllerMock: VerificationSheetControllerProtocol { @@ -13,7 +16,9 @@ final class VerificationSheetControllerMock: VerificationSheetControllerProtocol let dataStore: VerificationSessionDataStore private(set) var didLoadAndUpdateUI = false - private(set) var didSaveData = false + private(set) var didRequestSaveData = false + private(set) var didFinishSaveDataExp = XCTestExpectation(description: "Saved data") + private(set) var numUploadedImages = 0 init( flowController: VerificationSheetFlowControllerMock, @@ -28,7 +33,15 @@ final class VerificationSheetControllerMock: VerificationSheetControllerProtocol } func saveData(completion: @escaping (VerificationSheetAPIContent) -> Void) { - didSaveData = true + didRequestSaveData = true + didFinishSaveDataExp.fulfill() completion(VerificationSheetAPIContent()) } + + func uploadDocument( + image: UIImage + ) -> Future { + numUploadedImages += 1 + return Promise(value: "") + } } diff --git a/StripeIdentity/StripeIdentityTests/Unit/NativeComponents/Coordinators/VerificationSheetControllerTest.swift b/StripeIdentity/StripeIdentityTests/Unit/NativeComponents/Coordinators/VerificationSheetControllerTest.swift index 933aa12b59..28bb00f317 100644 --- a/StripeIdentity/StripeIdentityTests/Unit/NativeComponents/Coordinators/VerificationSheetControllerTest.swift +++ b/StripeIdentity/StripeIdentityTests/Unit/NativeComponents/Coordinators/VerificationSheetControllerTest.swift @@ -6,17 +6,28 @@ // import XCTest +import UIKit +@_spi(STP) import StripeCore +@_spi(STP) import StripeCoreTestUtils @testable import StripeIdentity final class VerificationSheetControllerTest: XCTestCase { let mockSecret = "secret_123" - let mockStaticContent = try! VerificationPageMock.response200.make() + static var mockStaticContent: VerificationPage! private var controller: VerificationSheetController! private var mockAPIClient: IdentityAPIClientTestMock! private var exp: XCTestExpectation! + override class func setUp() { + super.setUp() + guard let mockStaticContent = try? VerificationPageMock.response200.make() else { + return XCTFail("Could not load mock data") + } + self.mockStaticContent = mockStaticContent + } + override func setUp() { super.setUp() @@ -26,7 +37,7 @@ final class VerificationSheetControllerTest: XCTestCase { exp = expectation(description: "Finished API call") } - func testValidVerificationPageResponse() throws { + func testLoadValidResponse() throws { let mockResponse = try VerificationPageMock.response200.make() // Load @@ -53,7 +64,7 @@ final class VerificationSheetControllerTest: XCTestCase { XCTAssertNil(controller.apiContent.lastError) } - func testErrorVerificationPageResponse() throws { + func testLoadErrorResponse() throws { let mockError = NSError(domain: "", code: 0, userInfo: nil) // Load @@ -72,14 +83,9 @@ final class VerificationSheetControllerTest: XCTestCase { XCTAssertNotNil(controller.apiContent.lastError) } - func testValidVerificationSessionDataResponse() throws { + func testSaveDataValidResponse() throws { let mockResponse = try VerificationSessionDataMock.response200.make() - - // Mock that a VerificationPage response has already been received - controller.apiContent.setStaticContent(result: .success(mockStaticContent)) - - // Mock that the user has entered data - controller.dataStore.biometricConsent = true + setUpForSaveData() // Save data controller.saveData { mutatedApiContent in @@ -90,8 +96,8 @@ final class VerificationSheetControllerTest: XCTestCase { // Verify 1 request made with Id, EAK, and collected data XCTAssertEqual(mockAPIClient.verificationSessionData.requestHistory.count, 1) - XCTAssertEqual(mockAPIClient.verificationSessionData.requestHistory.first?.id, mockStaticContent.id) - XCTAssertEqual(mockAPIClient.verificationSessionData.requestHistory.first?.ephemeralKey, mockStaticContent.ephemeralApiKey) + XCTAssertEqual(mockAPIClient.verificationSessionData.requestHistory.first?.id, VerificationSheetControllerTest.mockStaticContent.id) + XCTAssertEqual(mockAPIClient.verificationSessionData.requestHistory.first?.ephemeralKey, VerificationSheetControllerTest.mockStaticContent.ephemeralApiKey) XCTAssertEqual(mockAPIClient.verificationSessionData.requestHistory.first?.data, controller.dataStore.toAPIModel) // Verify response & error are nil until API responds to request @@ -109,14 +115,9 @@ final class VerificationSheetControllerTest: XCTestCase { XCTAssertNil(controller.apiContent.lastError) } - func testErrorVerificationSessionDataResponse() throws { + func testSaveDataErrorResponse() throws { let mockError = NSError(domain: "", code: 0, userInfo: nil) - - // Mock that a VerificationPage response has already been received - controller.apiContent.setStaticContent(result: .success(mockStaticContent)) - - // Mock that the user has entered data - controller.dataStore.biometricConsent = true + setUpForSaveData() // Save data controller.saveData { mutatedApiContent in @@ -125,7 +126,7 @@ final class VerificationSheetControllerTest: XCTestCase { self.exp.fulfill() } - // Respond to request with success + // Respond to request with failure mockAPIClient.verificationSessionData.respondToRequests(with: .failure(mockError)) // Verify completion block is called @@ -135,4 +136,42 @@ final class VerificationSheetControllerTest: XCTestCase { XCTAssertNil(controller.apiContent.sessionData) XCTAssertNotNil(controller.apiContent.lastError) } + + func testUploadDocument() throws { + let mockImage = UIImage() + let mockResponse = try FileMock.identityDocument.make() + + let uploadPromise = controller.uploadDocument(image: mockImage) + + uploadPromise.observe { [weak self] result in + switch result { + case .success(let response): + XCTAssertEqual(response, mockResponse.id) + case .failure(let error): + XCTFail("Expected success but instead found error \(error)") + } + self?.exp.fulfill() + } + + // Verify API request is made + XCTAssertEqual(mockAPIClient.imageUpload.requestHistory.count, 1) + XCTAssertEqual(mockAPIClient.imageUpload.requestHistory.first?.image, mockImage) + XCTAssertEqual(mockAPIClient.imageUpload.requestHistory.first?.purpose, .identityDocument) + + // Respond to request with success + mockAPIClient.imageUpload.respondToRequests(with: .success(mockResponse)) + + // Verify completion block is called + wait(for: [exp], timeout: 1) + } +} + +private extension VerificationSheetControllerTest { + func setUpForSaveData() { + // Mock that a VerificationPage response has already been received + controller.apiContent.setStaticContent(result: .success(VerificationSheetControllerTest.mockStaticContent)) + + // Mock that the user has entered data + controller.dataStore.biometricConsent = true + } } diff --git a/StripeIdentity/StripeIdentityTests/Unit/NativeComponents/ViewControllers/DocumentCaptureViewControllerTest.swift b/StripeIdentity/StripeIdentityTests/Unit/NativeComponents/ViewControllers/DocumentCaptureViewControllerTest.swift index 5c54f25dc1..4c1cb7d91a 100644 --- a/StripeIdentity/StripeIdentityTests/Unit/NativeComponents/ViewControllers/DocumentCaptureViewControllerTest.swift +++ b/StripeIdentity/StripeIdentityTests/Unit/NativeComponents/ViewControllers/DocumentCaptureViewControllerTest.swift @@ -81,6 +81,8 @@ final class DocumentCaptureViewControllerTest: XCTestCase { expectedState: .scanned(.idCardFront, UIImage()), isButtonDisabled: false ) + // Verify image started uploading + XCTAssertNotNil(vc.frontUploadFuture) } func testTransitionFromScannedCardFront() { @@ -116,12 +118,32 @@ final class DocumentCaptureViewControllerTest: XCTestCase { expectedState: .scanned(.idCardBack, UIImage()), isButtonDisabled: false ) + // Verify image started uploading + XCTAssertNotNil(vc.backUploadFuture) } func testTransitionFromScannedCardBack() { + // NOTE: Setting mock upload promises so that we can test the VC state + // before `saveDataAndTransition` finishes and the state is reset to + // `scanned`, otherwise the promises will resolve immediately + let mockFrontUploadFuture = Promise() + let mockBackUploadFuture = Promise() + let vc = makeViewController(state: .scanned(.idCardBack, UIImage())) + vc.frontUploadFuture = mockFrontUploadFuture + vc.backUploadFuture = mockBackUploadFuture vc.didTapButton() - wait(for: [mockFlowController.didTransitionToNextScreenExp], timeout: 1) + verify( + vc, + expectedState: .saving(lastImage: UIImage()), + isButtonDisabled: true + ) + // Mock that upload finishes + mockFrontUploadFuture.resolve(with: nil) + mockBackUploadFuture.resolve(with: nil) + + wait(for: [mockSheetController.didFinishSaveDataExp], timeout: 1) + XCTAssertTrue(mockSheetController.didRequestSaveData) } func testInitialStatePassport() { @@ -156,30 +178,58 @@ final class DocumentCaptureViewControllerTest: XCTestCase { expectedState: .scanned(.passport, UIImage()), isButtonDisabled: false ) + // Verify image started uploading + XCTAssertNotNil(vc.frontUploadFuture) } func testTransitionFromScannedPassport() { + // NOTE: Setting mock upload promise so that we can test the VC state + // before `saveDataAndTransition` finishes and the state is reset to + // `scanned`, otherwise the promises will resolve immediately + let mockFrontUploadFuture = Promise() + let vc = makeViewController(state: .scanned(.passport, UIImage())) + vc.frontUploadFuture = mockFrontUploadFuture vc.didTapButton() - wait(for: [mockFlowController.didTransitionToNextScreenExp], timeout: 1) + verify( + vc, + expectedState: .saving(lastImage: UIImage()), + isButtonDisabled: true + ) + // Mock that upload finishes + mockFrontUploadFuture.resolve(with: nil) + + wait(for: [mockSheetController.didFinishSaveDataExp], timeout: 1) + XCTAssertTrue(mockSheetController.didRequestSaveData) } func testSaveDataAndTransition() { - // TODO: Test that image is uploaded - // Blocked by https://github.com/stripe-ios/stripe-ios/pull/479 - - let mockFrontImage = UIImage() - let mockBackImage = UIImage() + let mockFrontImage = VerificationSessionDataStore.DocumentImage(image: UIImage(), fileId: "front_id") + let mockBackImage = VerificationSessionDataStore.DocumentImage(image: UIImage(), fileId: "back_id") + let mockLastClassification = DocumentScanner.Classification.idCardBack + // Mock that file has been captured and upload has begun let vc = makeViewController(documentType: .drivingLicense) - vc.frontDocument = mockFrontImage - vc.backDocument = mockBackImage + vc.frontUploadFuture = Promise(value: mockFrontImage) + vc.backUploadFuture = Promise(value: mockBackImage) - vc.saveDataAndTransition() - XCTAssertEqual(dataStore.frontDocumentImage, .init(image: mockFrontImage, fileId: "")) - XCTAssertEqual(dataStore.backDocumentImage, .init(image: mockBackImage, fileId: "")) - XCTAssertTrue(mockSheetController.didSaveData) - wait(for: [mockFlowController.didTransitionToNextScreenExp], timeout: 1) + // Request to save data + vc.saveDataAndTransition(lastClassification: mockLastClassification, lastImage: mockBackImage.image) + + // Verify data saved and transitioned to next screen + wait(for: [mockSheetController.didFinishSaveDataExp, mockFlowController.didTransitionToNextScreenExp], timeout: 1) + + // Verify dataStore updated + XCTAssertEqual(dataStore.frontDocumentImage, mockFrontImage) + XCTAssertEqual(dataStore.backDocumentImage, mockBackImage) + + // Verify state + verify( + vc, + expectedState: .scanned(mockLastClassification, mockBackImage.image), + isButtonDisabled: false, + isScanning: false + ) } } @@ -230,6 +280,8 @@ extension DocumentCaptureViewController.State: Equatable { (.scanning(let left), .scanning(let right)), (.scanned(let left, _), .scanned(let right, _)): return left == right + case (.saving, .saving): + return true default: return false } diff --git a/StripeIdentity/StripeIdentityTests/Unit/NativeComponents/ViewControllers/DocumentTypeSelectViewControllerTest.swift b/StripeIdentity/StripeIdentityTests/Unit/NativeComponents/ViewControllers/DocumentTypeSelectViewControllerTest.swift index 61dc06d5ce..c62ebdbb7e 100644 --- a/StripeIdentity/StripeIdentityTests/Unit/NativeComponents/ViewControllers/DocumentTypeSelectViewControllerTest.swift +++ b/StripeIdentity/StripeIdentityTests/Unit/NativeComponents/ViewControllers/DocumentTypeSelectViewControllerTest.swift @@ -67,9 +67,8 @@ final class DocumentTypeSelectViewControllerTest: XCTestCase { // Verify that dataStore is updated XCTAssertEqual(dataStore.idDocumentType, .passport) // Verify that saveData was called - XCTAssertTrue(mockSheetController.didSaveData) // Verify user was transitioned to next screen - wait(for: [mockFlowController.didTransitionToNextScreenExp], timeout: 1) + wait(for: [mockSheetController.didFinishSaveDataExp, mockFlowController.didTransitionToNextScreenExp], timeout: 1) } }