Repository: Adyen/adyen-ios
Pull request #2069: Tests | CardImageParser
Author: login: nauaros
Labels: name: chore
description: a pull request that has chore changes that shouldn't be in the release notes
Created: 2025-03-24T10:31:22Z | Merged: 2025-03-26T09:07:32Z

# Summary

Unit tests for:
- [x] CardImageParser
- [x] ExpirationDateFormatter

# Ticket

<ticket>
COIOS-000
</ticket>



Diff:
diff --git a/Adyen.xcodeproj/project.pbxproj b/Adyen.xcodeproj/project.pbxproj
index 5371cdf6f7..ad511456b0 100644
--- a/Adyen.xcodeproj/project.pbxproj
+++ b/Adyen.xcodeproj/project.pbxproj
@@ -533,6 +533,7 @@
 		C930FB6E269D79E0006A26D2 /* AffirmDetails.swift in Sources */ = {isa = PBXBuildFile; fileRef = C930FB6D269D79E0006A26D2 /* AffirmDetails.swift */; };
 		C933801E276A06E1005B66CD /* BACSDirectDebitComponentTracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = C933801D276A06E1005B66CD /* BACSDirectDebitComponentTracker.swift */; };
 		C9338020276A09E7005B66CD /* BACSDirectDebitComponentTrackerProtocolMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = C933801F276A09E7005B66CD /* BACSDirectDebitComponentTrackerProtocolMock.swift */; };
+		C933BE592D91659600EBFF7E /* ExpirationDateFormatterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C933BE582D91659600EBFF7E /* ExpirationDateFormatterTests.swift */; };
 		C93844732D79DF3B00B1E747 /* CardScannerViewModelMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = C93844722D79DF3B00B1E747 /* CardScannerViewModelMock.swift */; };
 		C93844752D7B1C4100B1E747 /* CardScannerViewControllerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C93844742D7B1C4100B1E747 /* CardScannerViewControllerTests.swift */; };
 		C93B01B72760B03400D311A1 /* BACSConfirmationViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = C93B01B62760B03400D311A1 /* BACSConfirmationViewController.swift */; };
@@ -541,6 +542,8 @@
 		C9454C38276A34150086C218 /* BACSDirectDebitPresentationDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = C9454C35276A33A00086C218 /* BACSDirectDebitPresentationDelegate.swift */; };
 		C94632BE27BA6985003DD81F /* AnalyticsProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = C94632BD27BA6985003DD81F /* AnalyticsProvider.swift */; };
 		C94639D02D5F68BC004A1B0C /* CaptureSessionManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = C94639CF2D5F68BC004A1B0C /* CaptureSessionManager.swift */; };
+		C948B6672D8AC5C9000331AA /* CardImageParserTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C948B6662D8AC5C9000331AA /* CardImageParserTests.swift */; };
+		C948B6692D8AD0CA000331AA /* ExpirationDateFormattingMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = C948B6682D8AD0CA000331AA /* ExpirationDateFormattingMock.swift */; };
 		C95903DE275A48D000E7D3BC /* BACSDirectDebitComponentTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C95903DD275A48D000E7D3BC /* BACSDirectDebitComponentTests.swift */; };
 		C96688BF26A6FC1C00DC7297 /* AffirmComponentTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C96688BE26A6FC1C00DC7297 /* AffirmComponentTests.swift */; };
 		C9680BE62D6CAD590076B93D /* CardScannerAssembler.swift in Sources */ = {isa = PBXBuildFile; fileRef = C9680BE52D6CAD590076B93D /* CardScannerAssembler.swift */; };
@@ -1910,6 +1913,7 @@
 		C930FB6D269D79E0006A26D2 /* AffirmDetails.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AffirmDetails.swift; sourceTree = "<group>"; };
 		C933801D276A06E1005B66CD /* BACSDirectDebitComponentTracker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BACSDirectDebitComponentTracker.swift; sourceTree = "<group>"; };
 		C933801F276A09E7005B66CD /* BACSDirectDebitComponentTrackerProtocolMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BACSDirectDebitComponentTrackerProtocolMock.swift; sourceTree = "<group>"; };
+		C933BE582D91659600EBFF7E /* ExpirationDateFormatterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExpirationDateFormatterTests.swift; sourceTree = "<group>"; };
 		C93844722D79DF3B00B1E747 /* CardScannerViewModelMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CardScannerViewModelMock.swift; sourceTree = "<group>"; };
 		C93844742D7B1C4100B1E747 /* CardScannerViewControllerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CardScannerViewControllerTests.swift; sourceTree = "<group>"; };
 		C93B01B62760B03400D311A1 /* BACSConfirmationViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BACSConfirmationViewController.swift; sourceTree = "<group>"; };
@@ -1917,6 +1921,8 @@
 		C9454C35276A33A00086C218 /* BACSDirectDebitPresentationDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BACSDirectDebitPresentationDelegate.swift; sourceTree = "<group>"; };
 		C94632BD27BA6985003DD81F /* AnalyticsProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalyticsProvider.swift; sourceTree = "<group>"; };
 		C94639CF2D5F68BC004A1B0C /* CaptureSessionManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CaptureSessionManager.swift; sourceTree = "<group>"; };
+		C948B6662D8AC5C9000331AA /* CardImageParserTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CardImageParserTests.swift; sourceTree = "<group>"; };
+		C948B6682D8AD0CA000331AA /* ExpirationDateFormattingMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExpirationDateFormattingMock.swift; sourceTree = "<group>"; };
 		C95903DD275A48D000E7D3BC /* BACSDirectDebitComponentTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BACSDirectDebitComponentTests.swift; sourceTree = "<group>"; };
 		C95C89312BF63A3500C47296 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
 		C96688BE26A6FC1C00DC7297 /* AffirmComponentTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AffirmComponentTests.swift; sourceTree = "<group>"; };
@@ -3502,6 +3508,7 @@
 				C91BF2572D771667001F19DE /* CardImageParsingMock.swift */,
 				C91BF2592D7716D4001F19DE /* CaptureSessionManagingMock.swift */,
 				C93844722D79DF3B00B1E747 /* CardScannerViewModelMock.swift */,
