19 Card Digit Support Part 2 (#1631)

* * Adds API bindings for new, private card metadata endpoint
* Disables card number truncation in `STPPaymentCardTextField`
* Disables auto-advancing out of the number entry field in `STPPaymentCardTextField`
* Updates card number validation to wait for response from card metadata endpoint
* Updates tests with new card number behavior and asynchronicity

* Fixes UI test

* Enables auto-advancing when able and loading indicator to STPPaymentCardTextField

* Revert

* Comment updates from review

* Adds logging for entering full card number before getting a network response.

* Adds additional logging.

* Return

* Use configuration

* Fixes inifite loop; fixes KVO; fixes tests for updated behavior.

* Fixes memory leak/crash from retain cycle in OCMock code

* Fix test case

* Only enable service-based BIN lookups for CUP bins (#1642)

* Fixes validation tests and removes delay from card textfield tests.

* Updates README with correct simulator version for tests.

* Fixes snapshot tests

Co-authored-by: davidme-stripe <52758633+davidme-stripe@users.noreply.github.com>
This commit is contained in:
Cameron 2020-09-14 12:40:26 -07:00 committed by GitHub
parent e8f53b2943
commit 03ab0f2280
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
19 changed files with 879 additions and 671 deletions

View File

@ -111,7 +111,7 @@ We welcome contributions of any kind including new features, bug fixes, and docu
1. Install Carthage (if you have homebrew installed, `brew install carthage`)
2. From the root of the repo, install test dependencies by running `carthage bootstrap --platform ios --configuration Release --no-use-binaries`
3. Open Stripe.xcworkspace
4. Choose the "StripeiOS" scheme with the iPhone 7, iOS 12.2 simulator (required for snapshot tests to pass)
4. Choose the "StripeiOS" scheme with the iPhone 8, iOS 13.6 simulator (required for snapshot tests to pass)
5. Run Product -> Test
## Migrating from Older Versions

View File

@ -276,6 +276,8 @@
363E25DA24198B0D00070D59 /* STPViewWithSeparator.m in Sources */ = {isa = PBXBuildFile; fileRef = 363E25D824198B0D00070D59 /* STPViewWithSeparator.m */; };
363EA802241C3EC8000C7671 /* STPAUBECSDebitFormViewSnapshotTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 363EA801241C3EC8000C7671 /* STPAUBECSDebitFormViewSnapshotTests.m */; };
363EA804241C4346000C7671 /* STPAUBECSDebitFormView+Testing.h in Headers */ = {isa = PBXBuildFile; fileRef = 363EA803241C4346000C7671 /* STPAUBECSDebitFormView+Testing.h */; };
364B75DD24F46354007D9FAB /* STPCardLoadingIndicator.h in Headers */ = {isa = PBXBuildFile; fileRef = 364B75DB24F46354007D9FAB /* STPCardLoadingIndicator.h */; };
364B75DE24F46354007D9FAB /* STPCardLoadingIndicator.m in Sources */ = {isa = PBXBuildFile; fileRef = 364B75DC24F46354007D9FAB /* STPCardLoadingIndicator.m */; };
3650AA4221C07E3C002B0893 /* AppDelegate.m in Sources */ = {isa = PBXBuildFile; fileRef = 3650AA4121C07E3C002B0893 /* AppDelegate.m */; };
3650AA4521C07E3C002B0893 /* ViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = 3650AA4421C07E3C002B0893 /* ViewController.m */; };
3650AA4A21C07E3D002B0893 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 3650AA4921C07E3D002B0893 /* Assets.xcassets */; };
@ -1067,6 +1069,8 @@
363E25D824198B0D00070D59 /* STPViewWithSeparator.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = STPViewWithSeparator.m; sourceTree = "<group>"; };
363EA801241C3EC8000C7671 /* STPAUBECSDebitFormViewSnapshotTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = STPAUBECSDebitFormViewSnapshotTests.m; sourceTree = "<group>"; };
363EA803241C4346000C7671 /* STPAUBECSDebitFormView+Testing.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "STPAUBECSDebitFormView+Testing.h"; sourceTree = "<group>"; };
364B75DB24F46354007D9FAB /* STPCardLoadingIndicator.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = STPCardLoadingIndicator.h; sourceTree = "<group>"; };
364B75DC24F46354007D9FAB /* STPCardLoadingIndicator.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = STPCardLoadingIndicator.m; sourceTree = "<group>"; };
3650AA3E21C07E3C002B0893 /* LocalizationTester.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = LocalizationTester.app; sourceTree = BUILT_PRODUCTS_DIR; };
3650AA4021C07E3C002B0893 /* AppDelegate.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = AppDelegate.h; sourceTree = "<group>"; };
3650AA4121C07E3C002B0893 /* AppDelegate.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = AppDelegate.m; sourceTree = "<group>"; };
@ -2807,8 +2811,17 @@
F1728CF81EAAA945002E0C29 /* Views */ = {
isa = PBXGroup;
children = (
363EA803241C4346000C7671 /* STPAUBECSDebitFormView+Testing.h */,
36B8DDE8241AEB5400BB908E /* STPAUBECSFormViewModel.h */,
36B8DDE9241AEB5400BB908E /* STPAUBECSFormViewModel.m */,
364B75DB24F46354007D9FAB /* STPCardLoadingIndicator.h */,
364B75DC24F46354007D9FAB /* STPCardLoadingIndicator.m */,
0438EF261B7416BB00D506CC /* STPFormTextField.h */,
0438EF271B7416BB00D506CC /* STPFormTextField.m */,
36B8DDE0241AC0A100BB908E /* STPLabeledFormTextFieldView.h */,
36B8DDE1241AC0A100BB908E /* STPLabeledFormTextFieldView.m */,
36B8DDE4241AC17200BB908E /* STPLabeledMultiFormTextFieldView.h */,
36B8DDE5241AC17200BB908E /* STPLabeledMultiFormTextFieldView.m */,
0438EF2A1B7416BB00D506CC /* STPPaymentCardTextFieldViewModel.h */,
0438EF2B1B7416BB00D506CC /* STPPaymentCardTextFieldViewModel.m */,
C158AB3D1E1EE98900348D01 /* STPSectionHeaderView.h */,
@ -2817,13 +2830,6 @@
B347DD471FE35423006B3BAC /* STPValidatedTextField.m */,
363E25D724198B0D00070D59 /* STPViewWithSeparator.h */,
363E25D824198B0D00070D59 /* STPViewWithSeparator.m */,
36B8DDE0241AC0A100BB908E /* STPLabeledFormTextFieldView.h */,
36B8DDE1241AC0A100BB908E /* STPLabeledFormTextFieldView.m */,
36B8DDE4241AC17200BB908E /* STPLabeledMultiFormTextFieldView.h */,
36B8DDE5241AC17200BB908E /* STPLabeledMultiFormTextFieldView.m */,
36B8DDE8241AEB5400BB908E /* STPAUBECSFormViewModel.h */,
36B8DDE9241AEB5400BB908E /* STPAUBECSFormViewModel.m */,
363EA803241C4346000C7671 /* STPAUBECSDebitFormView+Testing.h */,
);
name = Views;
sourceTree = "<group>";
@ -2928,6 +2934,7 @@
B61D4B97245767D2001AEBEF /* STPPaymentIntentShippingDetailsAddress.h in Headers */,
C158AB3F1E1EE98900348D01 /* STPSectionHeaderView.h in Headers */,
04EBC7571B7533C300A0E6AE /* STPCardValidator.h in Headers */,
364B75DD24F46354007D9FAB /* STPCardLoadingIndicator.h in Headers */,
366658B0240F20AD00D00354 /* STPPaymentMethodAUBECSDebit.h in Headers */,
36B8DDE2241AC0A100BB908E /* STPLabeledFormTextFieldView.h in Headers */,
046FE9A11CE55D1D00DA6A7B /* STPPaymentActivityIndicatorView.h in Headers */,
@ -3776,6 +3783,7 @@
311A475824EB1D2300576D92 /* STPCardScanner.m in Sources */,
F1BEB2FF1F3508BB0043F48C /* NSError+Stripe.m in Sources */,
B699BC9524577FED009107F9 /* STPPaymentIntentShippingDetailsAddressParams.m in Sources */,
364B75DE24F46354007D9FAB /* STPCardLoadingIndicator.m in Sources */,
049952D01BCF13510088C703 /* STPAPIRequest.m in Sources */,
F1A2F92E1EEB6A70006B0456 /* NSCharacterSet+Stripe.m in Sources */,
F1D3A24B1EB012010095BFA9 /* STPFile.m in Sources */,

View File

@ -72,6 +72,16 @@
intentID:(NSString *)intentID
errorDictionary:(NSDictionary *)errorDictionary;
#pragma mark - Card Metadata
- (void)logUserEnteredCompletePANBeforeMetadataLoadedWithConfiguration:(STPPaymentConfiguration *)configuration;
- (void)logCardMetadataResponseFailureWithConfiguration:(STPPaymentConfiguration *)configuration;
- (void)logCardMetadataMissingRangeWithConfiguration:(STPPaymentConfiguration *)configuration;
#pragma mark - Card Scanning
- (void)logCardScanSucceededWithDuration:(NSTimeInterval)duration;
- (void)logCardScanCancelledWithDuration:(NSTimeInterval)duration;

View File

@ -321,6 +321,43 @@
[self logPayload:payload];
}
#pragma mark - Card Metadata
- (void)logUserEnteredCompletePANBeforeMetadataLoadedWithConfiguration:(STPPaymentConfiguration *)configuration {
NSDictionary *configurationDictionary = [self.class serializeConfiguration:configuration];
NSMutableDictionary *payload = [self.class commonPayload];
[payload addEntriesFromDictionary:@{
@"event": @"stripeios.card_metadata_loaded_too_slow",
}];
[payload addEntriesFromDictionary:[self productUsageDictionary]];
[payload addEntriesFromDictionary:configurationDictionary];
[self logPayload:payload];
}
- (void)logCardMetadataResponseFailureWithConfiguration:(STPPaymentConfiguration *)configuration {
NSDictionary *configurationDictionary = [self.class serializeConfiguration:configuration];
NSMutableDictionary *payload = [self.class commonPayload];
[payload addEntriesFromDictionary:@{
@"event": @"stripeios.card_metadata_load_failure",
}];
[payload addEntriesFromDictionary:[self productUsageDictionary]];
[payload addEntriesFromDictionary:configurationDictionary];
[self logPayload:payload];
}
- (void)logCardMetadataMissingRangeWithConfiguration:(STPPaymentConfiguration *)configuration {
NSDictionary *configurationDictionary = [self.class serializeConfiguration:configuration];
NSMutableDictionary *payload = [self.class commonPayload];
[payload addEntriesFromDictionary:@{
@"event": @"stripeios.card_metadata_missing_range",
}];
[payload addEntriesFromDictionary:[self productUsageDictionary]];
[payload addEntriesFromDictionary:configurationDictionary];
[self logPayload:payload];
}
#pragma mark - Card Scanning
- (void)logCardScanSucceededWithDuration:(NSTimeInterval)duration {
NSMutableDictionary *payload = [self.class commonPayload];
[payload addEntriesFromDictionary:@{

View File

@ -23,12 +23,22 @@ typedef void (^STPRetrieveBINRangesCompletionBlock)(NSArray<STPBINRange *> * _Nu
@property (nonatomic, readonly, copy) NSString *qRangeLow;
@property (nonatomic, readonly, copy) NSString *qRangeHigh;
@property (nonatomic, nullable, readonly) NSString *country;
@property (nonatomic, readonly) BOOL isCardMetadata; // indicates bin range was downloaded from edge service
+ (BOOL)isLoadingCardMetadataForPrefix:(NSString *)binPrefix;
+ (NSArray<STPBINRange *> *)allRanges;
+ (NSArray<STPBINRange *> *)binRangesForNumber:(NSString *)number;
+ (NSArray<STPBINRange *> *)binRangesForBrand:(STPCardBrand)brand;
+ (instancetype)mostSpecificBINRangeForNumber:(NSString *)number;
+ (NSUInteger)maxCardNumberLength;
+ (NSUInteger)minLengthForFullBINRange;
+ (BOOL)hasBINRangesForPrefix:(NSString *)binPrefix;
+ (BOOL)isInvalidBINPrefix:(NSString *)binPrefix;
// This will asynchronously check if we have already fetched metadata for this prefix and if we have not will
// issue a network request to retrieve it if possible.
+ (void)retrieveBINRangesForPrefix:(NSString *)binPrefix completion:(STPRetrieveBINRangesCompletionBlock)completion;

View File

@ -10,12 +10,17 @@
#import "NSDictionary+Stripe.h"
#import "NSString+Stripe.h"
#import "STPAnalyticsClient.h"
#import "STPAPIClient+Private.h"
#import "STPCard+Private.h"
#import "STPCardBINMetadata.h"
#import "STPPaymentConfiguration.h"
NS_ASSUME_NONNULL_BEGIN
static const NSUInteger kMaxCardNumberLength = 19;
static const NSUInteger kPrefixLengthForMetadataRequest = 6;
@interface STPBINRange()
@property (nonatomic) NSUInteger length;
@ -90,12 +95,30 @@ NS_ASSUME_NONNULL_BEGIN
binRange.brand = [STPCard brandFromString:brandString];
binRange.length = [length unsignedIntegerValue];
binRange.country = [dict stp_stringForKey:@"country"];
binRange->_isCardMetadata = YES;
return binRange;
}
#pragma mark - Class Utilities
+ (BOOL)isLoadingCardMetadataForPrefix:(NSString *)binPrefix {
__block BOOL isLoading = NO;
dispatch_sync([self _retrievalQueue], ^{
NSString *binPrefixKey = [binPrefix stp_safeSubstringToIndex:kPrefixLengthForMetadataRequest];
isLoading = binPrefixKey != nil && sPendingRequests[binPrefixKey] != nil;
});
return isLoading;
}
+ (NSUInteger)maxCardNumberLength {
return kMaxCardNumberLength;
}
+ (NSUInteger)minLengthForFullBINRange {
return kPrefixLengthForMetadataRequest;
}
static NSArray<STPBINRange *> *STPBINRangeAllRanges = nil;
+ (void)_performSyncWithAllRangesLock:(dispatch_block_t)block {
@ -110,7 +133,7 @@ static NSArray<STPBINRange *> *STPBINRangeAllRanges = nil;
if (STPBINRangeAllRanges == nil) {
NSArray *ranges = @[
// Unknown
@[@"", @"", @16, @(STPCardBrandUnknown)],
@[@"", @"", @19, @(STPCardBrandUnknown)],
// American Express
@[@"34", @"34", @15, @(STPCardBrandAmex)],
@ -135,6 +158,7 @@ static NSArray<STPBINRange *> *STPBINRangeAllRanges = nil;
// UnionPay
@[@"62", @"62", @16, @(STPCardBrandUnionPay)],
@[@"81", @"81", @16, @(STPCardBrandUnionPay)],
// Visa
@[@"40", @"49", @16, @(STPCardBrandVisa)],
@ -203,12 +227,13 @@ static NSArray<STPBINRange *> *STPBINRangeAllRanges = nil;
}]];
}
+ (void)retrieveBINRangesForPrefix:(NSString *)binPrefix completion:(STPRetrieveBINRangesCompletionBlock)completion {
// sPendingRequests contains the completion blocks for a given metadata request that we have not yet gotten a response for
static NSMutableDictionary<NSString *, NSArray<STPRetrieveBINRangesCompletionBlock> *> *sPendingRequests = nil;
// sRetrievedRanges tracks the bin prefixes for which we've already received metadata responses
static NSMutableDictionary<NSString *, NSArray<STPBINRange *> *> *sRetrievedRanges = nil;
// sRetrievalQueue protects access to the two above dictionaries, sSpendingRequests and sRetrievedRanges
// _retrievalQueue protects access to the two above dictionaries, sSpendingRequests and sRetrievedRanges
+ (dispatch_queue_t)_retrievalQueue {
static dispatch_queue_t sRetrievalQueue = nil;
static dispatch_once_t onceToken;
@ -218,10 +243,42 @@ static NSArray<STPBINRange *> *STPBINRangeAllRanges = nil;
sRetrievedRanges = [NSMutableDictionary new];
});
dispatch_async(sRetrievalQueue, ^{
NSString *binPrefixKey = [binPrefix stp_safeSubstringToIndex:6];
if (sRetrievedRanges[binPrefixKey] != nil || binPrefixKey.length < 6) {
return sRetrievalQueue;
}
+ (BOOL)hasBINRangesForPrefix:(NSString *)binPrefix {
if ([self isInvalidBINPrefix:binPrefix]) {
return YES; // we won't fetch any more info for this prefix
}
if (![self isVariableLengthBINPrefix:binPrefix]) {
return YES; // if we know a card has a static length, we don't need to ask the BIN service
}
__block BOOL hasBINRanges = NO;
dispatch_sync([self _retrievalQueue], ^{
NSString *binPrefixKey = [binPrefix stp_safeSubstringToIndex:kPrefixLengthForMetadataRequest];
hasBINRanges = binPrefixKey.length == kPrefixLengthForMetadataRequest && sRetrievedRanges[binPrefixKey] != nil;
});
return hasBINRanges;
}
+ (BOOL)isInvalidBINPrefix:(NSString *)binPrefix {
NSString *firstFive = [binPrefix stp_safeSubstringToIndex:kPrefixLengthForMetadataRequest - 1];
return ((STPBINRange *)[self mostSpecificBINRangeForNumber:firstFive]).brand == STPCardBrandUnknown;
}
+ (BOOL)isVariableLengthBINPrefix:(NSString *)binPrefix {
NSString *firstFive = [binPrefix stp_safeSubstringToIndex:kPrefixLengthForMetadataRequest - 1];
// Only UnionPay has variable-length cards at the moment.
return ((STPBINRange *)[self mostSpecificBINRangeForNumber:firstFive]).brand == STPCardBrandUnionPay;
}
+ (void)retrieveBINRangesForPrefix:(NSString *)binPrefix completion:(STPRetrieveBINRangesCompletionBlock)completion {
dispatch_async([self _retrievalQueue], ^{
NSString *binPrefixKey = [binPrefix stp_safeSubstringToIndex:kPrefixLengthForMetadataRequest];
if (sRetrievedRanges[binPrefixKey] != nil || binPrefixKey.length < kPrefixLengthForMetadataRequest || [self isInvalidBINPrefix:binPrefixKey]) {
// if we already have a metadata response or the binPrefix isn't long enough to make a request,
// or we know that this is not a valid BIN prefix
// return the bin ranges we already have on device
dispatch_async(dispatch_get_main_queue(), ^{
completion([self binRangesForNumber:binPrefix], nil);
@ -234,7 +291,7 @@ static NSArray<STPBINRange *> *STPBINRangeAllRanges = nil;
sPendingRequests[binPrefixKey] = @[[completion copy]];
[[STPAPIClient sharedClient] retrieveCardBINMetadataForPrefix:binPrefixKey
withCompletion:^(STPCardBINMetadata * _Nullable cardMetadata, NSError * _Nullable error) {
dispatch_async(sRetrievalQueue, ^{
dispatch_async([self _retrievalQueue], ^{
NSArray<STPBINRange *> *ranges = cardMetadata.ranges;
NSArray<STPRetrieveBINRangesCompletionBlock> *completionBlocks = sPendingRequests[binPrefixKey];
@ -244,6 +301,8 @@ static NSArray<STPBINRange *> *STPBINRangeAllRanges = nil;
[self _performSyncWithAllRangesLock:^{
STPBINRangeAllRanges = [STPBINRangeAllRanges arrayByAddingObjectsFromArray:ranges];
}];
} else {
[[STPAnalyticsClient sharedClient] logCardMetadataResponseFailureWithConfiguration:[STPPaymentConfiguration sharedConfiguration]];
}
dispatch_async(dispatch_get_main_queue(), ^{

View File

@ -45,7 +45,7 @@ NS_ASSUME_NONNULL_BEGIN
NSString *brand = [string lowercaseString];
if ([brand isEqualToString:@"visa"]) {
return STPCardBrandVisa;
} else if ([brand isEqualToString:@"american express"]) {
} else if ([brand isEqualToString:@"american express"] || [brand isEqualToString:@"american_express"]) {
return STPCardBrandAmex;
} else if ([brand isEqualToString:@"mastercard"]) {
return STPCardBrandMasterCard;
@ -53,7 +53,7 @@ NS_ASSUME_NONNULL_BEGIN
return STPCardBrandDiscover;
} else if ([brand isEqualToString:@"jcb"]) {
return STPCardBrandJCB;
} else if ([brand isEqualToString:@"diners club"]) {
} else if ([brand isEqualToString:@"diners club"] || [brand isEqualToString:@"diners_club"]) {
return STPCardBrandDinersClub;
} else if ([brand isEqualToString:@"unionpay"]) {
return STPCardBrandUnionPay;

View File

@ -34,6 +34,7 @@ NS_ASSUME_NONNULL_BEGIN
}
}
}
if (ranges.count == 0) {
return nil;
}

View File

@ -0,0 +1,17 @@
//
// STPCardLoadingIndicator.h
// StripeiOS
//
// Created by Cameron Sabol on 8/24/20.
// Copyright © 2020 Stripe, Inc. All rights reserved.
//
#import <UIKit/UIKit.h>
NS_ASSUME_NONNULL_BEGIN
@interface STPCardLoadingIndicator : UIView
@end
NS_ASSUME_NONNULL_END

View File

@ -0,0 +1,82 @@
//
// STPCardLoadingIndicator.m
// StripeiOS
//
// Created by Cameron Sabol on 8/24/20.
// Copyright © 2020 Stripe, Inc. All rights reserved.
//
#import "STPCardLoadingIndicator.h"
NS_ASSUME_NONNULL_BEGIN
static const CGFloat kCardLoadingIndicatorDiameter = 14.f;
static const CGFloat kCardLoadingInnerCircleDiameter = 10.f;
static const CFTimeInterval kLoadingAnimationSpinDuration = 0.6;
static NSString * const kLoadingAnimationIdentifier = @"STPCardLoadingIndicator.spinning";
@implementation STPCardLoadingIndicator {
CALayer *_indicatorLayer;
}
- (instancetype)initWithFrame:(CGRect)frame {
self = [super initWithFrame:frame];
if (self) {
self.backgroundColor = [UIColor colorWithRed:79.f/255.f green:86.f/255.f blue:107.f/255.f alpha:1.f];
// Make us a circle
CAShapeLayer *shape = [CAShapeLayer layer];
UIBezierPath *path = [UIBezierPath bezierPathWithArcCenter:CGPointMake(0.5f*kCardLoadingIndicatorDiameter, 0.5f*kCardLoadingIndicatorDiameter)
radius:0.5f*kCardLoadingIndicatorDiameter
startAngle:0.f
endAngle:(2.f*M_PI)
clockwise:YES];
shape.path = path.CGPath;
self.layer.mask = shape;
// Add the inner circle
CAShapeLayer *innerCircle = [CAShapeLayer layer];
innerCircle.anchorPoint = CGPointMake(0.5f, 0.5f);
innerCircle.position = CGPointMake(0.5f*kCardLoadingIndicatorDiameter, 0.5f*kCardLoadingIndicatorDiameter);
UIBezierPath *indicatorPath = [UIBezierPath bezierPathWithArcCenter:CGPointMake(0.f, 0.f)
radius:0.5f*kCardLoadingInnerCircleDiameter
startAngle:0.f
endAngle:(9.f*M_PI/6.f)
clockwise:YES];
innerCircle.path = indicatorPath.CGPath;
innerCircle.strokeColor = [UIColor colorWithWhite:1.f alpha:0.8f].CGColor;
innerCircle.fillColor = [UIColor clearColor].CGColor;
[self.layer addSublayer:innerCircle];
_indicatorLayer = (CALayer *)innerCircle;
}
return self;
}
- (CGSize)intrinsicContentSize {
return CGSizeMake(kCardLoadingIndicatorDiameter, kCardLoadingIndicatorDiameter);
}
- (void)didMoveToWindow {
[super didMoveToWindow];
[self startAnimating];
}
- (void)startAnimating {
CABasicAnimation *spinAnimation = [CABasicAnimation animationWithKeyPath:@"transform.rotation"];
spinAnimation.byValue = [NSNumber numberWithFloat:(float)(2.0f*M_PI)];
spinAnimation.duration = kLoadingAnimationSpinDuration;
spinAnimation.repeatCount = INFINITY;
[_indicatorLayer addAnimation:spinAnimation forKey:kLoadingAnimationIdentifier];
}
- (void)stopAnimating {
[_indicatorLayer removeAnimationForKey:kLoadingAnimationIdentifier];
}
@end
NS_ASSUME_NONNULL_END

View File

@ -15,6 +15,8 @@ NS_ASSUME_NONNULL_BEGIN
+ (NSArray<NSNumber *> *)cardNumberFormatForBrand:(STPCardBrand)brand;
+ (NSArray<NSNumber *> *)cardNumberFormatForCardNumber:(NSString *)cardNumber;
+ (BOOL)stringIsValidLuhn:(NSString *)number;
@end
NS_ASSUME_NONNULL_END

View File

@ -33,6 +33,25 @@ NS_ASSUME_NONNULL_BEGIN
return [self cardNumberFormatForBrand:binRange.brand];
}
+ (BOOL)stringIsValidLuhn:(NSString *)number {
BOOL odd = true;
int sum = 0;
NSMutableArray *digits = [NSMutableArray arrayWithCapacity:number.length];
for (int i = 0; i < (NSInteger)number.length; i++) {
[digits addObject:[number substringWithRange:NSMakeRange(i, 1)]];
}
for (NSString *digitStr in [digits reverseObjectEnumerator]) {
int digit = [digitStr intValue];
if ((odd = !odd)) digit *= 2;
if (digit > 9) digit -= 9;
sum += digit;
}
return sum % 10 == 0;
}
@end
NS_ASSUME_NONNULL_END

View File

@ -9,7 +9,9 @@
#import "STPCardValidator.h"
#import "STPCardValidator+Private.h"
#import "STPAnalyticsClient.h"
#import "STPBINRange.h"
#import "STPPaymentConfiguration.h"
#import "NSCharacterSet+Stripe.h"
@implementation STPCardValidator
@ -157,7 +159,16 @@ static NSString * _Nonnull stringByRemovingCharactersFromSet(NSString * _Nonnull
}
if (sanitizedNumber.length == binRange.length) {
BOOL isValidLuhn = [self stringIsValidLuhn:sanitizedNumber];
return isValidLuhn ? STPCardValidationStateValid : STPCardValidationStateInvalid;
if (isValidLuhn) {
if (!binRange.isCardMetadata && [STPBINRange hasBINRangesForPrefix:sanitizedNumber]) {
// log that we didn't get a match in the metadata response so fell back to a hard coded response
[[STPAnalyticsClient sharedClient] logCardMetadataMissingRangeWithConfiguration:[STPPaymentConfiguration sharedConfiguration]];
}
return STPCardValidationStateValid;
} else {
return STPCardValidationStateInvalid;
}
} else if (sanitizedNumber.length > binRange.length) {
return STPCardValidationStateInvalid;
} else {
@ -252,25 +263,6 @@ static NSString * _Nonnull stringByRemovingCharactersFromSet(NSString * _Nonnull
return [[[self cardNumberFormatForBrand:brand] lastObject] unsignedIntegerValue];
}
+ (BOOL)stringIsValidLuhn:(NSString *)number {
BOOL odd = true;
int sum = 0;
NSMutableArray *digits = [NSMutableArray arrayWithCapacity:number.length];
for (int i = 0; i < (NSInteger)number.length; i++) {
[digits addObject:[number substringWithRange:NSMakeRange(i, 1)]];
}
for (NSString *digitStr in [digits reverseObjectEnumerator]) {
int digit = [digitStr intValue];
if ((odd = !odd)) digit *= 2;
if (digit > 9) digit -= 9;
sum += digit;
}
return sum % 10 == 0;
}
+ (NSInteger)currentYear {
NSCalendar *calendar = [[NSCalendar alloc] initWithCalendarIdentifier:NSCalendarIdentifierGregorian];
NSDateComponents *dateComponents = [calendar components:NSCalendarUnitYear fromDate:[NSDate date]];

View File

@ -12,6 +12,8 @@
#import "NSArray+Stripe.h"
#import "NSString+Stripe.h"
#import "STPBINRange.h"
#import "STPCardLoadingIndicator.h"
#import "STPCardValidator+Private.h"
#import "STPFormTextField.h"
#import "STPImageLibrary.h"
@ -22,7 +24,7 @@
#import "STPAnalyticsClient.h"
@interface STPPaymentCardTextField()<STPFormTextFieldDelegate> {
BOOL _isNumberImageRequestValid;
STPCardLoadingIndicator *_metadataLoadingIndicator;
}
@property (nonatomic, readwrite, weak) UIImageView *brandImageView;
@ -89,6 +91,7 @@ NS_INLINE CGFloat stp_ceilCGFloat(CGFloat x) {
#endif
}
static const NSTimeInterval kCardLoadingAnimationDelay = 0.1;
@implementation STPPaymentCardTextField
@ -686,7 +689,7 @@ CGFloat const STPPaymentCardTextFieldMinimumPadding = 10;
switch (fieldType) {
case STPCardFieldTypeNumber:
// no-op, we don't autoadvance for number so we want to leave as incomplete
state = self.viewModel.hasCompleteMetadataForCardNumber ? [STPCardValidator validationStateForNumber:self.viewModel.cardNumber validatingCardBrand:YES] : STPCardValidationStateIncomplete;
break;
case STPCardFieldTypeExpiration:
@ -897,7 +900,9 @@ typedef NS_ENUM(NSInteger, STPCardTextFieldState) {
}
- (CGRect)brandImageRectForBounds:(CGRect)bounds {
return CGRectMake(STPPaymentCardTextFieldDefaultPadding, -1, self.brandImageView.image.size.width, bounds.size.height);
CGFloat height = (CGFloat)MIN(bounds.size.height, self.brandImageView.image.size.height);
// the -1 to y here helps the image actually be centered
return CGRectMake(STPPaymentCardTextFieldDefaultPadding, 0.5f*bounds.size.height - 0.5f*height -1, self.brandImageView.image.size.width, height);
}
- (CGRect)fieldsRectForBounds:(CGRect)bounds {
@ -1318,14 +1323,44 @@ typedef void (^STPLayoutAnimationCompletionBlock)(BOOL completed);
self.cvcField.validText = [self.viewModel validationStateForCVC] != STPCardValidationStateInvalid;
[self updateImageForFieldType:fieldType];
if (self.viewModel.hasCompleteMetadataForCardNumber) {
STPCardValidationState state = [STPCardValidator validationStateForNumber:self.viewModel.cardNumber validatingCardBrand:YES];
[self updateCVCPlaceholder];
self.cvcField.validText = [self.viewModel validationStateForCVC] != STPCardValidationStateInvalid;
formTextField.validText = state != STPCardValidationStateInvalid;
if (state == STPCardValidationStateValid) {
// auto-advance
[[self nextFirstResponderField] becomeFirstResponder];
UIAccessibilityPostNotification(UIAccessibilityScreenChangedNotification, nil);
}
} else {
[self.viewModel validationStateForCardNumberWithHandler:^(STPCardValidationState state) {
if ([self.viewModel.cardNumber isEqualToString:number]) {
[self updateCVCPlaceholder];
self.cvcField.validText = [self.viewModel validationStateForCVC] != STPCardValidationStateInvalid;
formTextField.validText = state != STPCardValidationStateInvalid;
if (state == STPCardValidationStateValid) {
// log that user entered full complete PAN before we got a network response
[[STPAnalyticsClient sharedClient] logUserEnteredCompletePANBeforeMetadataLoadedWithConfiguration:[STPPaymentConfiguration sharedConfiguration]];
}
[self onChange];
}
// Update image on response because we may want to remove the loading indicator
[self updateImageForFieldType:([self currentFirstResponderField] ?: self.numberField).tag];
// no auto-advance
}];
if (self.viewModel.isNumberMaxLength) {
BOOL isValidLuhn = [STPCardValidator stringIsValidLuhn:self.viewModel.cardNumber];
formTextField.validText = isValidLuhn;
if (isValidLuhn) {
// auto-advance
[[self nextFirstResponderField] becomeFirstResponder];
UIAccessibilityPostNotification(UIAccessibilityScreenChangedNotification, nil);
}
}
}
break;
}
@ -1557,7 +1592,11 @@ typedef NS_ENUM(NSInteger, STPFieldEditingTransitionCallSite) {
if (validationState == STPCardValidationStateInvalid) {
return [self.class errorImageForCardBrand:self.viewModel.brand];
} else {
if (self.viewModel.hasCompleteMetadataForCardNumber) {
return [self.class brandImageForCardBrand:self.viewModel.brand];
} else {
return [self.class brandImageForCardBrand:STPCardBrandUnknown];
}
}
case STPCardFieldTypeCVC:
return [self.class cvcImageForCardBrand:self.viewModel.brand];
@ -1598,7 +1637,46 @@ typedef NS_ENUM(NSInteger, STPFieldEditingTransitionCallSite) {
- (void)updateImageForFieldType:(STPCardFieldType)fieldType {
void (^applyImage)(STPCardFieldType, STPCardValidationState) = ^void(STPCardFieldType applyFieldType, STPCardValidationState validationState) {
void (^addLoadingIndicator)(void) = ^{
if (self->_metadataLoadingIndicator == nil) {
self->_metadataLoadingIndicator = [[STPCardLoadingIndicator alloc] init];
self->_metadataLoadingIndicator.translatesAutoresizingMaskIntoConstraints = NO;
[self addSubview:self->_metadataLoadingIndicator];
[NSLayoutConstraint activateConstraints:@[
[self->_metadataLoadingIndicator.rightAnchor constraintEqualToAnchor:self.brandImageView.rightAnchor],
[self->_metadataLoadingIndicator.topAnchor constraintEqualToAnchor:self.brandImageView.topAnchor],
]];
}
STPCardLoadingIndicator *loadingIndicator = self->_metadataLoadingIndicator;
if (!loadingIndicator.isHidden) {
return;
}
loadingIndicator.alpha = 0.f;
loadingIndicator.hidden = NO;
[UIView animateWithDuration:0.6 delay:0 options:UIViewAnimationOptionCurveEaseInOut animations:^{
loadingIndicator.alpha = 1.f;
} completion:^(__unused BOOL finished) {
loadingIndicator.alpha = 1.f;
}];
};
void (^removeLoadingIndicator)(void) = ^{
if (self->_metadataLoadingIndicator != nil && !self->_metadataLoadingIndicator.isHidden) {
STPCardLoadingIndicator *loadingIndicator = self->_metadataLoadingIndicator;
[UIView animateWithDuration:0.6 delay:0 options:UIViewAnimationOptionCurveEaseInOut animations:^{
loadingIndicator.alpha = 0.f;
} completion:^(__unused BOOL finished) {
loadingIndicator.alpha = 0.f;
loadingIndicator.hidden = YES;
}];
}
};
void (^applyBrandImage)(STPCardFieldType, STPCardValidationState) = ^void(STPCardFieldType applyFieldType, STPCardValidationState validationState) {
UIImage *image = [self brandImageForFieldType:applyFieldType validationState:validationState];
if (![image isEqual:self.brandImageView.image]) {
@ -1621,44 +1699,36 @@ typedef NS_ENUM(NSInteger, STPFieldEditingTransitionCallSite) {
}
};
if (!self.viewModel.hasCompleteMetadataForCardNumber && [STPBINRange isLoadingCardMetadataForPrefix:self.viewModel.cardNumber]) {
applyBrandImage(STPCardFieldTypeNumber, STPCardValidationStateIncomplete);
// delay a bit before showing loading indicator because the response may come quickly
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(kCardLoadingAnimationDelay * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
if (!self.viewModel.hasCompleteMetadataForCardNumber && [STPBINRange isLoadingCardMetadataForPrefix:self.viewModel.cardNumber]) {
addLoadingIndicator();
}
});
} else {
removeLoadingIndicator();
switch (fieldType) {
case STPCardFieldTypeNumber: {
if (self.currentBrandImageFieldType != STPCardFieldTypeNumber) {
STPCardValidationState cardStateForImmediateUpdate = self.numberField.validText ? STPCardValidationStateIncomplete : STPCardValidationStateInvalid;
applyImage(fieldType, cardStateForImmediateUpdate);
}
STPCardFieldType currentImageFieldType = self.currentBrandImageFieldType;
NSString *currentNumber = [self.numberField.text copy];
_isNumberImageRequestValid = YES;
[self.viewModel validationStateForCardNumberWithHandler:^(STPCardValidationState state) {
if (self->_isNumberImageRequestValid &&
currentImageFieldType == self.currentBrandImageFieldType &&
([currentNumber isEqualToString:self.numberField.text] || (currentNumber == nil && self.numberField.text == nil))) {
// we haven't requested image for another field and the card number has not changed. Update with this state
applyImage(fieldType, state);
self->_isNumberImageRequestValid = NO;
}
}];
}
case STPCardFieldTypeNumber:
applyBrandImage(STPCardFieldTypeNumber, [STPCardValidator validationStateForNumber:self.viewModel.cardNumber validatingCardBrand:YES]);
break;
case STPCardFieldTypeExpiration:
_isNumberImageRequestValid = NO;
applyImage(fieldType, [self.viewModel validationStateForExpiration]);
applyBrandImage(fieldType, [self.viewModel validationStateForExpiration]);
break;
case STPCardFieldTypeCVC:
_isNumberImageRequestValid = NO;
applyImage(fieldType, [self.viewModel validationStateForCVC]);
applyBrandImage(fieldType, [self.viewModel validationStateForCVC]);
break;
case STPCardFieldTypePostalCode:
_isNumberImageRequestValid = NO;
applyImage(fieldType, [self.viewModel validationStateForPostalCode]);
applyBrandImage(fieldType, [self.viewModel validationStateForPostalCode]);
break;
}
}
}
- (NSString *)defaultCVCPlaceholder {
@ -1702,10 +1772,17 @@ typedef NS_ENUM(NSInteger, STPFieldEditingTransitionCallSite) {
+ (NSSet<NSString *> *)keyPathsForValuesAffectingIsValid {
return [NSSet setWithArray:@[
// viewModel.valid
[NSString stringWithFormat:@"%@.%@",
NSStringFromSelector(@selector(viewModel)),
NSStringFromSelector(@selector(valid))],
]];
// viewModel.hasCompleteMetadataForCardNumber
[NSString stringWithFormat:@"%@.%@",
NSStringFromSelector(@selector(viewModel)),
NSStringFromSelector(@selector(hasCompleteMetadataForCardNumber))],
]
];
}
@end

View File

@ -35,6 +35,8 @@ typedef NS_ENUM(NSInteger, STPCardFieldType) {
@property (nonatomic, copy, nullable) NSString *postalCodeCountryCode;
@property (nonatomic, readonly) STPCardBrand brand;
@property (nonatomic, readonly) BOOL isValid;
@property (nonatomic, readonly) BOOL hasCompleteMetadataForCardNumber;
@property (nonatomic, readonly) BOOL isNumberMaxLength;
- (NSString *)defaultPlaceholder;
- (nullable NSString *)compressedCardNumberWithPlaceholder:(nullable NSString *)placeholder;

View File

@ -13,10 +13,24 @@
#import "STPCardValidator+Private.h"
#import "STPPostalCodeValidator.h"
@interface STPPaymentCardTextFieldViewModel ()
@property (nonatomic, readwrite) BOOL hasCompleteMetadataForCardNumber;
@end
@implementation STPPaymentCardTextFieldViewModel
- (void)setCardNumber:(NSString *)cardNumber {
_cardNumber = [STPCardValidator sanitizedNumericStringForString:cardNumber];
NSString *sanitizedNumber = [STPCardValidator sanitizedNumericStringForString:cardNumber];
self.hasCompleteMetadataForCardNumber = [STPBINRange hasBINRangesForPrefix:sanitizedNumber];
if (self.hasCompleteMetadataForCardNumber) {
STPCardBrand brand = [STPCardValidator brandForNumber:sanitizedNumber];
NSInteger maxLength = [STPCardValidator maxLengthForCardBrand:brand];
_cardNumber = [sanitizedNumber stp_safeSubstringToIndex:maxLength];
} else {
_cardNumber = [sanitizedNumber stp_safeSubstringToIndex:[STPBINRange maxCardNumberLength]];
}
}
- (nullable NSString *)compressedCardNumberWithPlaceholder:(nullable NSString *)placeholder {
@ -116,6 +130,10 @@
}
}
- (BOOL)isNumberMaxLength {
return self.cardNumber.length == [STPBINRange maxCardNumberLength];
}
- (STPCardValidationState)validationStateForPostalCode {
if (self.postalCode.length > 0) {
return STPCardValidationStateValid;
@ -126,12 +144,14 @@
- (void)validationStateForCardNumberWithHandler:(void (^)(STPCardValidationState))handler {
[STPBINRange retrieveBINRangesForPrefix:self.cardNumber completion:^(__unused NSArray<STPBINRange *> * _Nullable ranges, __unused NSError * _Nullable error) {
self.hasCompleteMetadataForCardNumber = [STPBINRange hasBINRangesForPrefix:self.cardNumber];
handler([STPCardValidator validationStateForNumber:self.cardNumber validatingCardBrand:YES]);
}];
}
- (BOOL)isValid {
return ([STPCardValidator validationStateForNumber:self.cardNumber validatingCardBrand:YES] == STPCardValidationStateValid
&& self.hasCompleteMetadataForCardNumber
&& [self validationStateForExpiration] == STPCardValidationStateValid
&& [self validationStateForCVC] == STPCardValidationStateValid
&& (!self.postalCodeRequired
@ -156,6 +176,7 @@
NSStringFromSelector(@selector(postalCode)),
NSStringFromSelector(@selector(postalCodeRequested)),
NSStringFromSelector(@selector(postalCodeCountryCode)),
NSStringFromSelector(@selector(hasCompleteMetadataForCardNumber)),
]];
}

View File

@ -114,8 +114,10 @@
}
XCTAssertEqual(STPCardValidationStateIncomplete, [STPCardValidator validationStateForNumber:@"1" validatingCardBrand:NO]);
XCTAssertEqual(STPCardValidationStateValid, [STPCardValidator validationStateForNumber:@"0000000000000000" validatingCardBrand:NO]);
XCTAssertEqual(STPCardValidationStateValid, [STPCardValidator validationStateForNumber:@"9999999999999995" validatingCardBrand:NO]);
XCTAssertEqual(STPCardValidationStateIncomplete, [STPCardValidator validationStateForNumber:@"0000000000000000" validatingCardBrand:NO]);
XCTAssertEqual(STPCardValidationStateIncomplete, [STPCardValidator validationStateForNumber:@"9999999999999995" validatingCardBrand:NO]);
XCTAssertEqual(STPCardValidationStateValid, [STPCardValidator validationStateForNumber:@"0000000000000000000" validatingCardBrand:NO]);
XCTAssertEqual(STPCardValidationStateValid, [STPCardValidator validationStateForNumber:@"9999999999999999998" validatingCardBrand:NO]);
XCTAssertEqual(STPCardValidationStateIncomplete, [STPCardValidator validationStateForNumber:@"4242424242424" validatingCardBrand:YES]);
XCTAssertEqual(STPCardValidationStateIncomplete, [STPCardValidator validationStateForNumber:nil validatingCardBrand:YES]);
}
@ -135,7 +137,7 @@
@[@(STPCardBrandDinersClub), @[@14, @16]],
@[@(STPCardBrandJCB), @[@16]],
@[@(STPCardBrandUnionPay), @[@16]],
@[@(STPCardBrandUnknown), @[@16]],
@[@(STPCardBrandUnknown), @[@19]],
];
for (NSArray *test in tests) {
NSSet *lengths = [STPCardValidator lengthsForCardBrand:[test[0] integerValue]];

View File

@ -64,6 +64,11 @@
// because we are calling to the card metadata service without configuration STPAPIClient
@implementation STPPaymentCardTextFieldTest
+ (void)setUp {
[super setUp];
[[STPAPIClient sharedClient] setPublishableKey:STPTestingDefaultPublishableKey];
}
- (void)testIntrinsicContentSize {
STPPaymentCardTextField *textField = [STPPaymentCardTextField new];
@ -93,10 +98,6 @@
card.number = number;
[sut setCardParams:card];
// The view model needs to request card metadata to choose the correct image, so give it
// time for a network roundtrip
XCTestExpectation *expectation = [self expectationWithDescription:@"Image fetching"];
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(STPTestingNetworkRequestTimeout * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
NSData *imgData = UIImagePNGRepresentation(sut.brandImageView.image);
NSData *expectedImgData = UIImagePNGRepresentation([STPPaymentCardTextField errorImageForCardBrand:STPCardBrandUnknown]);
@ -107,10 +108,6 @@
XCTAssertEqual(sut.expirationField.text.length, (NSUInteger)0);
XCTAssertEqual(sut.cvcField.text.length, (NSUInteger)0);
XCTAssertNil(sut.currentFirstResponderField);
[expectation fulfill];
});
[self waitForExpectationsWithTimeout:2*STPTestingNetworkRequestTimeout handler:nil];
}
- (void)testSetCard_expiration {
@ -164,13 +161,10 @@
- (void)testSetCard_numberVisa {
STPPaymentCardTextField *sut = [STPPaymentCardTextField new];
STPPaymentMethodCardParams *card = [STPPaymentMethodCardParams new];
NSString *number = @"4242";
NSString *number = @"424242";
card.number = number;
[sut setCardParams:card];
// The view model needs to request card metadata to choose the correct image, so give it
// time for a network roundtrip
XCTestExpectation *expectation = [self expectationWithDescription:@"Image fetching"];
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(STPTestingNetworkRequestTimeout * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
NSData *imgData = UIImagePNGRepresentation(sut.brandImageView.image);
NSData *expectedImgData = UIImagePNGRepresentation([STPPaymentCardTextField brandImageForCardBrand:STPCardBrandVisa]);
@ -183,11 +177,6 @@
XCTAssertEqualObjects(sut.cvcField.placeholder, @"CVC");
XCTAssertNil(sut.currentFirstResponderField);
XCTAssertFalse(sut.isValid);
[expectation fulfill];
});
[self waitForExpectationsWithTimeout:2*STPTestingNetworkRequestTimeout handler:nil];
}
- (void)testSetCard_numberVisaInvalid {
@ -197,31 +186,19 @@
card.number = number;
[sut setCardParams:card];
// The view model needs to request card metadata to choose the correct image, so give it
// time for a network roundtrip
XCTestExpectation *expectation = [self expectationWithDescription:@"Image fetching"];
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(STPTestingNetworkRequestTimeout * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
NSData *imgData = UIImagePNGRepresentation(sut.brandImageView.image);
NSData *expectedImgData = UIImagePNGRepresentation([STPPaymentCardTextField errorImageForCardBrand:STPCardBrandVisa]);
XCTAssertTrue([expectedImgData isEqualToData:imgData]);
[expectation fulfill];
});
[self waitForExpectationsWithTimeout:2*STPTestingNetworkRequestTimeout handler:nil];
}
- (void)testSetCard_numberAmex {
STPPaymentCardTextField *sut = [STPPaymentCardTextField new];
STPPaymentMethodCardParams *card = [STPPaymentMethodCardParams new];
NSString *number = @"3782";
NSString *number = @"378282";
card.number = number;
[sut setCardParams:card];
// The view model needs to request card metadata to choose the correct image, so give it
// time for a network roundtrip
XCTestExpectation *expectation = [self expectationWithDescription:@"Image fetching"];
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(STPTestingNetworkRequestTimeout * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
NSData *imgData = UIImagePNGRepresentation(sut.brandImageView.image);
NSData *expectedImgData = UIImagePNGRepresentation([STPPaymentCardTextField brandImageForCardBrand:STPCardBrandAmex]);
@ -233,10 +210,6 @@
XCTAssertEqualObjects(sut.cvcField.placeholder, @"CVV");
XCTAssertNil(sut.currentFirstResponderField);
XCTAssertFalse(sut.isValid);
[expectation fulfill];
});
[self waitForExpectationsWithTimeout:2*STPTestingNetworkRequestTimeout handler:nil];
}
- (void)testSetCard_numberAmexInvalid {
@ -245,20 +218,13 @@
NSString *number = @"378282246311111";
card.number = number;
[sut setCardParams:card];
// The view model needs to request card metadata to choose the correct image, so give it
// time for a network roundtrip
XCTestExpectation *expectation = [self expectationWithDescription:@"Image fetching"];
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(STPTestingNetworkRequestTimeout * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
NSData *imgData = UIImagePNGRepresentation(sut.brandImageView.image);
NSData *expectedImgData = UIImagePNGRepresentation([STPPaymentCardTextField errorImageForCardBrand:STPCardBrandAmex]);
XCTAssertNotNil(sut.focusedTextFieldForLayout);
XCTAssertTrue(sut.focusedTextFieldForLayout.integerValue == STPCardFieldTypeNumber);
XCTAssertTrue([expectedImgData isEqualToData:imgData]);
[expectation fulfill];
});
[self waitForExpectationsWithTimeout:2*STPTestingNetworkRequestTimeout handler:nil];
}
- (void)testSetCard_numberAndExpiration {
@ -269,10 +235,7 @@
card.expMonth = @(10);
card.expYear = @(99);
[sut setCardParams:card];
// The view model needs to request card metadata to choose the correct image, so give it
// time for a network roundtrip
XCTestExpectation *expectation = [self expectationWithDescription:@"Image fetching"];
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(STPTestingNetworkRequestTimeout * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
NSData *imgData = UIImagePNGRepresentation(sut.brandImageView.image);
NSData *expectedImgData = UIImagePNGRepresentation([STPPaymentCardTextField brandImageForCardBrand:STPCardBrandVisa]);
@ -283,24 +246,17 @@
XCTAssertEqual(sut.cvcField.text.length, (NSUInteger)0);
XCTAssertNil(sut.currentFirstResponderField);
XCTAssertFalse(sut.isValid);
[expectation fulfill];
});
[self waitForExpectationsWithTimeout:2*STPTestingNetworkRequestTimeout handler:nil];
}
- (void)testSetCard_partialNumberAndExpiration {
STPPaymentCardTextField *sut = [STPPaymentCardTextField new];
STPPaymentMethodCardParams *card = [STPPaymentMethodCardParams new];
NSString *number = @"42";
NSString *number = @"424242";
card.number = number;
card.expMonth = @(10);
card.expYear = @(99);
[sut setCardParams:card];
// The view model needs to request card metadata to choose the correct image, so give it
// time for a network roundtrip
XCTestExpectation *expectation = [self expectationWithDescription:@"Image fetching"];
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(STPTestingNetworkRequestTimeout * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
NSData *imgData = UIImagePNGRepresentation(sut.brandImageView.image);
NSData *expectedImgData = UIImagePNGRepresentation([STPPaymentCardTextField brandImageForCardBrand:STPCardBrandVisa]);
@ -312,11 +268,6 @@
XCTAssertEqual(sut.cvcField.text.length, (NSUInteger)0);
XCTAssertNil(sut.currentFirstResponderField);
XCTAssertFalse(sut.isValid);
[expectation fulfill];
});
[self waitForExpectationsWithTimeout:2*STPTestingNetworkRequestTimeout handler:nil];
}
- (void)testSetCard_numberAndCVC {
@ -327,10 +278,7 @@
card.number = number;
card.cvc = cvc;
[sut setCardParams:card];
// The view model needs to request card metadata to choose the correct image, so give it
// time for a network roundtrip
XCTestExpectation *expectation = [self expectationWithDescription:@"Image fetching"];
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(STPTestingNetworkRequestTimeout * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
NSData *imgData = UIImagePNGRepresentation(sut.brandImageView.image);
NSData *expectedImgData = UIImagePNGRepresentation([STPPaymentCardTextField brandImageForCardBrand:STPCardBrandAmex]);
@ -341,11 +289,6 @@
XCTAssertEqualObjects(sut.cvcField.text, cvc);
XCTAssertNil(sut.currentFirstResponderField);
XCTAssertFalse(sut.isValid);
[expectation fulfill];
});
[self waitForExpectationsWithTimeout:2*STPTestingNetworkRequestTimeout handler:nil];
}
- (void)testSetCard_expirationAndCVC {
@ -356,10 +299,7 @@
card.expYear = @(99);
card.cvc = cvc;
[sut setCardParams:card];
// The view model needs to request card metadata to choose the correct image, so give it
// time for a network roundtrip
XCTestExpectation *expectation = [self expectationWithDescription:@"Image fetching"];
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(STPTestingNetworkRequestTimeout * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
NSData *imgData = UIImagePNGRepresentation(sut.brandImageView.image);
NSData *expectedImgData = UIImagePNGRepresentation([STPPaymentCardTextField brandImageForCardBrand:STPCardBrandUnknown]);
@ -371,11 +311,6 @@
XCTAssertEqualObjects(sut.cvcField.text, cvc);
XCTAssertNil(sut.currentFirstResponderField);
XCTAssertFalse(sut.isValid);
[expectation fulfill];
});
[self waitForExpectationsWithTimeout:2*STPTestingNetworkRequestTimeout handler:nil];
}
- (void)testSetCard_completeCardCountryWithoutPostal {
@ -389,10 +324,7 @@
card.expYear = @(99);
card.cvc = cvc;
[sut setCardParams:card];
// The view model needs to request card metadata to choose the correct image, so give it
// time for a network roundtrip
XCTestExpectation *expectation = [self expectationWithDescription:@"Image fetching"];
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(STPTestingNetworkRequestTimeout * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
NSData *imgData = UIImagePNGRepresentation(sut.brandImageView.image);
NSData *expectedImgData = UIImagePNGRepresentation([STPPaymentCardTextField brandImageForCardBrand:STPCardBrandVisa]);
@ -403,11 +335,6 @@
XCTAssertEqualObjects(sut.cvcField.text, cvc);
XCTAssertNil(sut.currentFirstResponderField);
XCTAssertTrue(sut.isValid);
[expectation fulfill];
});
[self waitForExpectationsWithTimeout:2*STPTestingNetworkRequestTimeout handler:nil];
}
- (void)testSetCard_completeCardNoPostal {
@ -421,10 +348,7 @@
card.expYear = @(99);
card.cvc = cvc;
[sut setCardParams:card];
// The view model needs to request card metadata to choose the correct image, so give it
// time for a network roundtrip
XCTestExpectation *expectation = [self expectationWithDescription:@"Image fetching"];
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(STPTestingNetworkRequestTimeout * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
NSData *imgData = UIImagePNGRepresentation(sut.brandImageView.image);
NSData *expectedImgData = UIImagePNGRepresentation([STPPaymentCardTextField brandImageForCardBrand:STPCardBrandVisa]);
@ -435,11 +359,6 @@
XCTAssertEqualObjects(sut.cvcField.text, cvc);
XCTAssertNil(sut.currentFirstResponderField);
XCTAssertTrue(sut.isValid);
[expectation fulfill];
});
[self waitForExpectationsWithTimeout:2*STPTestingNetworkRequestTimeout handler:nil];
}
- (void)testSetCard_completeCard {
@ -453,10 +372,7 @@
card.cvc = cvc;
sut.postalCodeField.text = @"90210";
[sut setCardParams:card];
// The view model needs to request card metadata to choose the correct image, so give it
// time for a network roundtrip
XCTestExpectation *expectation = [self expectationWithDescription:@"Image fetching"];
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(STPTestingNetworkRequestTimeout * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
NSData *imgData = UIImagePNGRepresentation(sut.brandImageView.image);
NSData *expectedImgData = UIImagePNGRepresentation([STPPaymentCardTextField brandImageForCardBrand:STPCardBrandVisa]);
@ -467,11 +383,6 @@
XCTAssertEqualObjects(sut.cvcField.text, cvc);
XCTAssertNil(sut.currentFirstResponderField);
XCTAssertTrue(sut.isValid);
[expectation fulfill];
});
[self waitForExpectationsWithTimeout:2*STPTestingNetworkRequestTimeout handler:nil];
}
- (void)testSetCard_empty {
@ -481,10 +392,7 @@
sut.expirationField.text = @"10/99";
STPPaymentMethodCardParams *card = [STPPaymentMethodCardParams new];
[sut setCardParams:card];
// The view model needs to request card metadata to choose the correct image, so give it
// time for a network roundtrip
XCTestExpectation *expectation = [self expectationWithDescription:@"Image fetching"];
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(STPTestingNetworkRequestTimeout * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
NSData *imgData = UIImagePNGRepresentation(sut.brandImageView.image);
NSData *expectedImgData = UIImagePNGRepresentation([STPPaymentCardTextField brandImageForCardBrand:STPCardBrandUnknown]);
@ -496,11 +404,6 @@
XCTAssertEqual(sut.cvcField.text.length, (NSUInteger)0);
XCTAssertNil(sut.currentFirstResponderField);
XCTAssertFalse(sut.isValid);
[expectation fulfill];
});
[self waitForExpectationsWithTimeout:2*STPTestingNetworkRequestTimeout handler:nil];
}
#pragma clang diagnostic push
@ -585,333 +488,299 @@
@implementation STPPaymentCardTextFieldUITests
//- (void)setUp {
// [super setUp];
// self.window = [[UIWindow alloc] initWithFrame:[UIScreen mainScreen].bounds];
// STPPaymentCardTextField *textField = [[STPPaymentCardTextField alloc] initWithFrame:self.window.bounds];
// [self.window addSubview:textField];
// XCTAssertTrue([textField.numberField canBecomeFirstResponder], @"text field cannot become first responder");
// self.sut = textField;
//}
//
//#pragma mark - UI Tests
//
//- (void)testSetCard_allFields_whileEditingNumber {
// XCTAssertTrue([self.sut.numberField becomeFirstResponder], @"text field is not first responder");
// STPPaymentMethodCardParams *card = [STPPaymentMethodCardParams new];
// self.sut.postalCodeField.text = @"90210";
// NSString *number = @"4242424242424242";
// NSString *cvc = @"123";
// card.number = number;
// card.expMonth = @(10);
// card.expYear = @(99);
// card.cvc = cvc;
// [self.sut setCardParams:card];
//
// // The view model needs to request card metadata to choose the correct image, so give it
// // time for a network roundtrip
// XCTestExpectation *expectation = [self expectationWithDescription:@"Image fetching"];
// dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(STPTestingNetworkRequestTimeout * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
// NSData *imgData = UIImagePNGRepresentation(self.sut.brandImageView.image);
// NSData *expectedImgData = UIImagePNGRepresentation([STPPaymentCardTextField brandImageForCardBrand:STPCardBrandVisa]);
//
// XCTAssertNotNil(self.sut.focusedTextFieldForLayout);
// XCTAssertTrue([expectedImgData isEqualToData:imgData]);
// XCTAssertEqualObjects(self.sut.numberField.text, number);
// XCTAssertEqualObjects(self.sut.expirationField.text, @"10/99");
// XCTAssertEqualObjects(self.sut.cvcField.text, cvc);
// XCTAssertEqualObjects(self.sut.postalCode, @"90210");
// XCTAssertTrue([self.sut isFirstResponder], @"after `setCardParams:`, should still be first responder to allow number editing");
// XCTAssertTrue(self.sut.isValid);
//
// [expectation fulfill];
// });
//
// [self waitForExpectationsWithTimeout:2*STPTestingNetworkRequestTimeout handler:nil];
//}
//
//- (void)testSetCard_partialNumberAndExpiration_whileEditingExpiration {
// XCTAssertTrue([self.sut.expirationField becomeFirstResponder], @"text field is not first responder");
// STPPaymentMethodCardParams *card = [STPPaymentMethodCardParams new];
// NSString *number = @"42";
// card.number = number;
// card.expMonth = @(10);
// card.expYear = @(99);
// [self.sut setCardParams:card];
//
// // The view model needs to request card metadata to choose the correct image, so give it
// // time for a network roundtrip
// XCTestExpectation *expectation = [self expectationWithDescription:@"Image fetching"];
// dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(STPTestingNetworkRequestTimeout * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
// NSData *imgData = UIImagePNGRepresentation(self.sut.brandImageView.image);
// NSData *expectedImgData = UIImagePNGRepresentation([STPPaymentCardTextField cvcImageForCardBrand:STPCardBrandVisa]);
//
// XCTAssertNotNil(self.sut.focusedTextFieldForLayout);
// XCTAssertTrue(self.sut.focusedTextFieldForLayout.integerValue == STPCardFieldTypeCVC);
// XCTAssertTrue([expectedImgData isEqualToData:imgData]);
// XCTAssertEqualObjects(self.sut.numberField.text, number);
// XCTAssertEqualObjects(self.sut.expirationField.text, @"10/99");
// XCTAssertEqual(self.sut.cvcField.text.length, (NSUInteger)0);
// XCTAssertTrue([self.sut.cvcField isFirstResponder], @"after `setCardParams:`, when firstResponder becomes valid, first invalid field should become firstResponder");
// XCTAssertFalse(self.sut.isValid);
//
// [expectation fulfill];
// });
//
// [self waitForExpectationsWithTimeout:2*STPTestingNetworkRequestTimeout handler:nil];
//}
//
//- (void)testSetCard_number_whileEditingCVC {
// XCTAssertTrue([self.sut.cvcField becomeFirstResponder], @"text field is not first responder");
// STPPaymentMethodCardParams *card = [STPPaymentMethodCardParams new];
// NSString *number = @"4242424242424242";
// card.number = number;
// [self.sut setCardParams:card];
//
// // The view model needs to request card metadata to choose the correct image, so give it
// // time for a network roundtrip
// XCTestExpectation *expectation = [self expectationWithDescription:@"Image fetching"];
// dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(STPTestingNetworkRequestTimeout * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
// NSData *imgData = UIImagePNGRepresentation(self.sut.brandImageView.image);
// NSData *expectedImgData = UIImagePNGRepresentation([STPPaymentCardTextField cvcImageForCardBrand:STPCardBrandVisa]);
//
// XCTAssertNotNil(self.sut.focusedTextFieldForLayout);
// XCTAssertTrue(self.sut.focusedTextFieldForLayout.integerValue == STPCardFieldTypeCVC);
// XCTAssertTrue([expectedImgData isEqualToData:imgData]);
// XCTAssertEqualObjects(self.sut.numberField.text, number);
// XCTAssertEqual(self.sut.expirationField.text.length, (NSUInteger)0);
// XCTAssertEqual(self.sut.cvcField.text.length, (NSUInteger)0);
// XCTAssertTrue([self.sut.cvcField isFirstResponder], @"after `setCardParams:`, if firstResponder is invalid, it should remain firstResponder");
// XCTAssertFalse(self.sut.isValid);
//
// [expectation fulfill];
// });
//
// [self waitForExpectationsWithTimeout:2*STPTestingNetworkRequestTimeout handler:nil];
//}
//
//- (void)testSetCard_empty_whileEditingNumber {
// self.sut.numberField.text = @"4242424242424242";
// self.sut.cvcField.text = @"123";
// self.sut.expirationField.text = @"10/99";
// XCTAssertTrue([self.sut.numberField becomeFirstResponder], @"text field is not first responder");
// STPPaymentMethodCardParams *card = [STPPaymentMethodCardParams new];
// [self.sut setCardParams:card];
//
// // The view model needs to request card metadata to choose the correct image, so give it
// // time for a network roundtrip
// XCTestExpectation *expectation = [self expectationWithDescription:@"Image fetching"];
// dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(STPTestingNetworkRequestTimeout * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
// NSData *imgData = UIImagePNGRepresentation(self.sut.brandImageView.image);
// NSData *expectedImgData = UIImagePNGRepresentation([STPPaymentCardTextField brandImageForCardBrand:STPCardBrandUnknown]);
//
// XCTAssertNotNil(self.sut.focusedTextFieldForLayout);
// XCTAssertTrue(self.sut.focusedTextFieldForLayout.integerValue == STPCardFieldTypeNumber);
// XCTAssertTrue([expectedImgData isEqualToData:imgData]);
// XCTAssertEqual(self.sut.numberField.text.length, (NSUInteger)0);
// XCTAssertEqual(self.sut.expirationField.text.length, (NSUInteger)0);
// XCTAssertEqual(self.sut.cvcField.text.length, (NSUInteger)0);
// XCTAssertTrue([self.sut.numberField isFirstResponder], @"after `setCardParams:` that clears the text fields, the first invalid field should become firstResponder");
// XCTAssertFalse(self.sut.isValid);
//
// [expectation fulfill];
// });
//
// [self waitForExpectationsWithTimeout:2*STPTestingNetworkRequestTimeout handler:nil];
//}
//
//- (void)testIsValidKVO {
// id observer = OCMClassMock([UIViewController class]);
// self.sut.numberField.text = @"4242424242424242";
// self.sut.expirationField.text = @"10/99";
// self.sut.postalCodeField.text = @"90210";
// XCTAssertFalse(self.sut.isValid);
//
// NSString *expectedKeyPath = @"sut.isValid";
// [self addObserver:observer forKeyPath:expectedKeyPath options:NSKeyValueObservingOptionNew context:nil];
// XCTestExpectation *exp = [self expectationWithDescription:@"observeValue"];
// OCMStub([observer observeValueForKeyPath:[OCMArg any] ofObject:[OCMArg any] change:[OCMArg any] context:nil])
// .andDo(^(NSInvocation *invocation) {
// NSString *keyPath;
// NSDictionary *change;
// [invocation getArgument:&keyPath atIndex:2];
// [invocation getArgument:&change atIndex:4];
// if ([keyPath isEqualToString:expectedKeyPath]) {
// if ([change[@"new"] boolValue]) {
// [exp fulfill];
// [self removeObserver:observer forKeyPath:@"sut.isValid"];
// }
// }
// });
//
// self.sut.cvcField.text = @"123";
//
// [self waitForExpectationsWithTimeout:2 handler:nil];
//}
//
//- (void)testBecomeFirstResponder {
// self.sut.postalCodeEntryEnabled = NO;
// XCTAssertTrue([self.sut canBecomeFirstResponder]);
// XCTAssertTrue([self.sut becomeFirstResponder]);
// XCTAssertTrue(self.sut.isFirstResponder);
//
// XCTAssertEqual(self.sut.numberField, self.sut.currentFirstResponderField);
//
// [self.sut becomeFirstResponder];
// XCTAssertEqual(self.sut.numberField, self.sut.currentFirstResponderField,
// @"Repeated calls to becomeFirstResponder should not change the firstResponder");
//
// self.sut.numberField.text = @"4242" "4242" "4242" "4242";
//
// XCTAssertEqual(self.sut.numberField, self.sut.currentFirstResponderField,
// @"Should not auto-advance from number field");
//
// XCTAssertTrue([self.sut.cvcField becomeFirstResponder]);
// XCTAssertEqual(self.sut.cvcField, self.sut.currentFirstResponderField,
// @"We don't block other fields from becoming firstResponder");
//
// XCTAssertTrue([self.sut becomeFirstResponder]);
// XCTAssertEqual(self.sut.cvcField, self.sut.currentFirstResponderField,
// @"Calling becomeFirstResponder does not change the currentFirstResponder");
//
// self.sut.expirationField.text = @"10/99";
// self.sut.cvcField.text = @"123";
//
// XCTAssertTrue(self.sut.isValid);
// [self.sut resignFirstResponder];
// XCTAssertTrue([self.sut canBecomeFirstResponder]);
// XCTAssertTrue([self.sut becomeFirstResponder]);
//
// XCTAssertEqual(self.sut.cvcField, self.sut.currentFirstResponderField,
// @"When all fields are valid, the last one should be the preferred firstResponder");
//
// self.sut.postalCodeEntryEnabled = YES;
// XCTAssertFalse(self.sut.isValid);
//
// [self.sut resignFirstResponder];
// XCTAssertTrue([self.sut becomeFirstResponder]);
// XCTAssertEqual(self.sut.postalCodeField, self.sut.currentFirstResponderField,
// @"When postalCodeEntryEnabled=YES, it should become firstResponder after other fields are valid");
//
// self.sut.expirationField.text = @"";
// [self.sut resignFirstResponder];
// XCTAssertTrue([self.sut becomeFirstResponder]);
// XCTAssertEqual(self.sut.expirationField, self.sut.currentFirstResponderField,
// @"Moves firstResponder back to expiration, because it's not valid anymore");
//
// self.sut.expirationField.text = @"10/99";
// self.sut.postalCodeField.text = @"90210";
//
// XCTAssertTrue(self.sut.isValid);
// [self.sut resignFirstResponder];
// XCTAssertTrue([self.sut becomeFirstResponder]);
// XCTAssertEqual(self.sut.postalCodeField, self.sut.currentFirstResponderField,
// @"When all fields are valid, the last one should be the preferred firstResponder");
//}
//
//- (void)testShouldReturnCyclesThroughFields {
// PaymentCardTextFieldBlockDelegate *delegate = [PaymentCardTextFieldBlockDelegate new];
// delegate.willEndEditingForReturn = ^(__unused STPPaymentCardTextField *textField) {
// XCTFail(@"Did not expect editing to end in this test");
// };
// self.sut.delegate = delegate;
//
// [self.sut becomeFirstResponder];
// XCTAssertTrue(self.sut.numberField.isFirstResponder);
//
// XCTAssertFalse([self.sut.numberField.delegate textFieldShouldReturn:self.sut.numberField], @"shouldReturn = NO");
// XCTAssertTrue(self.sut.expirationField.isFirstResponder, @"with side effect to move 1st responder to next field");
//
// XCTAssertFalse([self.sut.expirationField.delegate textFieldShouldReturn:self.sut.expirationField], @"shouldReturn = NO");
// XCTAssertTrue(self.sut.cvcField.isFirstResponder, @"with side effect to move 1st responder to next field");
//
// XCTAssertFalse([self.sut.cvcField.delegate textFieldShouldReturn:self.sut.cvcField], @"shouldReturn = NO");
// XCTAssertTrue(self.sut.postalCodeField.isFirstResponder, @"with side effect to move 1st responder to next field");
//
// XCTAssertFalse([self.sut.postalCodeField.delegate textFieldShouldReturn:self.sut.postalCodeField], @"shouldReturn = NO");
// XCTAssertTrue(self.sut.numberField.isFirstResponder, @"with side effect to move 1st responder from last field to first invalid field");
//}
//
//- (void)testShouldReturnCyclesThroughFieldsWithoutPostal {
// PaymentCardTextFieldBlockDelegate *delegate = [PaymentCardTextFieldBlockDelegate new];
// delegate.willEndEditingForReturn = ^(__unused STPPaymentCardTextField *textField) {
// XCTFail(@"Did not expect editing to end in this test");
// };
// self.sut.delegate = delegate;
// self.sut.postalCodeEntryEnabled = NO;
//
// [self.sut becomeFirstResponder];
// XCTAssertTrue(self.sut.numberField.isFirstResponder);
//
// XCTAssertFalse([self.sut.numberField.delegate textFieldShouldReturn:self.sut.numberField], @"shouldReturn = NO");
// XCTAssertTrue(self.sut.expirationField.isFirstResponder, @"with side effect to move 1st responder to next field");
//
// XCTAssertFalse([self.sut.expirationField.delegate textFieldShouldReturn:self.sut.expirationField], @"shouldReturn = NO");
// XCTAssertTrue(self.sut.cvcField.isFirstResponder, @"with side effect to move 1st responder to next field");
//
// XCTAssertFalse([self.sut.cvcField.delegate textFieldShouldReturn:self.sut.cvcField], @"shouldReturn = NO");
// XCTAssertTrue(self.sut.numberField.isFirstResponder, @"with side effect to move 1st responder from last field to first invalid field");
//}
//
//- (void)testShouldReturnDismissesWhenValidNoPostalCode {
// __block BOOL hasReturned = NO;
// __block BOOL didEnd = NO;
//
// self.sut.postalCodeEntryEnabled = NO;
// [self.sut setCardParams:[STPFixtures paymentMethodCardParams]];
//
// PaymentCardTextFieldBlockDelegate *delegate = [PaymentCardTextFieldBlockDelegate new];
// delegate.willEndEditingForReturn = ^(__unused STPPaymentCardTextField *textField) {
// XCTAssertFalse(didEnd, @"willEnd is called before didEnd");
// XCTAssertFalse(hasReturned, @"willEnd is only called once");
// hasReturned = YES;
// };
// delegate.didEndEditing = ^(__unused STPPaymentCardTextField *textField) {
// XCTAssertTrue(hasReturned, @"didEndEditing should be called after willEnd");
// XCTAssertFalse(didEnd, @"didEnd is only called once");
// didEnd = YES;
// };
//
// self.sut.delegate = delegate;
// [self.sut becomeFirstResponder];
// XCTAssertTrue(self.sut.cvcField.isFirstResponder, @"when textfield is filled out, default first responder is the last field");
//
// XCTAssertFalse(hasReturned, @"willEndEditingForReturn delegate method should not have been called yet");
// XCTAssertFalse([self.sut.cvcField.delegate textFieldShouldReturn:self.sut.cvcField], @"shouldReturn = NO");
//
// XCTAssertNil(self.sut.currentFirstResponderField, @"Should have resigned first responder");
// XCTAssertTrue(hasReturned, @"delegate method has been invoked");
// XCTAssertTrue(didEnd, @"delegate method has been invoked");
//}
//
//- (void)testShouldReturnDismissesWhenValid {
// __block BOOL hasReturned = NO;
// __block BOOL didEnd = NO;
//
// [self.sut setCardParams:[STPFixtures paymentMethodCardParams]];
// self.sut.postalCodeField.text = @"90210";
// PaymentCardTextFieldBlockDelegate *delegate = [PaymentCardTextFieldBlockDelegate new];
// delegate.willEndEditingForReturn = ^(__unused STPPaymentCardTextField *textField) {
// XCTAssertFalse(didEnd, @"willEnd is called before didEnd");
// XCTAssertFalse(hasReturned, @"willEnd is only called once");
// hasReturned = YES;
// };
// delegate.didEndEditing = ^(__unused STPPaymentCardTextField *textField) {
// XCTAssertTrue(hasReturned, @"didEndEditing should be called after willEnd");
// XCTAssertFalse(didEnd, @"didEnd is only called once");
// didEnd = YES;
// };
//
// self.sut.delegate = delegate;
// [self.sut becomeFirstResponder];
// XCTAssertTrue(self.sut.postalCodeField.isFirstResponder, @"when textfield is filled out, default first responder is the last field");
//
// XCTAssertFalse(hasReturned, @"willEndEditingForReturn delegate method should not have been called yet");
// XCTAssertFalse([self.sut.postalCodeField.delegate textFieldShouldReturn:self.sut.postalCodeField], @"shouldReturn = NO");
//
// XCTAssertNil(self.sut.currentFirstResponderField, @"Should have resigned first responder");
// XCTAssertTrue(hasReturned, @"delegate method has been invoked");
// XCTAssertTrue(didEnd, @"delegate method has been invoked");
//}
+ (void)setUp {
[super setUp];
[[STPAPIClient sharedClient] setPublishableKey:STPTestingDefaultPublishableKey];
}
- (void)setUp {
[super setUp];
self.window = [[UIWindow alloc] initWithFrame:[UIScreen mainScreen].bounds];
STPPaymentCardTextField *textField = [[STPPaymentCardTextField alloc] initWithFrame:self.window.bounds];
[self.window addSubview:textField];
XCTAssertTrue([textField.numberField canBecomeFirstResponder], @"text field cannot become first responder");
self.sut = textField;
}
#pragma mark - UI Tests
- (void)testSetCard_allFields_whileEditingNumber {
XCTAssertTrue([self.sut.numberField becomeFirstResponder], @"text field is not first responder");
STPPaymentMethodCardParams *card = [STPPaymentMethodCardParams new];
self.sut.postalCodeField.text = @"90210";
NSString *number = @"4242424242424242";
NSString *cvc = @"123";
card.number = number;
card.expMonth = @(10);
card.expYear = @(99);
card.cvc = cvc;
[self.sut setCardParams:card];
NSData *imgData = UIImagePNGRepresentation(self.sut.brandImageView.image);
NSData *expectedImgData = UIImagePNGRepresentation([STPPaymentCardTextField brandImageForCardBrand:STPCardBrandVisa]);
XCTAssertNil(self.sut.focusedTextFieldForLayout);
XCTAssertTrue([expectedImgData isEqualToData:imgData]);
XCTAssertEqualObjects(self.sut.numberField.text, number);
XCTAssertEqualObjects(self.sut.expirationField.text, @"10/99");
XCTAssertEqualObjects(self.sut.cvcField.text, cvc);
XCTAssertEqualObjects(self.sut.postalCode, @"90210");
XCTAssertFalse([self.sut isFirstResponder], @"after `setCardParams:`, if all fields are valid, should resign firstResponder");
XCTAssertTrue(self.sut.isValid);
}
- (void)testSetCard_partialNumberAndExpiration_whileEditingExpiration {
XCTAssertTrue([self.sut.expirationField becomeFirstResponder], @"text field is not first responder");
STPPaymentMethodCardParams *card = [STPPaymentMethodCardParams new];
NSString *number = @"42";
card.number = number;
card.expMonth = @(10);
card.expYear = @(99);
[self.sut setCardParams:card];
NSData *imgData = UIImagePNGRepresentation(self.sut.brandImageView.image);
NSData *expectedImgData = UIImagePNGRepresentation([STPPaymentCardTextField cvcImageForCardBrand:STPCardBrandVisa]);
XCTAssertNotNil(self.sut.focusedTextFieldForLayout);
XCTAssertTrue(self.sut.focusedTextFieldForLayout.integerValue == STPCardFieldTypeCVC);
XCTAssertTrue([expectedImgData isEqualToData:imgData]);
XCTAssertEqualObjects(self.sut.numberField.text, number);
XCTAssertEqualObjects(self.sut.expirationField.text, @"10/99");
XCTAssertEqual(self.sut.cvcField.text.length, (NSUInteger)0);
XCTAssertTrue([self.sut.cvcField isFirstResponder], @"after `setCardParams:`, when firstResponder becomes valid, first invalid field should become firstResponder");
XCTAssertFalse(self.sut.isValid);
}
- (void)testSetCard_number_whileEditingCVC {
XCTAssertTrue([self.sut.cvcField becomeFirstResponder], @"text field is not first responder");
STPPaymentMethodCardParams *card = [STPPaymentMethodCardParams new];
NSString *number = @"4242424242424242";
card.number = number;
[self.sut setCardParams:card];
NSData *imgData = UIImagePNGRepresentation(self.sut.brandImageView.image);
NSData *expectedImgData = UIImagePNGRepresentation([STPPaymentCardTextField cvcImageForCardBrand:STPCardBrandVisa]);
XCTAssertNotNil(self.sut.focusedTextFieldForLayout);
XCTAssertTrue(self.sut.focusedTextFieldForLayout.integerValue == STPCardFieldTypeCVC);
XCTAssertTrue([expectedImgData isEqualToData:imgData]);
XCTAssertEqualObjects(self.sut.numberField.text, number);
XCTAssertEqual(self.sut.expirationField.text.length, (NSUInteger)0);
XCTAssertEqual(self.sut.cvcField.text.length, (NSUInteger)0);
XCTAssertTrue([self.sut.cvcField isFirstResponder], @"after `setCardParams:`, if firstResponder is invalid, it should remain firstResponder");
XCTAssertFalse(self.sut.isValid);
}
- (void)testSetCard_empty_whileEditingNumber {
self.sut.numberField.text = @"4242424242424242";
self.sut.cvcField.text = @"123";
self.sut.expirationField.text = @"10/99";
XCTAssertTrue([self.sut.numberField becomeFirstResponder], @"text field is not first responder");
STPPaymentMethodCardParams *card = [STPPaymentMethodCardParams new];
[self.sut setCardParams:card];
NSData *imgData = UIImagePNGRepresentation(self.sut.brandImageView.image);
NSData *expectedImgData = UIImagePNGRepresentation([STPPaymentCardTextField brandImageForCardBrand:STPCardBrandUnknown]);
XCTAssertNotNil(self.sut.focusedTextFieldForLayout);
XCTAssertTrue(self.sut.focusedTextFieldForLayout.integerValue == STPCardFieldTypeNumber);
XCTAssertTrue([expectedImgData isEqualToData:imgData]);
XCTAssertEqual(self.sut.numberField.text.length, (NSUInteger)0);
XCTAssertEqual(self.sut.expirationField.text.length, (NSUInteger)0);
XCTAssertEqual(self.sut.cvcField.text.length, (NSUInteger)0);
XCTAssertTrue([self.sut.numberField isFirstResponder], @"after `setCardParams:` that clears the text fields, the first invalid field should become firstResponder");
XCTAssertFalse(self.sut.isValid);
}
- (void)testIsValidKVO {
id observer = OCMClassMock([UIViewController class]);
self.sut.numberField.text = @"4242424242424242";
self.sut.expirationField.text = @"10/99";
self.sut.postalCodeField.text = @"90210";
XCTAssertFalse(self.sut.isValid);
NSString *expectedKeyPath = @"sut.isValid";
[self addObserver:observer forKeyPath:expectedKeyPath options:NSKeyValueObservingOptionNew context:nil];
XCTestExpectation *exp = [self expectationWithDescription:@"observeValue"];
OCMStub([observer observeValueForKeyPath:[OCMArg any] ofObject:[OCMArg any] change:[OCMArg any] context:nil])
.andDo(^(NSInvocation *invocation) {
NSString *keyPath;
NSDictionary *change;
[invocation getArgument:&keyPath atIndex:2];
[invocation getArgument:&change atIndex:4];
if ([keyPath isEqualToString:expectedKeyPath]) {
if ([change[@"new"] boolValue]) {
[exp fulfill];
[self removeObserver:observer forKeyPath:@"sut.isValid"];
}
}
});
self.sut.cvcField.text = @"123";
[self waitForExpectationsWithTimeout:STPTestingNetworkRequestTimeout handler:nil];
}
- (void)testBecomeFirstResponder {
self.sut.postalCodeEntryEnabled = NO;
XCTAssertTrue([self.sut canBecomeFirstResponder]);
XCTAssertTrue([self.sut becomeFirstResponder]);
XCTAssertTrue(self.sut.isFirstResponder);
XCTAssertEqual(self.sut.numberField, self.sut.currentFirstResponderField);
[self.sut becomeFirstResponder];
XCTAssertEqual(self.sut.numberField, self.sut.currentFirstResponderField,
@"Repeated calls to becomeFirstResponder should not change the firstResponder");
self.sut.numberField.text = @"4242" "4242" "4242" "4242";
// Don't unit test auto-advance from number field here because we don't know the cache state
XCTAssertTrue([self.sut.cvcField becomeFirstResponder]);
XCTAssertEqual(self.sut.cvcField, self.sut.currentFirstResponderField,
@"We don't block other fields from becoming firstResponder");
XCTAssertTrue([self.sut becomeFirstResponder]);
XCTAssertEqual(self.sut.cvcField, self.sut.currentFirstResponderField,
@"Calling becomeFirstResponder does not change the currentFirstResponder");
self.sut.expirationField.text = @"10/99";
self.sut.cvcField.text = @"123";
[self.sut resignFirstResponder];
XCTAssertTrue([self.sut canBecomeFirstResponder]);
XCTAssertTrue([self.sut becomeFirstResponder]);
XCTAssertEqual(self.sut.cvcField, self.sut.currentFirstResponderField,
@"When all fields are valid, the last one should be the preferred firstResponder");
self.sut.postalCodeEntryEnabled = YES;
XCTAssertFalse(self.sut.isValid);
[self.sut resignFirstResponder];
XCTAssertTrue([self.sut becomeFirstResponder]);
XCTAssertEqual(self.sut.postalCodeField, self.sut.currentFirstResponderField,
@"When postalCodeEntryEnabled=YES, it should become firstResponder after other fields are valid");
self.sut.expirationField.text = @"";
[self.sut resignFirstResponder];
XCTAssertTrue([self.sut becomeFirstResponder]);
XCTAssertEqual(self.sut.expirationField, self.sut.currentFirstResponderField,
@"Moves firstResponder back to expiration, because it's not valid anymore");
self.sut.expirationField.text = @"10/99";
self.sut.postalCodeField.text = @"90210";
[self.sut resignFirstResponder];
XCTAssertTrue([self.sut becomeFirstResponder]);
XCTAssertEqual(self.sut.postalCodeField, self.sut.currentFirstResponderField,
@"When all fields are valid, the last one should be the preferred firstResponder");
}
- (void)testShouldReturnCyclesThroughFields {
PaymentCardTextFieldBlockDelegate *delegate = [PaymentCardTextFieldBlockDelegate new];
delegate.willEndEditingForReturn = ^(__unused STPPaymentCardTextField *textField) {
XCTFail(@"Did not expect editing to end in this test");
};
self.sut.delegate = delegate;
[self.sut becomeFirstResponder];
XCTAssertTrue(self.sut.numberField.isFirstResponder);
XCTAssertFalse([self.sut.numberField.delegate textFieldShouldReturn:self.sut.numberField], @"shouldReturn = NO");
XCTAssertTrue(self.sut.expirationField.isFirstResponder, @"with side effect to move 1st responder to next field");
XCTAssertFalse([self.sut.expirationField.delegate textFieldShouldReturn:self.sut.expirationField], @"shouldReturn = NO");
XCTAssertTrue(self.sut.cvcField.isFirstResponder, @"with side effect to move 1st responder to next field");
XCTAssertFalse([self.sut.cvcField.delegate textFieldShouldReturn:self.sut.cvcField], @"shouldReturn = NO");
XCTAssertTrue(self.sut.postalCodeField.isFirstResponder, @"with side effect to move 1st responder to next field");
XCTAssertFalse([self.sut.postalCodeField.delegate textFieldShouldReturn:self.sut.postalCodeField], @"shouldReturn = NO");
XCTAssertTrue(self.sut.numberField.isFirstResponder, @"with side effect to move 1st responder from last field to first invalid field");
}
- (void)testShouldReturnCyclesThroughFieldsWithoutPostal {
PaymentCardTextFieldBlockDelegate *delegate = [PaymentCardTextFieldBlockDelegate new];
delegate.willEndEditingForReturn = ^(__unused STPPaymentCardTextField *textField) {
XCTFail(@"Did not expect editing to end in this test");
};
self.sut.delegate = delegate;
self.sut.postalCodeEntryEnabled = NO;
[self.sut becomeFirstResponder];
XCTAssertTrue(self.sut.numberField.isFirstResponder);
XCTAssertFalse([self.sut.numberField.delegate textFieldShouldReturn:self.sut.numberField], @"shouldReturn = NO");
XCTAssertTrue(self.sut.expirationField.isFirstResponder, @"with side effect to move 1st responder to next field");
XCTAssertFalse([self.sut.expirationField.delegate textFieldShouldReturn:self.sut.expirationField], @"shouldReturn = NO");
XCTAssertTrue(self.sut.cvcField.isFirstResponder, @"with side effect to move 1st responder to next field");
XCTAssertFalse([self.sut.cvcField.delegate textFieldShouldReturn:self.sut.cvcField], @"shouldReturn = NO");
XCTAssertTrue(self.sut.numberField.isFirstResponder, @"with side effect to move 1st responder from last field to first invalid field");
}
- (void)testShouldReturnDismissesWhenValidNoPostalCode {
__block BOOL hasReturned = NO;
__block BOOL didEnd = NO;
self.sut.postalCodeEntryEnabled = NO;
[self.sut setCardParams:[STPFixtures paymentMethodCardParams]];
PaymentCardTextFieldBlockDelegate *delegate = [PaymentCardTextFieldBlockDelegate new];
delegate.willEndEditingForReturn = ^(__unused STPPaymentCardTextField *textField) {
XCTAssertFalse(didEnd, @"willEnd is called before didEnd");
XCTAssertFalse(hasReturned, @"willEnd is only called once");
hasReturned = YES;
};
delegate.didEndEditing = ^(__unused STPPaymentCardTextField *textField) {
XCTAssertTrue(hasReturned, @"didEndEditing should be called after willEnd");
XCTAssertFalse(didEnd, @"didEnd is only called once");
didEnd = YES;
};
self.sut.delegate = delegate;
[self.sut becomeFirstResponder];
XCTAssertTrue(self.sut.cvcField.isFirstResponder, @"when textfield is filled out, default first responder is the last field");
XCTAssertFalse(hasReturned, @"willEndEditingForReturn delegate method should not have been called yet");
XCTAssertFalse([self.sut.cvcField.delegate textFieldShouldReturn:self.sut.cvcField], @"shouldReturn = NO");
XCTAssertNil(self.sut.currentFirstResponderField, @"Should have resigned first responder");
XCTAssertTrue(hasReturned, @"delegate method has been invoked");
XCTAssertTrue(didEnd, @"delegate method has been invoked");
}
- (void)testShouldReturnDismissesWhenValid {
__block BOOL hasReturned = NO;
__block BOOL didEnd = NO;
[self.sut setCardParams:[STPFixtures paymentMethodCardParams]];
self.sut.postalCodeField.text = @"90210";
PaymentCardTextFieldBlockDelegate *delegate = [PaymentCardTextFieldBlockDelegate new];
delegate.willEndEditingForReturn = ^(__unused STPPaymentCardTextField *textField) {
XCTAssertFalse(didEnd, @"willEnd is called before didEnd");
XCTAssertFalse(hasReturned, @"willEnd is only called once");
hasReturned = YES;
};
delegate.didEndEditing = ^(__unused STPPaymentCardTextField *textField) {
XCTAssertTrue(hasReturned, @"didEndEditing should be called after willEnd");
XCTAssertFalse(didEnd, @"didEnd is only called once");
didEnd = YES;
};
self.sut.delegate = delegate;
[self.sut becomeFirstResponder];
XCTAssertTrue(self.sut.postalCodeField.isFirstResponder, @"when textfield is filled out, default first responder is the last field");
XCTAssertFalse(hasReturned, @"willEndEditingForReturn delegate method should not have been called yet");
XCTAssertFalse([self.sut.postalCodeField.delegate textFieldShouldReturn:self.sut.postalCodeField], @"shouldReturn = NO");
XCTAssertNil(self.sut.currentFirstResponderField, @"Should have resigned first responder");
XCTAssertTrue(hasReturned, @"delegate method has been invoked");
XCTAssertTrue(didEnd, @"delegate method has been invoked");
}
@end

View File

@ -29,7 +29,7 @@
@[@"4242424242424242", @"4242424242424242"],
@[@"4242 4242 4242 4242", @"4242424242424242"],
@[@"4242xxx4242", @"42424242"],
@[@"12345678901234567890", @"12345678901234567890"],
@[@"12345678901234567890", @"1234567890123456789"],
];
for (NSArray *test in tests) {
self.viewModel.cardNumber = test[0];