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
This commit is contained in:
Mel 2021-11-16 13:33:45 -08:00 committed by GitHub
parent 8fd90116ca
commit c85696c593
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 265 additions and 68 deletions

View File

@ -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 = "<group>"; };
E66784B026980677005F7CC8 /* BundleLocatorProtocol.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BundleLocatorProtocol.swift; sourceTree = "<group>"; };
E6752D7726F413A00062B821 /* String+StripeCore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+StripeCore.swift"; sourceTree = "<group>"; };
E67A1E6027404FD500977F63 /* File_IdentityDocument.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = File_IdentityDocument.json; sourceTree = "<group>"; };
E681E5652698EC9700692E45 /* NSError+StripeCore.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "NSError+StripeCore.swift"; sourceTree = "<group>"; };
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 = "<group>"; };
@ -393,6 +395,14 @@
name = "Recovered References";
sourceTree = "<group>";
};
E67A1E5F27404FB500977F63 /* Mock Files */ = {
isa = PBXGroup;
children = (
E67A1E6027404FD500977F63 /* File_IdentityDocument.json */,
);
path = "Mock Files";
sourceTree = "<group>";
};
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;
};

View File

@ -93,12 +93,16 @@
}
@_spi(STP) public class Promise<Value>: Future<Value> {
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) {

View File

@ -0,0 +1,7 @@
{
"created": 1636833390,
"id": "file_id",
"purpose": "identity_document",
"size": 100,
"type": "jpg"
}

View File

@ -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"
}

View File

@ -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<VerificationSessionData>
func uploadImage(
_ image: UIImage,
purpose: StripeFile.Purpose
) -> Promise<StripeFile>
}
extension STPAPIClient: IdentityAPIClient {

View File

@ -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<StripeFile> {
return STPAPIClient.shared.uploadImage(image, purpose: purpose)
}
}

View File

@ -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? {

View File

@ -17,6 +17,8 @@ protocol VerificationSheetControllerProtocol: AnyObject {
clientSecret: String
)
func uploadDocument(image: UIImage) -> Future<String>
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<String> {
// 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)
}
}
}

View File

@ -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<VerificationSessionDataStore.DocumentImage?> = Promise(value: nil)
var backUploadFuture: Future<VerificationSessionDataStore.DocumentImage?> = 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<VerificationSessionDataStore.DocumentImage?> = 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
)
}
}
}
}

View File

@ -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<String, VerificationPage>()
let verificationSessionData = MockAPIRequests<(id: String, data: VerificationSessionDataUpdate, ephemeralKey: String), VerificationSessionData>()
let imageUpload = MockAPIRequests<(image: UIImage, purpose: StripeFile.Purpose), StripeFile>()
func postIdentityVerificationPage(clientSecret: String) -> Promise<VerificationPage> {
return verificationPage.makeRequest(with: clientSecret)
@ -29,6 +31,10 @@ final class IdentityAPIClientTestMock: IdentityAPIClient {
ephemeralKey: ephemeralKeySecret
))
}
func uploadImage(_ image: UIImage, purpose: StripeFile.Purpose) -> Promise<StripeFile> {
return imageUpload.makeRequest(with: (image, purpose))
}
}
class MockAPIRequests<ParamsType, ResponseType> {

View File

@ -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<String> {
numUploadedImages += 1
return Promise(value: "")
}
}

View File

@ -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
}
}

View File

@ -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<VerificationSessionDataStore.DocumentImage?>()
let mockBackUploadFuture = Promise<VerificationSessionDataStore.DocumentImage?>()
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<VerificationSessionDataStore.DocumentImage?>()
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
}

View File

@ -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)
}
}