+				C948B6682D8AD0CA000331AA /* ExpirationDateFormattingMock.swift */,
 			);
 			path = Mocks;
 			sourceTree = "<group>";
@@ -3610,6 +3617,8 @@
 				C96844922D771328001DB7F1 /* CardScannerViewModelTests.swift */,
 				C91BF25B2D772233001F19DE /* Assets.xcassets */,
 				C93844742D7B1C4100B1E747 /* CardScannerViewControllerTests.swift */,
+				C948B6662D8AC5C9000331AA /* CardImageParserTests.swift */,
+				C933BE582D91659600EBFF7E /* ExpirationDateFormatterTests.swift */,
 			);
 			path = AdyenCardScannerTests;
 			sourceTree = "<group>";
@@ -7094,7 +7103,10 @@
 			buildActionMask = 2147483647;
 			files = (
 				C91BF2582D771667001F19DE /* CardImageParsingMock.swift in Sources */,
+				C933BE592D91659600EBFF7E /* ExpirationDateFormatterTests.swift in Sources */,
+				C948B6692D8AD0CA000331AA /* ExpirationDateFormattingMock.swift in Sources */,
 				C93844752D7B1C4100B1E747 /* CardScannerViewControllerTests.swift in Sources */,
+				C948B6672D8AC5C9000331AA /* CardImageParserTests.swift in Sources */,
 				C93844732D79DF3B00B1E747 /* CardScannerViewModelMock.swift in Sources */,
 				C91BF25A2D7716D4001F19DE /* CaptureSessionManagingMock.swift in Sources */,
 				C96844932D771328001DB7F1 /* CardScannerViewModelTests.swift in Sources */,
diff --git a/AdyenCardScanner/Sources/CardScannerAssembler.swift b/AdyenCardScanner/Sources/CardScannerAssembler.swift
index 82c64dd668..c0f425ba03 100644
--- a/AdyenCardScanner/Sources/CardScannerAssembler.swift
+++ b/AdyenCardScanner/Sources/CardScannerAssembler.swift
@@ -33,11 +33,7 @@ internal class CardScannerAssembler: CardScannerAssembling {
     internal func resolveCardScannerViewController(
         completion: @escaping (Result<CardScanDetails, CardScannerError>) -> Void
     ) -> UIViewController? {
-        guard let captureDevice else {
-            // TODO: Remove after error handling implementation
-            print("Card scanner: cannot resolve view controller, missing capture device")
-            return nil
-        }
+        guard let captureDevice else { return nil }
 
         let expireDateFormatter = ExpirationDateFormatter()
         let cardImageParser = CardImageParser(expirationDateFormatter: expireDateFormatter)
diff --git a/AdyenCardScannerTests/Assets.xcassets/test-card-number-1.imageset/Contents.json b/AdyenCardScannerTests/Assets.xcassets/test-card-number-1.imageset/Contents.json
new file mode 100644
index 0000000000..577eb048fe
--- /dev/null
+++ b/AdyenCardScannerTests/Assets.xcassets/test-card-number-1.imageset/Contents.json
@@ -0,0 +1,21 @@
+{
+  "images" : [
+    {
+      "filename" : "test-card-number-1.jpg",
+      "idiom" : "universal",
+      "scale" : "1x"
+    },
+    {
+      "idiom" : "universal",
+      "scale" : "2x"
+    },
+    {
+      "idiom" : "universal",
+      "scale" : "3x"
+    }
+  ],
+  "info" : {
+    "author" : "xcode",
+    "version" : 1
+  }
+}
diff --git a/AdyenCardScannerTests/Assets.xcassets/test-card-number-1.imageset/test-card-number-1.jpg b/AdyenCardScannerTests/Assets.xcassets/test-card-number-1.imageset/test-card-number-1.jpg
new file mode 100644
index 0000000000..a31330fafc
Binary files /dev/null and b/AdyenCardScannerTests/Assets.xcassets/test-card-number-1.imageset/test-card-number-1.jpg differ
diff --git a/AdyenCardScannerTests/Assets.xcassets/test-card-number-2.imageset/Contents.json b/AdyenCardScannerTests/Assets.xcassets/test-card-number-2.imageset/Contents.json
new file mode 100644
index 0000000000..6d11e1e59e
--- /dev/null
+++ b/AdyenCardScannerTests/Assets.xcassets/test-card-number-2.imageset/Contents.json
@@ -0,0 +1,21 @@
+{
+  "images" : [
+    {
+      "filename" : "test-card-number-2.jpg",
+      "idiom" : "universal",
+      "scale" : "1x"
+    },
+    {
+      "idiom" : "universal",
+      "scale" : "2x"
+    },
+    {
+      "idiom" : "universal",
+      "scale" : "3x"
+    }
+  ],
+  "info" : {
+    "author" : "xcode",
+    "version" : 1
+  }
+}
diff --git a/AdyenCardScannerTests/Assets.xcassets/test-card-number-2.imageset/test-card-number-2.jpg b/AdyenCardScannerTests/Assets.xcassets/test-card-number-2.imageset/test-card-number-2.jpg
new file mode 100644
index 0000000000..80510d5818
Binary files /dev/null and b/AdyenCardScannerTests/Assets.xcassets/test-card-number-2.imageset/test-card-number-2.jpg differ
diff --git a/AdyenCardScannerTests/Assets.xcassets/test-card-number-3.imageset/Contents.json b/AdyenCardScannerTests/Assets.xcassets/test-card-number-3.imageset/Contents.json
new file mode 100644
index 0000000000..d06ef600ad
--- /dev/null
+++ b/AdyenCardScannerTests/Assets.xcassets/test-card-number-3.imageset/Contents.json
@@ -0,0 +1,21 @@
+{
+  "images" : [
+    {
+      "filename" : "test-card-number-3.png",
+      "idiom" : "universal",
+      "scale" : "1x"
+    },
+    {
+      "idiom" : "universal",
+      "scale" : "2x"
+    },
+    {
+      "idiom" : "universal",
+      "scale" : "3x"
+    }
+  ],
+  "info" : {
+    "author" : "xcode",
+    "version" : 1
+  }
+}
diff --git a/AdyenCardScannerTests/Assets.xcassets/test-card-number-3.imageset/test-card-number-3.png b/AdyenCardScannerTests/Assets.xcassets/test-card-number-3.imageset/test-card-number-3.png
new file mode 100644
index 0000000000..d372988ffc
Binary files /dev/null and b/AdyenCardScannerTests/Assets.xcassets/test-card-number-3.imageset/test-card-number-3.png differ
diff --git a/AdyenCardScannerTests/CardImageParserTests.swift b/AdyenCardScannerTests/CardImageParserTests.swift
new file mode 100644
index 0000000000..cad87ce8bd
--- /dev/null
+++ b/AdyenCardScannerTests/CardImageParserTests.swift
@@ -0,0 +1,158 @@
+//
+// Copyright (c) 2025 Adyen N.V.
+//
+// This file is open source and available under the MIT license. See the LICENSE file for more info.
+//
+
+@testable import AdyenCardScanner
+import XCTest
+
+
+final class CardImageParserTests: XCTestCase {
+
+    var sut: CardImageParser!
+    var expirationDateFormatter: ExpirationDateFormatter!
+
+    override func setUpWithError() throws {
+        try super.setUpWithError()
+        expirationDateFormatter = ExpirationDateFormatter()
+    }
+
+    override func tearDownWithError() throws {
+        sut = nil
+        expirationDateFormatter = nil
+        try super.tearDownWithError()
+    }
+
+    func testParseImageWithHighContrastCardImage() throws {
+        // Given
+        let expirationDateFormatter = ExpirationDateFormatter()
+        sut = CardImageParser(expirationDateFormatter: expirationDateFormatter)
+
+        let testCreditCard = try XCTUnwrap(testCreditCardHighContrast)
+        let expectation = expectation(description: "Image should be parsed")
+
+        // When
+        sut.parse(image: testCreditCard.image) { receivedCreditCard in
+            expectation.fulfill()
+
+            let expectedCreditCard = testCreditCard.creditCard
+            XCTAssertEqual(expectedCreditCard.number, receivedCreditCard.number)
+            XCTAssertEqual(expectedCreditCard.expirationDate, receivedCreditCard.expirationDate)
+        }
+
+        // Then
+        wait(for: [expectation], timeout: 0.1)
+    }
+
+    func testParseImageWithLowContrastCardImage() throws {
+        // Given
+        let expirationDateFormatter = ExpirationDateFormatter()
+        sut = CardImageParser(expirationDateFormatter: expirationDateFormatter)
+
+        let testCreditCard = try XCTUnwrap(testCreditCardLowContrast)
+        let expectation = expectation(description: "Image should be parsed")
+
+        // When
+        sut.parse(image: testCreditCard.image) { receivedCreditCard in
+            expectation.fulfill()
+
+            let expectedCreditCard = testCreditCard.creditCard
+            XCTAssertEqual(expectedCreditCard.number, receivedCreditCard.number)
+            XCTAssertEqual(expectedCreditCard.expirationDate, receivedCreditCard.expirationDate)
+        }
+
+        // Then
+        wait(for: [expectation], timeout: 0.1)
+    }
+
+    func testParseImageWithInvalidLuhnCheck() throws {
+        // Given
+        let expirationDateFormatter = ExpirationDateFormatter()
+        sut = CardImageParser(expirationDateFormatter: expirationDateFormatter)
+
+        let testCreditCard = try XCTUnwrap(testCreditCardInvalidLuhn)
+        let expectation = expectation(description: "Image should be parsed")
+        expectation.isInverted = true
+
+        // When
+        sut.parse(image: testCreditCard.image) { receivedCreditCard in
+            expectation.fulfill()
+
+            let expectedCreditCard = testCreditCard.creditCard
+            XCTAssertEqual(expectedCreditCard.number, receivedCreditCard.number)
+            XCTAssertEqual(expectedCreditCard.expirationDate, receivedCreditCard.expirationDate)
+        }
+
+        // Then
+        wait(for: [expectation], timeout: 0.1)
+    }
+
+    // MARK: - Private
+
+    private struct TestCreditCard {
+        let image: CIImage
+        let creditCard: CreditCard
+    }
+
+    private var testCreditCardHighContrast: TestCreditCard? {
+        let image = UIImage(
+            named: "test-card-number-1",
+            in: Bundle(for: type(of: self)),
+            compatibleWith: nil
+        )
+        guard let cgImage = image?.cgImage else { return nil }
+        let originalImage = CIImage(cgImage: cgImage)
+
+        let creditCard = CreditCard(
+            number: "4111110003920001",
+            expirationDate: dateFrom("03/30")
+        )
+        return TestCreditCard(
+            image: originalImage,
+            creditCard: creditCard
+        )
+    }
+
+    private var testCreditCardLowContrast: TestCreditCard? {
+        let image = UIImage(
+            named: "test-card-number-2",
+            in: Bundle(for: type(of: self)),
+            compatibleWith: nil
+        )
+        guard let cgImage = image?.cgImage else { return nil }
+        let originalImage = CIImage(cgImage: cgImage)
+
+        let creditCard = CreditCard(
+            number: "5413330089099999",
+            expirationDate: dateFrom("02/28")
+        )
+        return TestCreditCard(
+            image: originalImage,
+            creditCard: creditCard
+        )
+    }
+
+    private var testCreditCardInvalidLuhn: TestCreditCard? {
+        let image = UIImage(
+            named: "test-card-number-3",
+            in: Bundle(for: type(of: self)),
+            compatibleWith: nil
+        )
+        guard let cgImage = image?.cgImage else { return nil }
+        let originalImage = CIImage(cgImage: cgImage)
+
+        let creditCard = CreditCard(
+            number: "5412751234123456",
+            expirationDate: dateFrom("12/23")
+        )
+        return TestCreditCard(
+            image: originalImage,
+            creditCard: creditCard
+        )
+    }
+
+    private func dateFrom(_ string: String) -> Date? {
+        return expirationDateFormatter.date(from: string)
+    }
+}
diff --git a/AdyenCardScannerTests/ExpirationDateFormatterTests.swift b/AdyenCardScannerTests/ExpirationDateFormatterTests.swift
new file mode 100644
index 0000000000..6c2899c20b
--- /dev/null
+++ b/AdyenCardScannerTests/ExpirationDateFormatterTests.swift
@@ -0,0 +1,69 @@
+//
+// Copyright (c) 2025 Adyen N.V.
+//
+// This file is open source and available under the MIT license. See the LICENSE file for more info.
+//
+
+import XCTest
+@testable import AdyenCardScanner
+
+final class ExpirationDateFormatterTests: XCTestCase {
+
+    var sut: ExpirationDateFormatter!
+    var dateFormatter = DateFormatter()
+
+    override func setUpWithError() throws {
+        try super.setUpWithError()
+        dateFormatter.dateFormat = "MM/yyyy"
+        dateFormatter.locale = Locale.current
+        dateFormatter.timeZone = TimeZone(secondsFromGMT: 0)
+    }
+
+    override func tearDownWithError() throws {
+        sut = nil
+        try super.tearDownWithError()
+    }
+
+    func testDateFromStringWithShortFormatDate() throws {
+        // Given
+        let stringDate = "03/2028"
+        let expectedDate = dateFormatter.date(from: stringDate)
+
+        let shortFormatDate = "03/28"
+        sut = ExpirationDateFormatter()
+
+        // When
+        let receivedDate = try XCTUnwrap(sut.date(from: shortFormatDate))
+
+        // Then
+        XCTAssertEqual(receivedDate, expectedDate)
+    }
+
+    func testDateFromStringWithLongFormatDate() throws {
+        // Given
+        let stringDate = "03/2028"
+        let expectedDate = dateFormatter.date(from: stringDate)
+
+        let longFormatDate = "03/2028"
+        sut = ExpirationDateFormatter()
+
+        // When
+        let receivedDate = try XCTUnwrap(sut.date(from: longFormatDate))
+
+        // Then
+        XCTAssertEqual(receivedDate, expectedDate)
+    }
+
+    func testDateFromStringWithInvalidFormatDateShouldReturnNil() throws {
+        // Given
+        let longFormatDate = "2028/03"
+        sut = ExpirationDateFormatter()
+
+        // When
+        let receivedDate = sut.date(from: longFormatDate)
+
+        // Then
+        XCTAssertNil(receivedDate)
+    }
+
+}
diff --git a/AdyenCardScannerTests/Mocks/ExpirationDateFormattingMock.swift b/AdyenCardScannerTests/Mocks/ExpirationDateFormattingMock.swift
new file mode 100644
index 0000000000..a66c283bd7
--- /dev/null
+++ b/AdyenCardScannerTests/Mocks/ExpirationDateFormattingMock.swift
@@ -0,0 +1,29 @@
+//
+// Copyright (c) 2025 Adyen N.V.
+//
+// This file is open source and available under the MIT license. See the LICENSE file for more info.
+//
+
+@testable import AdyenCardScanner
+import Foundation
+
+class ExpirationDateFormattingMock: ExpirationDateFormatting {
+    // MARK: - date(from:)
+
+    var dateCallsCount = 0
+    var dateCalled: Bool {
+        dateCallsCount > 0
+    }
+
+    var dateReceivedString: String?
+    var dateReceivedInvocations: [String] = []
+    var dateReturnValue: Date?
+    var dateClosure: ((String) -> Date?)?
+
+    func date(from string: String) -> Date? {
+        dateCallsCount += 1
+        dateReceivedString = string
+        dateReceivedInvocations.append(string)
+        return dateClosure?(string) ?? dateReturnValue
+    }
+}


Reviews:
user: login: github-actions[bot]
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-03-24T10:31:51Z
user: login: neelSharma12
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-25T08:45:55Z
user: login: neelSharma12
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-03-25T09:46:07Z
user: login: erenbesel
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-03-25T10:26:15Z
user: login: atmamont
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-25T11:08:03Z
user: login: nauaros
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-25T12:28:01Z
user: login: nauaros
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-25T12:28:10Z
user: login: nauaros
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-25T12:28:18Z
user: login: nauaros
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-25T12:29:07Z
user: login: atmamont
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-03-25T13:08:10Z

Review comments:
diff_hunk: @@ -0,0 +1,153 @@
+//
+// Copyright (c) 2025 Adyen N.V.
+//
+// This file is open source and available under the MIT license. See the LICENSE file for more info.
+//
+
+@testable import AdyenCardScanner
+import XCTest
+
+
+final class CardImageParserTests: XCTestCase {
+
+    var sut: CardImageParser!
+
+    override func tearDownWithError() throws {
+        sut = nil
+        try super.tearDownWithError()
+    }
+
+    func testParseImageWithHighContrastCardImage() throws {
+        // Given
+        let expirationDateFormatter = ExpirationDateFormatter()
+        sut = CardImageParser(expirationDateFormatter: expirationDateFormatter)
+
+        let testCreditCard = try XCTUnwrap(testCreditCardHighContrast)
+        let expectation = expectation(description: "Image should be parsed")
+
+        // When
+        sut.parse(image: testCreditCard.image) { receivedCreditCard in
+            expectation.fulfill()
+
+            let expectedCreditCard = testCreditCard.creditCard
+            XCTAssertEqual(expectedCreditCard.number, receivedCreditCard.number)
+            XCTAssertEqual(expectedCreditCard.expirationDate, receivedCreditCard.expirationDate)
+        }
+
+        // Then
+        waitForExpectations(timeout: 10.0)
path: AdyenCardScannerTests/CardImageParserTests.swift
user: login: neelSharma12
body: We have added `wait` api in  `XCTestCase extension`. Should we use that one instead of using `waitForExpectations` ?
created_at: 2025-03-25T08:45:55Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,153 @@
+//
+// Copyright (c) 2025 Adyen N.V.
+//
+// This file is open source and available under the MIT license. See the LICENSE file for more info.
+//
+
+@testable import AdyenCardScanner
+import XCTest
+
+
+final class CardImageParserTests: XCTestCase {
+
+    var sut: CardImageParser!
+
+    override func tearDownWithError() throws {
+        sut = nil
+        try super.tearDownWithError()
+    }
+
+    func testParseImageWithHighContrastCardImage() throws {
+        // Given
+        let expirationDateFormatter = ExpirationDateFormatter()
+        sut = CardImageParser(expirationDateFormatter: expirationDateFormatter)
+
+        let testCreditCard = try XCTUnwrap(testCreditCardHighContrast)
+        let expectation = expectation(description: "Image should be parsed")
+
+        // When
+        sut.parse(image: testCreditCard.image) { receivedCreditCard in
+            expectation.fulfill()
+
+            let expectedCreditCard = testCreditCard.creditCard
+            XCTAssertEqual(expectedCreditCard.number, receivedCreditCard.number)
+            XCTAssertEqual(expectedCreditCard.expirationDate, receivedCreditCard.expirationDate)
+        }
+
+        // Then
+        waitForExpectations(timeout: 10.0)
path: AdyenCardScannerTests/CardImageParserTests.swift
user: login: atmamont
body: We do not expect 10 seconds delay in real scenario, let's reduce it to something close to reality with a safe buffer for CI variability? Something Like 0.1-0.5.
Personally I would also never use generic "wait for all expectations" as it reduces readability, it is safer for the future to use a specific expectations array we want to wait for.
Example:
`wait(for: [expectation], timeout: 0.1)`
Makes a difference when you wait for multiple expectations and you can tell all of them from this one line.
created_at: 2025-03-25T09:56:10Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,153 @@
+//
+// Copyright (c) 2025 Adyen N.V.
+//
+// This file is open source and available under the MIT license. See the LICENSE file for more info.
+//
+
+@testable import AdyenCardScanner
+import XCTest
+
+
+final class CardImageParserTests: XCTestCase {
+
+    var sut: CardImageParser!
+
+    override func tearDownWithError() throws {
+        sut = nil
+        try super.tearDownWithError()
+    }
+
+    func testParseImageWithHighContrastCardImage() throws {
+        // Given
+        let expirationDateFormatter = ExpirationDateFormatter()
+        sut = CardImageParser(expirationDateFormatter: expirationDateFormatter)
+
+        let testCreditCard = try XCTUnwrap(testCreditCardHighContrast)
+        let expectation = expectation(description: "Image should be parsed")
+
+        // When
+        sut.parse(image: testCreditCard.image) { receivedCreditCard in
+            expectation.fulfill()
+
+            let expectedCreditCard = testCreditCard.creditCard
+            XCTAssertEqual(expectedCreditCard.number, receivedCreditCard.number)
+            XCTAssertEqual(expectedCreditCard.expirationDate, receivedCreditCard.expirationDate)
+        }
+
+        // Then
+        waitForExpectations(timeout: 10.0)
+    }
+
+    func testParseImageWithLowContrastCardImage() throws {
+        // Given
+        let expirationDateFormatter = ExpirationDateFormatter()
+        sut = CardImageParser(expirationDateFormatter: expirationDateFormatter)
+
+        let testCreditCard = try XCTUnwrap(testCreditCardLowContrast)
+        let expectation = expectation(description: "Image should be parsed")
+
+        // When
+        sut.parse(image: testCreditCard.image) { receivedCreditCard in
+            expectation.fulfill()
+
+            let expectedCreditCard = testCreditCard.creditCard
+            XCTAssertEqual(expectedCreditCard.number, receivedCreditCard.number)
+            XCTAssertEqual(expectedCreditCard.expirationDate, receivedCreditCard.expirationDate)
+        }
+
+        // Then
+        waitForExpectations(timeout: 10.0)
+    }
+
+    func testParseImageWithInvalidLuhnCheck() throws {
+        // Given
+        let expirationDateFormatter = ExpirationDateFormatter()
+        sut = CardImageParser(expirationDateFormatter: expirationDateFormatter)
+
+        let testCreditCard = try XCTUnwrap(testCreditCardInvalidLuhn)
+        let expectation = expectation(description: "Image should be parsed")
+        expectation.isInverted = true
+
+        // When
+        sut.parse(image: testCreditCard.image) { receivedCreditCard in
+            expectation.fulfill()
+
+            let expectedCreditCard = testCreditCard.creditCard
+            XCTAssertEqual(expectedCreditCard.number, receivedCreditCard.number)
+            XCTAssertEqual(expectedCreditCard.expirationDate, receivedCreditCard.expirationDate)
+        }
+
+        // Then
+        waitForExpectations(timeout: 10.0)
+    }
+
+    // MARK: - Private
+
+    private struct TestCreditCard {
+        let image: CIImage
+        let creditCard: CreditCard
+    }
+
+    private var testCreditCardHighContrast: TestCreditCard? {
+        let image = UIImage(
+            named: "test-card-number-1",
+            in: Bundle(for: type(of: self)),
+            compatibleWith: nil
+        )
+        guard let cgImage = image?.cgImage else { return nil }
+        let originalImage = CIImage(cgImage: cgImage)
+
+        let creditCard = CreditCard(
+            number: "4111110003920001",
+            expirationDate: dateFrom("03/30")
+        )
+        return TestCreditCard(
+            image: originalImage,
+            creditCard: creditCard
+        )
+    }
+
+    private var testCreditCardLowContrast: TestCreditCard? {
+        let image = UIImage(
+            named: "test-card-number-2",
+            in: Bundle(for: type(of: self)),
+            compatibleWith: nil
+        )
+        guard let cgImage = image?.cgImage else { return nil }
+        let originalImage = CIImage(cgImage: cgImage)
+
+        let creditCard = CreditCard(
+            number: "5413330089099999",
+            expirationDate: dateFrom("02/28")
+        )
+        return TestCreditCard(
+            image: originalImage,
+            creditCard: creditCard
+        )
+    }
+
+    private var testCreditCardInvalidLuhn: TestCreditCard? {
+        let image = UIImage(
+            named: "test-card-number-3",
+            in: Bundle(for: type(of: self)),
+            compatibleWith: nil
+        )
+        guard let cgImage = image?.cgImage else { return nil }
+        let originalImage = CIImage(cgImage: cgImage)
+
+        let creditCard = CreditCard(
+            number: "5412751234123456",
+            expirationDate: dateFrom("12/23")
+        )
+        return TestCreditCard(
+            image: originalImage,
+            creditCard: creditCard
+        )
+    }
+
+
+    private func dateFrom(_ string: String) -> Date? {
+        let dateFormatter = ExpirationDateFormatter()
path: AdyenCardScannerTests/CardImageParserTests.swift
user: login: atmamont
body: Could be a part of test class along with `sut` so it is not instantiated every time (formatters are expensive).
created_at: 2025-03-25T09:56:55Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,71 @@
+//
+// Copyright (c) 2025 Adyen N.V.
+//
+// This file is open source and available under the MIT license. See the LICENSE file for more info.
+//
+
+import XCTest
+@testable import AdyenCardScanner
+
+final class ExpirationDateFormatterTests: XCTestCase {
+
+    var sut: ExpirationDateFormatter!
+    var dateFormatter: DateFormatter!
+
+    override func setUpWithError() throws {
+        try super.setUpWithError()
+        dateFormatter = DateFormatter()
path: AdyenCardScannerTests/ExpirationDateFormatterTests.swift
user: login: atmamont
body: Could be instantiated once per test suite.
created_at: 2025-03-25T09:59:03Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,29 @@
+//
+// Copyright (c) 2025 Adyen N.V.
+//
+// This file is open source and available under the MIT license. See the LICENSE file for more info.
+//
+
+@testable import AdyenCardScanner
+import Foundation
+
+class ExpirationDateFormattingMock: ExpirationDateFormatting {
path: AdyenCardScannerTests/Mocks/ExpirationDateFormattingMock.swift
user: login: atmamont
body: Is it a good time to think of adding sourcery for mock generation?
created_at: 2025-03-25T11:07:58Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,153 @@
+//
+// Copyright (c) 2025 Adyen N.V.
+//
+// This file is open source and available under the MIT license. See the LICENSE file for more info.
+//
+
+@testable import AdyenCardScanner
+import XCTest
+
+
+final class CardImageParserTests: XCTestCase {
+
+    var sut: CardImageParser!
+
+    override func tearDownWithError() throws {
+        sut = nil
+        try super.tearDownWithError()
+    }
+
+    func testParseImageWithHighContrastCardImage() throws {
+        // Given
+        let expirationDateFormatter = ExpirationDateFormatter()
+        sut = CardImageParser(expirationDateFormatter: expirationDateFormatter)
+
+        let testCreditCard = try XCTUnwrap(testCreditCardHighContrast)
+        let expectation = expectation(description: "Image should be parsed")
+
+        // When
+        sut.parse(image: testCreditCard.image) { receivedCreditCard in
+            expectation.fulfill()
+
+            let expectedCreditCard = testCreditCard.creditCard
+            XCTAssertEqual(expectedCreditCard.number, receivedCreditCard.number)
+            XCTAssertEqual(expectedCreditCard.expirationDate, receivedCreditCard.expirationDate)
+        }
+
+        // Then
+        waitForExpectations(timeout: 10.0)
path: AdyenCardScannerTests/CardImageParserTests.swift
user: login: nauaros
body: Good point, I change to the suggested example. We keep it more realistic this way.
created_at: 2025-03-25T12:28:01Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,153 @@
+//
+// Copyright (c) 2025 Adyen N.V.
+//
+// This file is open source and available under the MIT license. See the LICENSE file for more info.
+//
+
+@testable import AdyenCardScanner
+import XCTest
+
+
+final class CardImageParserTests: XCTestCase {
+
+    var sut: CardImageParser!
+
+    override func tearDownWithError() throws {
+        sut = nil
+        try super.tearDownWithError()
+    }
+
+    func testParseImageWithHighContrastCardImage() throws {
+        // Given
+        let expirationDateFormatter = ExpirationDateFormatter()
+        sut = CardImageParser(expirationDateFormatter: expirationDateFormatter)
+
+        let testCreditCard = try XCTUnwrap(testCreditCardHighContrast)
+        let expectation = expectation(description: "Image should be parsed")
+
+        // When
+        sut.parse(image: testCreditCard.image) { receivedCreditCard in
+            expectation.fulfill()
+
+            let expectedCreditCard = testCreditCard.creditCard
+            XCTAssertEqual(expectedCreditCard.number, receivedCreditCard.number)
+            XCTAssertEqual(expectedCreditCard.expirationDate, receivedCreditCard.expirationDate)
+        }
+
+        // Then
+        waitForExpectations(timeout: 10.0)
+    }
+
+    func testParseImageWithLowContrastCardImage() throws {
+        // Given
+        let expirationDateFormatter = ExpirationDateFormatter()
+        sut = CardImageParser(expirationDateFormatter: expirationDateFormatter)
+
+        let testCreditCard = try XCTUnwrap(testCreditCardLowContrast)
+        let expectation = expectation(description: "Image should be parsed")
+
+        // When
+        sut.parse(image: testCreditCard.image) { receivedCreditCard in
+            expectation.fulfill()
+
+            let expectedCreditCard = testCreditCard.creditCard
+            XCTAssertEqual(expectedCreditCard.number, receivedCreditCard.number)
+            XCTAssertEqual(expectedCreditCard.expirationDate, receivedCreditCard.expirationDate)
+        }
+
+        // Then
+        waitForExpectations(timeout: 10.0)
+    }
+
+    func testParseImageWithInvalidLuhnCheck() throws {
+        // Given
+        let expirationDateFormatter = ExpirationDateFormatter()
+        sut = CardImageParser(expirationDateFormatter: expirationDateFormatter)
+
+        let testCreditCard = try XCTUnwrap(testCreditCardInvalidLuhn)
+        let expectation = expectation(description: "Image should be parsed")
+        expectation.isInverted = true
+
+        // When
+        sut.parse(image: testCreditCard.image) { receivedCreditCard in
+            expectation.fulfill()
+
+            let expectedCreditCard = testCreditCard.creditCard
+            XCTAssertEqual(expectedCreditCard.number, receivedCreditCard.number)
+            XCTAssertEqual(expectedCreditCard.expirationDate, receivedCreditCard.expirationDate)
+        }
+
+        // Then
+        waitForExpectations(timeout: 10.0)
+    }
+
+    // MARK: - Private
+
+    private struct TestCreditCard {
+        let image: CIImage
+        let creditCard: CreditCard
+    }
+
+    private var testCreditCardHighContrast: TestCreditCard? {
+        let image = UIImage(
+            named: "test-card-number-1",
+            in: Bundle(for: type(of: self)),
+            compatibleWith: nil
+        )
+        guard let cgImage = image?.cgImage else { return nil }
+        let originalImage = CIImage(cgImage: cgImage)
+
+        let creditCard = CreditCard(
+            number: "4111110003920001",
+            expirationDate: dateFrom("03/30")
+        )
+        return TestCreditCard(
+            image: originalImage,
+            creditCard: creditCard
+        )
+    }
+
+    private var testCreditCardLowContrast: TestCreditCard? {
+        let image = UIImage(
+            named: "test-card-number-2",
+            in: Bundle(for: type(of: self)),
+            compatibleWith: nil
+        )
+        guard let cgImage = image?.cgImage else { return nil }
+        let originalImage = CIImage(cgImage: cgImage)
+
+        let creditCard = CreditCard(
+            number: "5413330089099999",
+            expirationDate: dateFrom("02/28")
+        )
+        return TestCreditCard(
+            image: originalImage,
+            creditCard: creditCard
+        )
+    }
+
+    private var testCreditCardInvalidLuhn: TestCreditCard? {
+        let image = UIImage(
+            named: "test-card-number-3",
+            in: Bundle(for: type(of: self)),
+            compatibleWith: nil
+        )
+        guard let cgImage = image?.cgImage else { return nil }
+        let originalImage = CIImage(cgImage: cgImage)
+
+        let creditCard = CreditCard(
+            number: "5412751234123456",
+            expirationDate: dateFrom("12/23")
+        )
+        return TestCreditCard(
+            image: originalImage,
+            creditCard: creditCard
+        )
+    }
+
+
+    private func dateFrom(_ string: String) -> Date? {
+        let dateFormatter = ExpirationDateFormatter()
path: AdyenCardScannerTests/CardImageParserTests.swift
user: login: nauaros
body: Done
created_at: 2025-03-25T12:28:10Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,71 @@
+//
+// Copyright (c) 2025 Adyen N.V.
+//
+// This file is open source and available under the MIT license. See the LICENSE file for more info.
+//
+
+import XCTest
+@testable import AdyenCardScanner
+
+final class ExpirationDateFormatterTests: XCTestCase {
+
+    var sut: ExpirationDateFormatter!
+    var dateFormatter: DateFormatter!
+
+    override func setUpWithError() throws {
+        try super.setUpWithError()
+        dateFormatter = DateFormatter()
path: AdyenCardScannerTests/ExpirationDateFormatterTests.swift
user: login: nauaros
body: Done
created_at: 2025-03-25T12:28:18Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,29 @@
+//
+// Copyright (c) 2025 Adyen N.V.
+//
+// This file is open source and available under the MIT license. See the LICENSE file for more info.
+//
+
+@testable import AdyenCardScanner
+import Foundation
+
+class ExpirationDateFormattingMock: ExpirationDateFormatting {
path: AdyenCardScannerTests/Mocks/ExpirationDateFormattingMock.swift
user: login: nauaros
body: Yeah, I thought of it like we did for MyStore. Since we don't have that many mocks, I decided to skip it for now. But yeah, it can be a good addition.
created_at: 2025-03-25T12:29:07Z
author_association: CONTRIBUTOR

PR comments:
user: login: github-actions[bot]
created_at: 2025-03-25T12:32:16Z
body: # ✅ No changes detected
_Comparing `card-scanner-tests-part-3` to `COIOS-826_OCR_feature`_

---
**Analyzed targets:** Adyen, AdyenActions, AdyenCard, AdyenCashAppPay, AdyenComponents, AdyenDelegatedAuthentication, AdyenDropIn, AdyenEncryption, AdyenSession, AdyenSwiftUI, AdyenTwint, AdyenWeChatPay
<!-- thollander/actions-comment-pull-request "api_changes" -->
author_association: CONTRIBUTOR
user: login: sonarqubecloud[bot]
created_at: 2025-03-25T13:01:12Z
body: ## [![Quality Gate Passed](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/QualityGateBadge/qg-passed-20px.png 'Quality Gate Passed')](https://sonarcloud.io/dashboard?id=Adyen_adyen-ios&pullRequest=2069) **Quality Gate passed**  
Issues  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 New issues](https://sonarcloud.io/project/issues?id=Adyen_adyen-ios&pullRequest=2069&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/accepted-16px.png '') [0 Accepted issues](https://sonarcloud.io/project/issues?id=Adyen_adyen-ios&pullRequest=2069&issueStatuses=ACCEPTED)

Measures  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 Security Hotspots](https://sonarcloud.io/project/security_hotspots?id=Adyen_adyen-ios&pullRequest=2069&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0.0% Coverage on New Code](https://sonarcloud.io/component_measures?id=Adyen_adyen-ios&pullRequest=2069&metric=new_coverage&view=list)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0.0% Duplication on New Code](https://sonarcloud.io/component_measures?id=Adyen_adyen-ios&pullRequest=2069&metric=new_duplicated_lines_density&view=list)  
  
[See analysis details on SonarQube Cloud](https://sonarcloud.io/dashboard?id=Adyen_adyen-ios&pullRequest=2069)


author_association: NONE