diff --git a/README.md b/README.md index e96dd36dba..1d226407a0 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/Stripe.xcodeproj/project.pbxproj b/Stripe.xcodeproj/project.pbxproj index c69933e3fd..f05057f1cf 100644 --- a/Stripe.xcodeproj/project.pbxproj +++ b/Stripe.xcodeproj/project.pbxproj @@ -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 = ""; }; 363EA801241C3EC8000C7671 /* STPAUBECSDebitFormViewSnapshotTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = STPAUBECSDebitFormViewSnapshotTests.m; sourceTree = ""; }; 363EA803241C4346000C7671 /* STPAUBECSDebitFormView+Testing.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "STPAUBECSDebitFormView+Testing.h"; sourceTree = ""; }; + 364B75DB24F46354007D9FAB /* STPCardLoadingIndicator.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = STPCardLoadingIndicator.h; sourceTree = ""; }; + 364B75DC24F46354007D9FAB /* STPCardLoadingIndicator.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = STPCardLoadingIndicator.m; sourceTree = ""; }; 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 = ""; }; 3650AA4121C07E3C002B0893 /* AppDelegate.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = AppDelegate.m; sourceTree = ""; }; @@ -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 = ""; @@ -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 */, diff --git a/Stripe/STPAnalyticsClient.h b/Stripe/STPAnalyticsClient.h index eb05dbfbe4..6933ef9aa5 100644 --- a/Stripe/STPAnalyticsClient.h +++ b/Stripe/STPAnalyticsClient.h @@ -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; diff --git a/Stripe/STPAnalyticsClient.m b/Stripe/STPAnalyticsClient.m index d3ae04aa36..b53eb3247f 100644 --- a/Stripe/STPAnalyticsClient.m +++ b/Stripe/STPAnalyticsClient.m @@ -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:@{ diff --git a/Stripe/STPBINRange.h b/Stripe/STPBINRange.h index 427d807392..5fb68c91e8 100644 --- a/Stripe/STPBINRange.h +++ b/Stripe/STPBINRange.h @@ -23,12 +23,22 @@ typedef void (^STPRetrieveBINRangesCompletionBlock)(NSArray * _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 *)allRanges; + (NSArray *)binRangesForNumber:(NSString *)number; + (NSArray *)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; diff --git a/Stripe/STPBINRange.m b/Stripe/STPBINRange.m index 7e150f2a81..42ef9f58c1 100644 --- a/Stripe/STPBINRange.m +++ b/Stripe/STPBINRange.m @@ -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 *STPBINRangeAllRanges = nil; + (void)_performSyncWithAllRangesLock:(dispatch_block_t)block { @@ -110,7 +133,7 @@ static NSArray *STPBINRangeAllRanges = nil; if (STPBINRangeAllRanges == nil) { NSArray *ranges = @[ // Unknown - @[@"", @"", @16, @(STPCardBrandUnknown)], + @[@"", @"", @19, @(STPCardBrandUnknown)], // American Express @[@"34", @"34", @15, @(STPCardBrandAmex)], @@ -135,6 +158,7 @@ static NSArray *STPBINRangeAllRanges = nil; // UnionPay @[@"62", @"62", @16, @(STPCardBrandUnionPay)], + @[@"81", @"81", @16, @(STPCardBrandUnionPay)], // Visa @[@"40", @"49", @16, @(STPCardBrandVisa)], @@ -203,12 +227,13 @@ static NSArray *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 *> *sPendingRequests = nil; - // sRetrievedRanges tracks the bin prefixes for which we've already received metadata responses - static NSMutableDictionary *> *sRetrievedRanges = nil; - // sRetrievalQueue protects access to the two above dictionaries, sSpendingRequests and sRetrievedRanges +// sPendingRequests contains the completion blocks for a given metadata request that we have not yet gotten a response for +static NSMutableDictionary *> *sPendingRequests = nil; +// sRetrievedRanges tracks the bin prefixes for which we've already received metadata responses +static NSMutableDictionary *> *sRetrievedRanges = nil; + +// _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 *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 *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 *ranges = cardMetadata.ranges; NSArray *completionBlocks = sPendingRequests[binPrefixKey]; @@ -244,6 +301,8 @@ static NSArray *STPBINRangeAllRanges = nil; [self _performSyncWithAllRangesLock:^{ STPBINRangeAllRanges = [STPBINRangeAllRanges arrayByAddingObjectsFromArray:ranges]; }]; + } else { + [[STPAnalyticsClient sharedClient] logCardMetadataResponseFailureWithConfiguration:[STPPaymentConfiguration sharedConfiguration]]; } dispatch_async(dispatch_get_main_queue(), ^{ diff --git a/Stripe/STPCard.m b/Stripe/STPCard.m index 7c07691308..0c1c6a1360 100644 --- a/Stripe/STPCard.m +++ b/Stripe/STPCard.m @@ -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; diff --git a/Stripe/STPCardBINMetadata.m b/Stripe/STPCardBINMetadata.m index 418f4f064c..1b44dd2069 100644 --- a/Stripe/STPCardBINMetadata.m +++ b/Stripe/STPCardBINMetadata.m @@ -34,6 +34,7 @@ NS_ASSUME_NONNULL_BEGIN } } } + if (ranges.count == 0) { return nil; } diff --git a/Stripe/STPCardLoadingIndicator.h b/Stripe/STPCardLoadingIndicator.h new file mode 100644 index 0000000000..f8f57b9317 --- /dev/null +++ b/Stripe/STPCardLoadingIndicator.h @@ -0,0 +1,17 @@ +// +// STPCardLoadingIndicator.h +// StripeiOS +// +// Created by Cameron Sabol on 8/24/20. +// Copyright © 2020 Stripe, Inc. All rights reserved. +// + +#import + +NS_ASSUME_NONNULL_BEGIN + +@interface STPCardLoadingIndicator : UIView + +@end + +NS_ASSUME_NONNULL_END diff --git a/Stripe/STPCardLoadingIndicator.m b/Stripe/STPCardLoadingIndicator.m new file mode 100644 index 0000000000..6b9387e85b --- /dev/null +++ b/Stripe/STPCardLoadingIndicator.m @@ -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 diff --git a/Stripe/STPCardValidator+Private.h b/Stripe/STPCardValidator+Private.h index 04ef1252b6..2b99b55fb1 100644 --- a/Stripe/STPCardValidator+Private.h +++ b/Stripe/STPCardValidator+Private.h @@ -15,6 +15,8 @@ NS_ASSUME_NONNULL_BEGIN + (NSArray *)cardNumberFormatForBrand:(STPCardBrand)brand; + (NSArray *)cardNumberFormatForCardNumber:(NSString *)cardNumber; ++ (BOOL)stringIsValidLuhn:(NSString *)number; + @end NS_ASSUME_NONNULL_END diff --git a/Stripe/STPCardValidator+Private.m b/Stripe/STPCardValidator+Private.m index 5552b6a763..da18072926 100644 --- a/Stripe/STPCardValidator+Private.m +++ b/Stripe/STPCardValidator+Private.m @@ -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 diff --git a/Stripe/STPCardValidator.m b/Stripe/STPCardValidator.m index 91f8ad6b82..40da135613 100644 --- a/Stripe/STPCardValidator.m +++ b/Stripe/STPCardValidator.m @@ -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]]; diff --git a/Stripe/STPPaymentCardTextField.m b/Stripe/STPPaymentCardTextField.m index 698eafc6be..598aa6143d 100644 --- a/Stripe/STPPaymentCardTextField.m +++ b/Stripe/STPPaymentCardTextField.m @@ -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() { - 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 { @@ -1317,15 +1322,45 @@ typedef void (^STPLayoutAnimationCompletionBlock)(BOOL completed); [self updateCVCPlaceholder]; self.cvcField.validText = [self.viewModel validationStateForCVC] != STPCardValidationStateInvalid; [self updateImageForFieldType:fieldType]; - - [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 (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); } - // no auto-advance - }]; + } 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 { - return [self.class brandImageForCardBrand:self.viewModel.brand]; + 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,67 +1637,98 @@ typedef NS_ENUM(NSInteger, STPFieldEditingTransitionCallSite) { - (void)updateImageForFieldType:(STPCardFieldType)fieldType { - void (^applyImage)(STPCardFieldType, STPCardValidationState) = ^void(STPCardFieldType applyFieldType, STPCardValidationState validationState) { - UIImage *image = [self brandImageForFieldType:applyFieldType validationState:validationState]; - if (![image isEqual:self.brandImageView.image]) { - - STPCardBrand newBrand = self.viewModel.brand; - UIViewAnimationOptions imageAnimationOptions = [self brandImageAnimationOptionsForNewType:fieldType - newBrand:newBrand - oldType:self.currentBrandImageFieldType - oldBrand:self.currentBrandImageBrand]; - - self.currentBrandImageFieldType = applyFieldType; - self.currentBrandImageBrand = newBrand; - - [UIView transitionWithView:self.brandImageView - duration:0.2 - options:imageAnimationOptions - animations:^{ - self.brandImageView.image = image; - } - completion:nil]; - } + 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; + }]; }; - switch (fieldType) { + void (^removeLoadingIndicator)(void) = ^{ + if (self->_metadataLoadingIndicator != nil && !self->_metadataLoadingIndicator.isHidden) { + STPCardLoadingIndicator *loadingIndicator = self->_metadataLoadingIndicator; - 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; - } + [UIView animateWithDuration:0.6 delay:0 options:UIViewAnimationOptionCurveEaseInOut animations:^{ + loadingIndicator.alpha = 0.f; + } completion:^(__unused BOOL finished) { + loadingIndicator.alpha = 0.f; + loadingIndicator.hidden = YES; }]; } - break; + }; + + void (^applyBrandImage)(STPCardFieldType, STPCardValidationState) = ^void(STPCardFieldType applyFieldType, STPCardValidationState validationState) { + UIImage *image = [self brandImageForFieldType:applyFieldType validationState:validationState]; + if (![image isEqual:self.brandImageView.image]) { - case STPCardFieldTypeExpiration: - _isNumberImageRequestValid = NO; - applyImage(fieldType, [self.viewModel validationStateForExpiration]); - break; + STPCardBrand newBrand = self.viewModel.brand; + UIViewAnimationOptions imageAnimationOptions = [self brandImageAnimationOptionsForNewType:fieldType + newBrand:newBrand + oldType:self.currentBrandImageFieldType + oldBrand:self.currentBrandImageBrand]; - case STPCardFieldTypeCVC: - _isNumberImageRequestValid = NO; - applyImage(fieldType, [self.viewModel validationStateForCVC]); - break; + self.currentBrandImageFieldType = applyFieldType; + self.currentBrandImageBrand = newBrand; - case STPCardFieldTypePostalCode: - _isNumberImageRequestValid = NO; - applyImage(fieldType, [self.viewModel validationStateForPostalCode]); - break; + [UIView transitionWithView:self.brandImageView + duration:0.2 + options:imageAnimationOptions + animations:^{ + self.brandImageView.image = image; + } + completion:nil]; + } + }; + + 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: + applyBrandImage(STPCardFieldTypeNumber, [STPCardValidator validationStateForNumber:self.viewModel.cardNumber validatingCardBrand:YES]); + break; + + case STPCardFieldTypeExpiration: + applyBrandImage(fieldType, [self.viewModel validationStateForExpiration]); + break; + + case STPCardFieldTypeCVC: + applyBrandImage(fieldType, [self.viewModel validationStateForCVC]); + break; + + case STPCardFieldTypePostalCode: + applyBrandImage(fieldType, [self.viewModel validationStateForPostalCode]); + break; + } } - } - (NSString *)defaultCVCPlaceholder { @@ -1702,10 +1772,17 @@ typedef NS_ENUM(NSInteger, STPFieldEditingTransitionCallSite) { + (NSSet *)keyPathsForValuesAffectingIsValid { return [NSSet setWithArray:@[ - [NSString stringWithFormat:@"%@.%@", - NSStringFromSelector(@selector(viewModel)), - NSStringFromSelector(@selector(valid))], - ]]; + // viewModel.valid + [NSString stringWithFormat:@"%@.%@", + NSStringFromSelector(@selector(viewModel)), + NSStringFromSelector(@selector(valid))], + + // viewModel.hasCompleteMetadataForCardNumber + [NSString stringWithFormat:@"%@.%@", + NSStringFromSelector(@selector(viewModel)), + NSStringFromSelector(@selector(hasCompleteMetadataForCardNumber))], + ] + ]; } @end diff --git a/Stripe/STPPaymentCardTextFieldViewModel.h b/Stripe/STPPaymentCardTextFieldViewModel.h index 6d6b500f58..82c2977e5c 100644 --- a/Stripe/STPPaymentCardTextFieldViewModel.h +++ b/Stripe/STPPaymentCardTextFieldViewModel.h @@ -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; diff --git a/Stripe/STPPaymentCardTextFieldViewModel.m b/Stripe/STPPaymentCardTextFieldViewModel.m index d4b558090b..df33c7fd47 100644 --- a/Stripe/STPPaymentCardTextFieldViewModel.m +++ b/Stripe/STPPaymentCardTextFieldViewModel.m @@ -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 * _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)), ]]; } diff --git a/Tests/Tests/STPCardValidatorTest.m b/Tests/Tests/STPCardValidatorTest.m index ff57a325e4..385306d5ee 100644 --- a/Tests/Tests/STPCardValidatorTest.m +++ b/Tests/Tests/STPCardValidatorTest.m @@ -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]]; diff --git a/Tests/Tests/STPPaymentCardTextFieldTest.m b/Tests/Tests/STPPaymentCardTextFieldTest.m index c698ffee85..35338de83b 100644 --- a/Tests/Tests/STPPaymentCardTextFieldTest.m +++ b/Tests/Tests/STPPaymentCardTextFieldTest.m @@ -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,24 +98,16 @@ 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]); - - XCTAssertNotNil(sut.focusedTextFieldForLayout); - XCTAssertTrue(sut.focusedTextFieldForLayout.integerValue == STPCardFieldTypeNumber); - XCTAssertTrue([expectedImgData isEqualToData:imgData]); - XCTAssertEqualObjects(sut.numberField.text, number); - XCTAssertEqual(sut.expirationField.text.length, (NSUInteger)0); - XCTAssertEqual(sut.cvcField.text.length, (NSUInteger)0); - XCTAssertNil(sut.currentFirstResponderField); - [expectation fulfill]; - }); + NSData *imgData = UIImagePNGRepresentation(sut.brandImageView.image); + NSData *expectedImgData = UIImagePNGRepresentation([STPPaymentCardTextField errorImageForCardBrand:STPCardBrandUnknown]); - [self waitForExpectationsWithTimeout:2*STPTestingNetworkRequestTimeout handler:nil]; + XCTAssertNotNil(sut.focusedTextFieldForLayout); + XCTAssertTrue(sut.focusedTextFieldForLayout.integerValue == STPCardFieldTypeNumber); + XCTAssertTrue([expectedImgData isEqualToData:imgData]); + XCTAssertEqualObjects(sut.numberField.text, number); + XCTAssertEqual(sut.expirationField.text.length, (NSUInteger)0); + XCTAssertEqual(sut.cvcField.text.length, (NSUInteger)0); + XCTAssertNil(sut.currentFirstResponderField); } - (void)testSetCard_expiration { @@ -164,30 +161,22 @@ - (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]); - - XCTAssertNotNil(sut.focusedTextFieldForLayout); - XCTAssertTrue(sut.focusedTextFieldForLayout.integerValue == STPCardFieldTypeNumber); - XCTAssertTrue([expectedImgData isEqualToData:imgData]); - XCTAssertEqualObjects(sut.numberField.text, number); - XCTAssertEqual(sut.expirationField.text.length, (NSUInteger)0); - XCTAssertEqual(sut.cvcField.text.length, (NSUInteger)0); - XCTAssertEqualObjects(sut.cvcField.placeholder, @"CVC"); - XCTAssertNil(sut.currentFirstResponderField); - XCTAssertFalse(sut.isValid); - - [expectation fulfill]; - }); + + NSData *imgData = UIImagePNGRepresentation(sut.brandImageView.image); + NSData *expectedImgData = UIImagePNGRepresentation([STPPaymentCardTextField brandImageForCardBrand:STPCardBrandVisa]); - [self waitForExpectationsWithTimeout:2*STPTestingNetworkRequestTimeout handler:nil]; + XCTAssertNotNil(sut.focusedTextFieldForLayout); + XCTAssertTrue(sut.focusedTextFieldForLayout.integerValue == STPCardFieldTypeNumber); + XCTAssertTrue([expectedImgData isEqualToData:imgData]); + XCTAssertEqualObjects(sut.numberField.text, number); + XCTAssertEqual(sut.expirationField.text.length, (NSUInteger)0); + XCTAssertEqual(sut.cvcField.text.length, (NSUInteger)0); + XCTAssertEqualObjects(sut.cvcField.placeholder, @"CVC"); + XCTAssertNil(sut.currentFirstResponderField); + XCTAssertFalse(sut.isValid); } - (void)testSetCard_numberVisaInvalid { @@ -197,46 +186,30 @@ 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]; - }); + NSData *imgData = UIImagePNGRepresentation(sut.brandImageView.image); + NSData *expectedImgData = UIImagePNGRepresentation([STPPaymentCardTextField errorImageForCardBrand:STPCardBrandVisa]); - [self waitForExpectationsWithTimeout:2*STPTestingNetworkRequestTimeout handler:nil]; + XCTAssertTrue([expectedImgData isEqualToData:imgData]); } - (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]); - - XCTAssertNotNil(sut.focusedTextFieldForLayout); - XCTAssertTrue(sut.focusedTextFieldForLayout.integerValue == STPCardFieldTypeNumber); - XCTAssertTrue([expectedImgData isEqualToData:imgData]); - XCTAssertEqualObjects(sut.numberField.text, number); - XCTAssertEqual(sut.cvcField.text.length, (NSUInteger)0); - XCTAssertEqualObjects(sut.cvcField.placeholder, @"CVV"); - XCTAssertNil(sut.currentFirstResponderField); - XCTAssertFalse(sut.isValid); - [expectation fulfill]; - }); - [self waitForExpectationsWithTimeout:2*STPTestingNetworkRequestTimeout handler:nil]; + NSData *imgData = UIImagePNGRepresentation(sut.brandImageView.image); + NSData *expectedImgData = UIImagePNGRepresentation([STPPaymentCardTextField brandImageForCardBrand:STPCardBrandAmex]); + + XCTAssertNotNil(sut.focusedTextFieldForLayout); + XCTAssertTrue(sut.focusedTextFieldForLayout.integerValue == STPCardFieldTypeNumber); + XCTAssertTrue([expectedImgData isEqualToData:imgData]); + XCTAssertEqualObjects(sut.numberField.text, number); + XCTAssertEqual(sut.cvcField.text.length, (NSUInteger)0); + XCTAssertEqualObjects(sut.cvcField.placeholder, @"CVV"); + XCTAssertNil(sut.currentFirstResponderField); + XCTAssertFalse(sut.isValid); } - (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]; + NSData *imgData = UIImagePNGRepresentation(sut.brandImageView.image); + NSData *expectedImgData = UIImagePNGRepresentation([STPPaymentCardTextField errorImageForCardBrand:STPCardBrandAmex]); + + XCTAssertNotNil(sut.focusedTextFieldForLayout); + XCTAssertTrue(sut.focusedTextFieldForLayout.integerValue == STPCardFieldTypeNumber); + XCTAssertTrue([expectedImgData isEqualToData:imgData]); } - (void)testSetCard_numberAndExpiration { @@ -269,54 +235,39 @@ 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]); - - XCTAssertNotNil(sut.focusedTextFieldForLayout); - XCTAssertTrue([expectedImgData isEqualToData:imgData]); - XCTAssertEqualObjects(sut.numberField.text, number); - XCTAssertEqualObjects(sut.expirationField.text, @"10/99"); - XCTAssertEqual(sut.cvcField.text.length, (NSUInteger)0); - XCTAssertNil(sut.currentFirstResponderField); - XCTAssertFalse(sut.isValid); - [expectation fulfill]; - }); - [self waitForExpectationsWithTimeout:2*STPTestingNetworkRequestTimeout handler:nil]; + NSData *imgData = UIImagePNGRepresentation(sut.brandImageView.image); + NSData *expectedImgData = UIImagePNGRepresentation([STPPaymentCardTextField brandImageForCardBrand:STPCardBrandVisa]); + + XCTAssertNotNil(sut.focusedTextFieldForLayout); + XCTAssertTrue([expectedImgData isEqualToData:imgData]); + XCTAssertEqualObjects(sut.numberField.text, number); + XCTAssertEqualObjects(sut.expirationField.text, @"10/99"); + XCTAssertEqual(sut.cvcField.text.length, (NSUInteger)0); + XCTAssertNil(sut.currentFirstResponderField); + XCTAssertFalse(sut.isValid); } - (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]); - - XCTAssertNotNil(sut.focusedTextFieldForLayout); - XCTAssertTrue(sut.focusedTextFieldForLayout.integerValue == STPCardFieldTypeNumber); - XCTAssertTrue([expectedImgData isEqualToData:imgData]); - XCTAssertEqualObjects(sut.numberField.text, number); - XCTAssertEqualObjects(sut.expirationField.text, @"10/99"); - XCTAssertEqual(sut.cvcField.text.length, (NSUInteger)0); - XCTAssertNil(sut.currentFirstResponderField); - XCTAssertFalse(sut.isValid); - - [expectation fulfill]; - }); - [self waitForExpectationsWithTimeout:2*STPTestingNetworkRequestTimeout handler:nil]; + NSData *imgData = UIImagePNGRepresentation(sut.brandImageView.image); + NSData *expectedImgData = UIImagePNGRepresentation([STPPaymentCardTextField brandImageForCardBrand:STPCardBrandVisa]); + + XCTAssertNotNil(sut.focusedTextFieldForLayout); + XCTAssertTrue(sut.focusedTextFieldForLayout.integerValue == STPCardFieldTypeNumber); + XCTAssertTrue([expectedImgData isEqualToData:imgData]); + XCTAssertEqualObjects(sut.numberField.text, number); + XCTAssertEqualObjects(sut.expirationField.text, @"10/99"); + XCTAssertEqual(sut.cvcField.text.length, (NSUInteger)0); + XCTAssertNil(sut.currentFirstResponderField); + XCTAssertFalse(sut.isValid); } - (void)testSetCard_numberAndCVC { @@ -327,25 +278,17 @@ 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]); - - XCTAssertNotNil(sut.focusedTextFieldForLayout); - XCTAssertTrue([expectedImgData isEqualToData:imgData]); - XCTAssertEqualObjects(sut.numberField.text, number); - XCTAssertEqual(sut.expirationField.text.length, (NSUInteger)0); - XCTAssertEqualObjects(sut.cvcField.text, cvc); - XCTAssertNil(sut.currentFirstResponderField); - XCTAssertFalse(sut.isValid); - - [expectation fulfill]; - }); - [self waitForExpectationsWithTimeout:2*STPTestingNetworkRequestTimeout handler:nil]; + NSData *imgData = UIImagePNGRepresentation(sut.brandImageView.image); + NSData *expectedImgData = UIImagePNGRepresentation([STPPaymentCardTextField brandImageForCardBrand:STPCardBrandAmex]); + + XCTAssertNotNil(sut.focusedTextFieldForLayout); + XCTAssertTrue([expectedImgData isEqualToData:imgData]); + XCTAssertEqualObjects(sut.numberField.text, number); + XCTAssertEqual(sut.expirationField.text.length, (NSUInteger)0); + XCTAssertEqualObjects(sut.cvcField.text, cvc); + XCTAssertNil(sut.currentFirstResponderField); + XCTAssertFalse(sut.isValid); } - (void)testSetCard_expirationAndCVC { @@ -356,26 +299,18 @@ 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]); - - XCTAssertNotNil(sut.focusedTextFieldForLayout); - XCTAssertTrue(sut.focusedTextFieldForLayout.integerValue == STPCardFieldTypeNumber); - XCTAssertTrue([expectedImgData isEqualToData:imgData]); - XCTAssertEqual(sut.numberField.text.length, (NSUInteger)0); - XCTAssertEqualObjects(sut.expirationField.text, @"10/99"); - XCTAssertEqualObjects(sut.cvcField.text, cvc); - XCTAssertNil(sut.currentFirstResponderField); - XCTAssertFalse(sut.isValid); - - [expectation fulfill]; - }); - [self waitForExpectationsWithTimeout:2*STPTestingNetworkRequestTimeout handler:nil]; + NSData *imgData = UIImagePNGRepresentation(sut.brandImageView.image); + NSData *expectedImgData = UIImagePNGRepresentation([STPPaymentCardTextField brandImageForCardBrand:STPCardBrandUnknown]); + + XCTAssertNotNil(sut.focusedTextFieldForLayout); + XCTAssertTrue(sut.focusedTextFieldForLayout.integerValue == STPCardFieldTypeNumber); + XCTAssertTrue([expectedImgData isEqualToData:imgData]); + XCTAssertEqual(sut.numberField.text.length, (NSUInteger)0); + XCTAssertEqualObjects(sut.expirationField.text, @"10/99"); + XCTAssertEqualObjects(sut.cvcField.text, cvc); + XCTAssertNil(sut.currentFirstResponderField); + XCTAssertFalse(sut.isValid); } - (void)testSetCard_completeCardCountryWithoutPostal { @@ -389,25 +324,17 @@ 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]); - - XCTAssertNotNil(sut.focusedTextFieldForLayout); - XCTAssertTrue([expectedImgData isEqualToData:imgData]); - XCTAssertEqualObjects(sut.numberField.text, number); - XCTAssertEqualObjects(sut.expirationField.text, @"10/99"); - XCTAssertEqualObjects(sut.cvcField.text, cvc); - XCTAssertNil(sut.currentFirstResponderField); - XCTAssertTrue(sut.isValid); - - [expectation fulfill]; - }); - [self waitForExpectationsWithTimeout:2*STPTestingNetworkRequestTimeout handler:nil]; + NSData *imgData = UIImagePNGRepresentation(sut.brandImageView.image); + NSData *expectedImgData = UIImagePNGRepresentation([STPPaymentCardTextField brandImageForCardBrand:STPCardBrandVisa]); + + XCTAssertNotNil(sut.focusedTextFieldForLayout); + XCTAssertTrue([expectedImgData isEqualToData:imgData]); + XCTAssertEqualObjects(sut.numberField.text, number); + XCTAssertEqualObjects(sut.expirationField.text, @"10/99"); + XCTAssertEqualObjects(sut.cvcField.text, cvc); + XCTAssertNil(sut.currentFirstResponderField); + XCTAssertTrue(sut.isValid); } - (void)testSetCard_completeCardNoPostal { @@ -421,25 +348,17 @@ 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]); - - XCTAssertNotNil(sut.focusedTextFieldForLayout); - XCTAssertTrue([expectedImgData isEqualToData:imgData]); - XCTAssertEqualObjects(sut.numberField.text, number); - XCTAssertEqualObjects(sut.expirationField.text, @"10/99"); - XCTAssertEqualObjects(sut.cvcField.text, cvc); - XCTAssertNil(sut.currentFirstResponderField); - XCTAssertTrue(sut.isValid); - - [expectation fulfill]; - }); - [self waitForExpectationsWithTimeout:2*STPTestingNetworkRequestTimeout handler:nil]; + NSData *imgData = UIImagePNGRepresentation(sut.brandImageView.image); + NSData *expectedImgData = UIImagePNGRepresentation([STPPaymentCardTextField brandImageForCardBrand:STPCardBrandVisa]); + + XCTAssertNotNil(sut.focusedTextFieldForLayout); + XCTAssertTrue([expectedImgData isEqualToData:imgData]); + XCTAssertEqualObjects(sut.numberField.text, number); + XCTAssertEqualObjects(sut.expirationField.text, @"10/99"); + XCTAssertEqualObjects(sut.cvcField.text, cvc); + XCTAssertNil(sut.currentFirstResponderField); + XCTAssertTrue(sut.isValid); } - (void)testSetCard_completeCard { @@ -453,25 +372,17 @@ 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]); - - XCTAssertNotNil(sut.focusedTextFieldForLayout); - XCTAssertTrue([expectedImgData isEqualToData:imgData]); - XCTAssertEqualObjects(sut.numberField.text, number); - XCTAssertEqualObjects(sut.expirationField.text, @"10/99"); - XCTAssertEqualObjects(sut.cvcField.text, cvc); - XCTAssertNil(sut.currentFirstResponderField); - XCTAssertTrue(sut.isValid); - - [expectation fulfill]; - }); - [self waitForExpectationsWithTimeout:2*STPTestingNetworkRequestTimeout handler:nil]; + NSData *imgData = UIImagePNGRepresentation(sut.brandImageView.image); + NSData *expectedImgData = UIImagePNGRepresentation([STPPaymentCardTextField brandImageForCardBrand:STPCardBrandVisa]); + + XCTAssertNotNil(sut.focusedTextFieldForLayout); + XCTAssertTrue([expectedImgData isEqualToData:imgData]); + XCTAssertEqualObjects(sut.numberField.text, number); + XCTAssertEqualObjects(sut.expirationField.text, @"10/99"); + XCTAssertEqualObjects(sut.cvcField.text, cvc); + XCTAssertNil(sut.currentFirstResponderField); + XCTAssertTrue(sut.isValid); } - (void)testSetCard_empty { @@ -481,26 +392,18 @@ 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]); - - XCTAssertNotNil(sut.focusedTextFieldForLayout); - XCTAssertTrue(sut.focusedTextFieldForLayout.integerValue == STPCardFieldTypeNumber); - XCTAssertTrue([expectedImgData isEqualToData:imgData]); - XCTAssertEqual(sut.numberField.text.length, (NSUInteger)0); - XCTAssertEqual(sut.expirationField.text.length, (NSUInteger)0); - XCTAssertEqual(sut.cvcField.text.length, (NSUInteger)0); - XCTAssertNil(sut.currentFirstResponderField); - XCTAssertFalse(sut.isValid); - - [expectation fulfill]; - }); - [self waitForExpectationsWithTimeout:2*STPTestingNetworkRequestTimeout handler:nil]; + NSData *imgData = UIImagePNGRepresentation(sut.brandImageView.image); + NSData *expectedImgData = UIImagePNGRepresentation([STPPaymentCardTextField brandImageForCardBrand:STPCardBrandUnknown]); + + XCTAssertNotNil(sut.focusedTextFieldForLayout); + XCTAssertTrue(sut.focusedTextFieldForLayout.integerValue == STPCardFieldTypeNumber); + XCTAssertTrue([expectedImgData isEqualToData:imgData]); + XCTAssertEqual(sut.numberField.text.length, (NSUInteger)0); + XCTAssertEqual(sut.expirationField.text.length, (NSUInteger)0); + XCTAssertEqual(sut.cvcField.text.length, (NSUInteger)0); + XCTAssertNil(sut.currentFirstResponderField); + XCTAssertFalse(sut.isValid); } #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 diff --git a/Tests/Tests/STPPaymentCardTextFieldViewModelTest.m b/Tests/Tests/STPPaymentCardTextFieldViewModelTest.m index 78b2652c24..6c7841d80e 100644 --- a/Tests/Tests/STPPaymentCardTextFieldViewModelTest.m +++ b/Tests/Tests/STPPaymentCardTextFieldViewModelTest.m @@ -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];