From 45a17e951a8e31037611c61935ba856ad442b092 Mon Sep 17 00:00:00 2001 From: Tinashe Makuti Date: Thu, 25 Jun 2026 10:55:57 +0200 Subject: [PATCH] RELEASE-4.0.0 --- .../cardmanagement/logging/LogEvent.kt | 7 + .../cardmanagement/logging/LogEventUtils.kt | 7 +- .../com/checkout/cardmanagement/model/Card.kt | 7 + .../model/CardManagementError.kt | 4 + .../cardmanagement/model/CardScheme.kt | 18 + .../model/CardSecureDataFlow.kt | 61 +++ .../model/CardSecureDataFlowDeprecated.kt | 27 ++ .../model/CardSecureDataResult.kt | 4 + .../checkout/cardmanagement/model/CardType.kt | 18 + .../cardmanagement/model/Extensions.kt | 19 + .../model/ProvisioningConfiguration.kt | 5 + .../utils/CardSecureDataError.kt | 8 + .../cardmanagement/CheckoutCardManagerTest.kt | 199 ++++++++++ .../com/checkout/cardmanagement/Fixtures.kt | 17 + .../CheckoutEventLoggerCoverageTest.kt | 60 +++ .../model/CardSecureDataResultTest.kt | 142 +++++++ .../model/CardStateManagementSuspendTest.kt | 367 ++++++++++++++++++ .../checkout/cardmanagement/model/CardTest.kt | 84 ++++ .../cardmanagement/model/ExtensionsTest.kt | 55 +++ .../model/ProvisioningConfigurationTest.kt | 200 ++++++++++ 20 files changed, 1307 insertions(+), 2 deletions(-) create mode 100644 cardmanagement/src/main/java/com/checkout/cardmanagement/model/CardScheme.kt create mode 100644 cardmanagement/src/main/java/com/checkout/cardmanagement/model/CardType.kt create mode 100644 cardmanagement/src/test/java/com/checkout/cardmanagement/logging/CheckoutEventLoggerCoverageTest.kt create mode 100644 cardmanagement/src/test/java/com/checkout/cardmanagement/model/CardSecureDataResultTest.kt create mode 100644 cardmanagement/src/test/java/com/checkout/cardmanagement/model/CardStateManagementSuspendTest.kt create mode 100644 cardmanagement/src/test/java/com/checkout/cardmanagement/model/ProvisioningConfigurationTest.kt diff --git a/cardmanagement/src/main/java/com/checkout/cardmanagement/logging/LogEvent.kt b/cardmanagement/src/main/java/com/checkout/cardmanagement/logging/LogEvent.kt index c8d19e8..6d0e2c9 100644 --- a/cardmanagement/src/main/java/com/checkout/cardmanagement/logging/LogEvent.kt +++ b/cardmanagement/src/main/java/com/checkout/cardmanagement/logging/LogEvent.kt @@ -47,6 +47,12 @@ internal sealed class LogEvent { val cardState: CardState, ) : LogEvent() + /** Describe a successful call to copy security code to clipboard */ + internal data class CopyCVV( + val cardId: String, + val cardState: CardState, + ) : LogEvent() + /** Describe a successful event where a card state change was completed */ internal data class StateManagement( val cardId: String, @@ -85,6 +91,7 @@ internal object LogEventSource { internal const val GET_CVV = "Get Security Code" internal const val GET_PAN_AND_CVV = "Get Pan and SecurityCode" internal const val COPY_PAN = "Copy Pan" + internal const val COPY_CVV = "Copy Security Code" internal const val CONFIGURE_PUSH_PROVISIONING = "Configure Push Provisioning" internal const val GET_CARD_DIGITIZATION_STATE = "Get Card Digitization State" internal const val PUSH_PROVISIONING = "Push Provisioning" diff --git a/cardmanagement/src/main/java/com/checkout/cardmanagement/logging/LogEventUtils.kt b/cardmanagement/src/main/java/com/checkout/cardmanagement/logging/LogEventUtils.kt index 5d1f7fe..a833ab1 100644 --- a/cardmanagement/src/main/java/com/checkout/cardmanagement/logging/LogEventUtils.kt +++ b/cardmanagement/src/main/java/com/checkout/cardmanagement/logging/LogEventUtils.kt @@ -73,6 +73,7 @@ internal class LogEventUtils { is LogEvent.GetCVV -> "card_cvv" is LogEvent.GetPanCVV -> "card_pan_cvv" is LogEvent.CopyPan -> "card_copy_pan" + is LogEvent.CopyCVV -> "card_copy_cvv" is LogEvent.StateManagement -> "card_state_change" is LogEvent.ConfigurePushProvisioning -> "configure_push_provisioning" is LogEvent.GetCardDigitizationState -> "get_card_digitization_state" @@ -90,6 +91,7 @@ internal class LogEventUtils { is LogEvent.GetCVV, is LogEvent.GetPanCVV, is LogEvent.CopyPan, + is LogEvent.CopyCVV, is LogEvent.StateManagement, is LogEvent.ConfigurePushProvisioning, is LogEvent.GetCardDigitizationState, @@ -139,6 +141,7 @@ internal class LogEventUtils { is LogEvent.GetCVV, is LogEvent.GetPanCVV, is LogEvent.CopyPan, + is LogEvent.CopyCVV, -> { propertyMap[KEY_CARD_ID] = this.readProperty("cardId") propertyMap[KEY_CARD_STATE] = @@ -210,8 +213,8 @@ private fun buildTextStyleMap(textStyle: TextStyle) = putIfNotNullOrNotUnspecified(textStyle, "localeList", null) putIfNotNullOrNotUnspecified(textStyle, "textDecoration", null) putIfNotNullOrNotUnspecified(textStyle, "shadow", null) - putIfNotNullOrNotUnspecified(textStyle, "textAlign", null) - putIfNotNullOrNotUnspecified(textStyle, "textDirection", null) + putIfNotNullOrNotUnspecified(textStyle, "textAlign", TextAlign.Unspecified) + putIfNotNullOrNotUnspecified(textStyle, "textDirection", TextDirection.Unspecified) putIfNotNullOrNotUnspecified(textStyle, "textIndent", null) } diff --git a/cardmanagement/src/main/java/com/checkout/cardmanagement/model/Card.kt b/cardmanagement/src/main/java/com/checkout/cardmanagement/model/Card.kt index 20bfaa2..bd67144 100644 --- a/cardmanagement/src/main/java/com/checkout/cardmanagement/model/Card.kt +++ b/cardmanagement/src/main/java/com/checkout/cardmanagement/model/Card.kt @@ -11,6 +11,8 @@ package com.checkout.cardmanagement.model * * @see com.checkout.cardmanagement.CheckoutCardManager.getCards * @see CardState + * @see CardType + * @see CardScheme * @see CardExpiryDate */ public data class Card( @@ -24,6 +26,9 @@ public data class Card( public val cardholderName: String, /** Unique identifier for this card, used for card operations and tracking (not the card PAN) */ public val id: String, + public val type: CardType = CardType.UNKNOWN, + /** Payment scheme of the card, e.g. Visa or Mastercard */ + public val cardScheme: CardScheme = CardScheme.UNKNOWN, /** A reference to the manager is required to enable sharing of the design system and the card service * Enables object to carry operations that depend on it */ @@ -46,6 +51,8 @@ public data class Card( ), cardholderName = networkCard.displayName ?: "", id = networkCard.id, + type = networkCard.type.toCardType(), + cardScheme = networkCard.scheme.toCardScheme(), manager = manager, ) } diff --git a/cardmanagement/src/main/java/com/checkout/cardmanagement/model/CardManagementError.kt b/cardmanagement/src/main/java/com/checkout/cardmanagement/model/CardManagementError.kt index f1889c6..819bdd5 100644 --- a/cardmanagement/src/main/java/com/checkout/cardmanagement/model/CardManagementError.kt +++ b/cardmanagement/src/main/java/com/checkout/cardmanagement/model/CardManagementError.kt @@ -29,6 +29,9 @@ public sealed class CardManagementError : Exception() { /** Error when a pan is attempted to be copied without being viewed */ public object PanNotViewedFailure : CardManagementError() + /** Error when a security code is attempted to be copied without being viewed */ + public object SecurityCodeNotViewedFailure : CardManagementError() + /** Requested to change card to an unavailable state */ public object InvalidStateRequested : CardManagementError() @@ -98,6 +101,7 @@ internal fun Throwable.toCardManagementError(): CardManagementError = CardNetworkError.Unauthenticated -> CardManagementError.Unauthenticated CardNetworkError.SecureOperationsFailure -> CardManagementError.UnableToPerformSecureOperation CardNetworkError.PanNotViewedFailure -> CardManagementError.PanNotViewedFailure + CardNetworkError.SecurityCodeNotViewedFailure -> CardManagementError.SecurityCodeNotViewedFailure is CardNetworkError.PushProvisioningFailure -> when (this.type) { PushProvisioningFailureType.CANCELLED -> diff --git a/cardmanagement/src/main/java/com/checkout/cardmanagement/model/CardScheme.kt b/cardmanagement/src/main/java/com/checkout/cardmanagement/model/CardScheme.kt new file mode 100644 index 0000000..7d2e54c --- /dev/null +++ b/cardmanagement/src/main/java/com/checkout/cardmanagement/model/CardScheme.kt @@ -0,0 +1,18 @@ +package com.checkout.cardmanagement.model + +/** + * Payment scheme of the [Card]. + * + * This can be used to conditionally enable or disable actions based on the card's scheme, + * such as Visa tokenization flows. + */ +public enum class CardScheme { + /** A Visa card */ + VISA, + + /** A Mastercard card */ + MASTERCARD, + + /** A card scheme not yet supported by this SDK version */ + UNKNOWN, +} diff --git a/cardmanagement/src/main/java/com/checkout/cardmanagement/model/CardSecureDataFlow.kt b/cardmanagement/src/main/java/com/checkout/cardmanagement/model/CardSecureDataFlow.kt index d7ac618..18b10be 100644 --- a/cardmanagement/src/main/java/com/checkout/cardmanagement/model/CardSecureDataFlow.kt +++ b/cardmanagement/src/main/java/com/checkout/cardmanagement/model/CardSecureDataFlow.kt @@ -4,6 +4,7 @@ import android.os.Build import androidx.compose.ui.platform.AbstractComposeView import com.checkout.cardmanagement.logging.CheckoutEventLogger import com.checkout.cardmanagement.logging.LogEvent +import com.checkout.cardmanagement.logging.LogEventSource.COPY_CVV import com.checkout.cardmanagement.logging.LogEventSource.COPY_PAN import com.checkout.cardmanagement.logging.LogEventSource.GET_CVV import com.checkout.cardmanagement.logging.LogEventSource.GET_PAN @@ -308,6 +309,66 @@ internal suspend fun Card.copyPanImpl( ) } +/** + * Copies the security code (CVV/CVC) to the device clipboard with security features. + * + * The security code must be viewed in the current session before copying (call [getSecurityCode] or + * [getPANAndSecurityCode] first). Not supported on Android API 29-32 (Android 10-12L). + * + * Example usage: + * ```kotlin + * when (val result = card.copySecurityCode(singleUseToken)) { + * is CardSecureDataResult.Success -> { + * showToast("Security code copied to clipboard") + * } + * is CardSecureDataResult.Error.SecurityCodeNotViewed -> { + * showError(result.message) + * promptToViewSecurityCodeFirst() + * } + * is CardSecureDataResult.Error.UnsupportedApiVersion -> { + * showError(result.message) + * disableCopyFeature() + * } + * is CardSecureDataResult.Error -> { + * showError(result.message) + * } + * } + * ``` + * + * @param singleUseToken Single-use authentication token for this operation + * @return Success (Unit), or specific error (SecurityCodeNotViewed, UnsupportedApiVersion, etc.) + * @since 3.0.0 + */ +public suspend fun Card.copySecurityCode(singleUseToken: String): CardSecureDataResult = + copySecurityCodeImpl(singleUseToken, isLegacyRequest = false) + +/** + * Internal implementation of [copySecurityCode] that accepts a legacy request flag for analytics. + * + * @param singleUseToken Single-use authentication token for this operation + * @param isLegacyRequest True if called from deprecated callback API, false if from suspend API + * @return Success (Unit), or specific error (SecurityCodeNotViewed, UnsupportedApiVersion, etc.) + */ +internal suspend fun Card.copySecurityCodeImpl( + singleUseToken: String, + isLegacyRequest: Boolean, +): CardSecureDataResult { + validateApiVersionForCopyPan()?.let { return it } + + return getSecureDataSuspend( + logger = manager.logger, + successLogEvent = LogEvent.CopyCVV(cardId = id, state), + logEventSource = COPY_CVV, + isLegacyRequest = isLegacyRequest, + displaySecureData = { + manager.service.copySecurityCode( + cardId = id, + singleUseToken = singleUseToken, + ) + }, + ) +} + internal suspend fun getSecureDataSuspend( logger: CheckoutEventLogger, logEventSource: String, diff --git a/cardmanagement/src/main/java/com/checkout/cardmanagement/model/CardSecureDataFlowDeprecated.kt b/cardmanagement/src/main/java/com/checkout/cardmanagement/model/CardSecureDataFlowDeprecated.kt index 42d3fb4..7e0999e 100644 --- a/cardmanagement/src/main/java/com/checkout/cardmanagement/model/CardSecureDataFlowDeprecated.kt +++ b/cardmanagement/src/main/java/com/checkout/cardmanagement/model/CardSecureDataFlowDeprecated.kt @@ -127,6 +127,33 @@ public fun Card.copyPan( copyPanImpl(singleUseToken, isLegacy) } +/** + * Copies security code (CVV/CVC) to clipboard with security features. The security code will only + * be copyable if it has first been viewed in the current session. + * + * @param singleUseToken Single-use authentication token for this operation + * @param completionHandler Callback that receives Result indicating success or error + * @deprecated This callback-based API will be deprecated in a future version. + * Prefer the coroutine-based suspend copySecurityCode() which returns CardSecureDataResult. + */ +@Deprecated( + message = + "This callback-based API will be deprecated in a future version." + + " Use suspend copySecurityCode() instead", + replaceWith = ReplaceWith("copySecurityCode(singleUseToken)"), + level = DeprecationLevel.WARNING, +) +public fun Card.copySecurityCode( + singleUseToken: String, + completionHandler: (Result) -> Unit, +): Unit = + getSecureDataBridge( + completionHandler = completionHandler, + card = this, + ) { isLegacy -> + copySecurityCodeImpl(singleUseToken, isLegacy) + } + /** * Internal helper function that bridges between the new sealed result pattern and the old callback pattern. * diff --git a/cardmanagement/src/main/java/com/checkout/cardmanagement/model/CardSecureDataResult.kt b/cardmanagement/src/main/java/com/checkout/cardmanagement/model/CardSecureDataResult.kt index 57281a3..7f8bccf 100644 --- a/cardmanagement/src/main/java/com/checkout/cardmanagement/model/CardSecureDataResult.kt +++ b/cardmanagement/src/main/java/com/checkout/cardmanagement/model/CardSecureDataResult.kt @@ -26,6 +26,10 @@ public sealed interface CardSecureDataResult { override val message: String, ) : Error + public data class SecurityCodeNotViewed( + override val message: String, + ) : Error + public data class UnableToPerformOperation( override val message: String, val cause: Throwable? = null, diff --git a/cardmanagement/src/main/java/com/checkout/cardmanagement/model/CardType.kt b/cardmanagement/src/main/java/com/checkout/cardmanagement/model/CardType.kt new file mode 100644 index 0000000..3519a3a --- /dev/null +++ b/cardmanagement/src/main/java/com/checkout/cardmanagement/model/CardType.kt @@ -0,0 +1,18 @@ +package com.checkout.cardmanagement.model + +/** + * Type of the [Card], indicating whether it is a physical or virtual card. + * + * This can be used to conditionally enable or disable actions, such as Reveal PIN, + * based on whether the card is physical or virtual. + */ +public enum class CardType { + /** A physical card that has been manufactured and issued to the cardholder */ + PHYSICAL, + + /** A virtual card that exists only in digital form */ + VIRTUAL, + + /** An unknown card type not yet supported by this SDK version */ + UNKNOWN, +} diff --git a/cardmanagement/src/main/java/com/checkout/cardmanagement/model/Extensions.kt b/cardmanagement/src/main/java/com/checkout/cardmanagement/model/Extensions.kt index 8b0bdee..4ac1eaa 100644 --- a/cardmanagement/src/main/java/com/checkout/cardmanagement/model/Extensions.kt +++ b/cardmanagement/src/main/java/com/checkout/cardmanagement/model/Extensions.kt @@ -1,5 +1,7 @@ package com.checkout.cardmanagement.model +import java.util.Locale + // Parse to Environment in Sian internal fun Environment.parse() = when (this) { @@ -7,6 +9,9 @@ internal fun Environment.parse() = Environment.PRODUCTION -> com.checkout.cardnetwork.common.model.Environment.PRODUCTION } +internal fun String.fromNetworkCardType(): CardType = + if (this.equals("physical", ignoreCase = true)) CardType.PHYSICAL else CardType.VIRTUAL + internal fun com.checkout.cardnetwork.data.dto.CardState.fromNetworkCardState(): CardState = CardState.values().find { state -> state.name == name } ?: CardState.INACTIVE @@ -32,3 +37,17 @@ internal fun CardRevokeReason.toCardNetworkRevokeReason(): com.checkout.cardnetw com.checkout.cardnetwork.data.dto.CardRevokeReason .values() .first { it.name == this.name } + +internal fun String.toCardType(): CardType = + when (this.trim().lowercase(Locale.ROOT)) { + "physical" -> CardType.PHYSICAL + "virtual" -> CardType.VIRTUAL + else -> CardType.UNKNOWN + } + +internal fun String?.toCardScheme(): CardScheme = + when (this?.let { it.trim().lowercase(Locale.ROOT) }) { + "visa" -> CardScheme.VISA + "mastercard" -> CardScheme.MASTERCARD + else -> CardScheme.UNKNOWN + } diff --git a/cardmanagement/src/main/java/com/checkout/cardmanagement/model/ProvisioningConfiguration.kt b/cardmanagement/src/main/java/com/checkout/cardmanagement/model/ProvisioningConfiguration.kt index 94bcb1d..3f2f166 100644 --- a/cardmanagement/src/main/java/com/checkout/cardmanagement/model/ProvisioningConfiguration.kt +++ b/cardmanagement/src/main/java/com/checkout/cardmanagement/model/ProvisioningConfiguration.kt @@ -8,6 +8,7 @@ package com.checkout.cardmanagement.model * @param serviceRSAModulus RSA Modulus in [ByteArray], from the key exchanged during onboarding. * @param serviceURL URL String for the Service endpoint * @param digitalCardURL URL String for the Digital Service endpoint + * @param visaClientAppId A nullable string identifier that can be obtained from your Visa configuration */ public data class ProvisioningConfiguration( internal val issuerID: String, @@ -15,6 +16,7 @@ public data class ProvisioningConfiguration( internal val serviceRSAModulus: ByteArray, internal val serviceURL: String, internal val digitalCardURL: String, + internal val visaClientAppId: String? = null, ) { override fun equals(other: Any?): Boolean { if (this === other) return true @@ -27,6 +29,7 @@ public data class ProvisioningConfiguration( if (!serviceRSAModulus.contentEquals(other.serviceRSAModulus)) return false if (serviceURL != other.serviceURL) return false if (digitalCardURL != other.digitalCardURL) return false + if (visaClientAppId != other.visaClientAppId) return false return true } @@ -37,6 +40,7 @@ public data class ProvisioningConfiguration( result = 31 * result + serviceRSAModulus.contentHashCode() result = 31 * result + serviceURL.hashCode() result = 31 * result + digitalCardURL.hashCode() + result = 31 * result + visaClientAppId.hashCode() return result } @@ -47,5 +51,6 @@ public data class ProvisioningConfiguration( serviceRSAModulus = this.serviceRSAModulus, serviceURL = this.serviceURL, digitalCardURL = this.digitalCardURL, + visaClientAppId = this.visaClientAppId, ) } diff --git a/cardmanagement/src/main/java/com/checkout/cardmanagement/utils/CardSecureDataError.kt b/cardmanagement/src/main/java/com/checkout/cardmanagement/utils/CardSecureDataError.kt index cd6f38e..88cc3bb 100644 --- a/cardmanagement/src/main/java/com/checkout/cardmanagement/utils/CardSecureDataError.kt +++ b/cardmanagement/src/main/java/com/checkout/cardmanagement/utils/CardSecureDataError.kt @@ -28,6 +28,11 @@ internal fun Throwable.toCardSecureDataError(): CardSecureDataResult.Error = message = error.message ?: "PAN not viewed", ) + CardManagementError.SecurityCodeNotViewedFailure -> + CardSecureDataResult.Error.SecurityCodeNotViewed( + message = error.message ?: "Security code not viewed", + ) + CardManagementError.UnableToPerformSecureOperation -> CardSecureDataResult.Error.UnableToPerformOperation( message = this.message ?: error.message ?: "Secure operation failed", @@ -68,6 +73,9 @@ internal fun CardSecureDataResult.getOrThrow(): T = is CardSecureDataResult.Error.PanNotViewed -> throw CardManagementError.PanNotViewedFailure + is CardSecureDataResult.Error.SecurityCodeNotViewed -> + throw CardManagementError.SecurityCodeNotViewedFailure + is CardSecureDataResult.Error.UnableToPerformOperation -> throw CardManagementError.UnableToPerformSecureOperation diff --git a/cardmanagement/src/test/java/com/checkout/cardmanagement/CheckoutCardManagerTest.kt b/cardmanagement/src/test/java/com/checkout/cardmanagement/CheckoutCardManagerTest.kt index b5809a4..7e22e62 100644 --- a/cardmanagement/src/test/java/com/checkout/cardmanagement/CheckoutCardManagerTest.kt +++ b/cardmanagement/src/test/java/com/checkout/cardmanagement/CheckoutCardManagerTest.kt @@ -23,6 +23,7 @@ import com.checkout.cardmanagement.model.CardSecureDataResult import com.checkout.cardmanagement.model.Environment.SANDBOX import com.checkout.cardmanagement.model.ProvisioningConfiguration import com.checkout.cardmanagement.model.copyPan +import com.checkout.cardmanagement.model.copySecurityCode import com.checkout.cardmanagement.model.getPANAndSecurityCode import com.checkout.cardmanagement.model.getPan import com.checkout.cardmanagement.model.getPin @@ -933,6 +934,121 @@ internal class CheckoutCardManagerTest { } } + @Test + fun `suspend getCards should log a CardList event with the returned card ids and requested statuses`() = + runBlocking { + manager.logInSession(VALID_TOKEN) + + manager.getCards() + + val eventCaptor = argumentCaptor() + verify(logger, atLeastOnce()).log(eventCaptor.capture(), any(Calendar::class.java), eq(emptyMap())) + val cardListEvent = eventCaptor.allValues.firstOrNull { it is LogEvent.CardList } as? LogEvent.CardList + assertTrue(cardListEvent != null) + assertEquals( + NETWORK_CARD_LIST.cards.map { it.id }, + cardListEvent!!.cardIds, + ) + assertTrue(cardListEvent.requestedStatuses.isEmpty()) + } + + @Test + fun `suspend getCards should log a Failure event when the service returns an error`() = + runBlocking { + `when`(cardService.getCards(eq(VALID_TOKEN), eq(emptySet()))).thenReturn( + Result.failure(CardNetworkError.ServerIssue), + ) + manager.logInSession(VALID_TOKEN) + + try { + manager.getCards() + } catch (_: CardManagementError) { + // expected — verified separately + } + + assertFailureLogEvent( + expectedSource = GET_CARDS, + expectedError = CardNetworkError.ServerIssue, + ) + } + + @Test + fun `suspend getCard by id should throw Unauthenticated when session token is null`() = + runBlocking { + try { + manager.getCard(CARD_ID) + throw AssertionError("Expected CardManagementError.Unauthenticated to be thrown") + } catch (e: CardManagementError) { + assertEquals(CardManagementError.Unauthenticated, e) + } + } + + @Test + fun `suspend getCard by id should return the card when service succeeds`() = + runBlocking { + `when`(cardService.getCard(CARD_ID, VALID_TOKEN)) + .thenReturn(Result.success(NETWORK_CARD)) + manager.logInSession(VALID_TOKEN) + + val card = manager.getCard(CARD_ID) + + assertEquals(NETWORK_CARD.id, card.id) + assertEquals(NETWORK_CARD.panLast4Digits, card.panLast4Digits) + assertEquals(NETWORK_CARD.state.name, card.state.name) + } + + @Test + fun `suspend getCard by id should log a CardList event with the single returned id on success`() = + runBlocking { + `when`(cardService.getCard(CARD_ID, VALID_TOKEN)) + .thenReturn(Result.success(NETWORK_CARD)) + manager.logInSession(VALID_TOKEN) + + manager.getCard(CARD_ID) + + val eventCaptor = argumentCaptor() + verify(logger, atLeastOnce()).log(eventCaptor.capture(), any(Calendar::class.java), eq(emptyMap())) + val cardListEvent = eventCaptor.allValues.firstOrNull { it is LogEvent.CardList } as? LogEvent.CardList + assertTrue(cardListEvent != null) + assertEquals(listOf(NETWORK_CARD.id), cardListEvent!!.cardIds) + assertTrue(cardListEvent.requestedStatuses.isEmpty()) + } + + @Test + fun `suspend getCard by id should throw CardManagementError when service returns NotFound`() = + runBlocking { + `when`(cardService.getCard(CARD_ID, VALID_TOKEN)) + .thenReturn(Result.failure(CardNetworkError.NotFound)) + manager.logInSession(VALID_TOKEN) + + try { + manager.getCard(CARD_ID) + throw AssertionError("Expected CardManagementError to be thrown") + } catch (e: CardManagementError) { + assertEquals(CardManagementError.NotFound, e) + } + } + + @Test + fun `suspend getCard by id should throw mapped error and log Failure on network failure`() = + runBlocking { + `when`(cardService.getCard(CARD_ID, VALID_TOKEN)) + .thenReturn(Result.failure(CardNetworkError.ServerIssue)) + manager.logInSession(VALID_TOKEN) + + try { + manager.getCard(CARD_ID) + throw AssertionError("Expected CardManagementError to be thrown") + } catch (e: CardManagementError) { + assertEquals(CardManagementError.ConnectionIssue, e) + } + + assertFailureLogEvent( + expectedSource = GET_CARDS, + expectedError = CardNetworkError.ServerIssue, + ) + } + @Test fun `suspend getPin should return Success with AbstractComposeView on success`() = runBlocking { @@ -1110,6 +1226,88 @@ internal class CheckoutCardManagerTest { assertTrue(result is CardSecureDataResult.Success) } + @Test + fun `copySecurityCode should call completionHandler with a success Result if no error is caught`() { + `when`( + cardService.copySecurityCode( + card.id, + SINGLE_USE_TOKEN, + ), + ).thenReturn(flowOf(Result.success(Unit))) + + card.copySecurityCode(SINGLE_USE_TOKEN) { result -> + assertTrue(result.isSuccess) + } + } + + @Test + fun `copySecurityCode should call completionHandler with a failure Result if a failure result is returned`() { + val testError = CardNetworkError.SecurityCodeNotViewedFailure + `when`( + cardService.copySecurityCode( + card.id, + SINGLE_USE_TOKEN, + ), + ).thenReturn(flow { emit(Result.failure(testError)) }) + + card.copySecurityCode(SINGLE_USE_TOKEN) { result -> + assertTrue(result.isFailure) + assertEquals(testError.toCardManagementError(), result.exceptionOrNull()) + } + } + + @Test + fun `copySecurityCode should log the CopyCVV event if the request is successful`() { + `when`( + cardService.copySecurityCode( + card.id, + SINGLE_USE_TOKEN, + ), + ).thenReturn(flowOf(Result.success(Unit))) + + card.copySecurityCode(SINGLE_USE_TOKEN) { _ -> + val eventCaptor = argumentCaptor() + verify(logger).log(eventCaptor.capture(), any(Calendar::class.java), eq(emptyMap())) + assertTrue(eventCaptor.firstValue is LogEvent.CopyCVV) + assertEquals( + card.id, + (eventCaptor.firstValue as LogEvent.CopyCVV).cardId, + ) + assertEquals( + card.state, + (eventCaptor.firstValue as LogEvent.CopyCVV).cardState, + ) + } + } + + @Test + fun `suspend copySecurityCode should return Success on success`() = + runBlocking { + `when`( + cardService.copySecurityCode( + card.id, + SINGLE_USE_TOKEN, + ), + ).thenReturn(flowOf(Result.success(Unit))) + + val result = card.copySecurityCode(SINGLE_USE_TOKEN) + assertTrue(result is CardSecureDataResult.Success) + } + + @Test + fun `suspend copySecurityCode should return SecurityCodeNotViewed error on not viewed failure`() = + runBlocking { + `when`( + cardService.copySecurityCode( + card.id, + SINGLE_USE_TOKEN, + ), + ).thenReturn(flow { emit(Result.failure(CardNetworkError.SecurityCodeNotViewedFailure)) }) + + val result = card.copySecurityCode(SINGLE_USE_TOKEN) + assertTrue(result is CardSecureDataResult.Error.SecurityCodeNotViewed) + } + @Test fun `suspend getPin should return Unauthenticated error on unauthenticated failure`() = runTest { @@ -1295,6 +1493,7 @@ internal class CheckoutCardManagerTest { serviceRSAModulus = byteArrayOf(), serviceURL = "SERVICE_URL", digitalCardURL = "DIGITAL_CARD_URL", + visaClientAppId = null, ) private fun getAllCardManageErrors(): List { diff --git a/cardmanagement/src/test/java/com/checkout/cardmanagement/Fixtures.kt b/cardmanagement/src/test/java/com/checkout/cardmanagement/Fixtures.kt index d69e5a9..8c304ed 100644 --- a/cardmanagement/src/test/java/com/checkout/cardmanagement/Fixtures.kt +++ b/cardmanagement/src/test/java/com/checkout/cardmanagement/Fixtures.kt @@ -22,6 +22,23 @@ internal object Fixtures { state = CardState.ACTIVE, type = "virtual", isSingleUse = false, + scheme = "visa", + ) + internal val NETWORK_CARD_PHYSICAL: NetworkCard = + NetworkCard( + cardholderId = "crh_shw5giae4mjufep6jdrdfvz5vu", + createdDate = "2023-02-01T16:39:13.76Z", + displayName = "CARD_HOLDER_NAME", + expiryMonth = "2", + expiryYear = "2025", + id = "crd_b4p45eus5hfvwq8eh4xxvi38xz", + panLast4Digits = "1234", + lastModifiedDate = "2023-02-01T16:39:13.76Z", + reference = null, + state = CardState.INACTIVE, + type = "physical", + isSingleUse = false, + scheme = "mastercard", ) internal val NETWORK_CARD_LIST = CardList( diff --git a/cardmanagement/src/test/java/com/checkout/cardmanagement/logging/CheckoutEventLoggerCoverageTest.kt b/cardmanagement/src/test/java/com/checkout/cardmanagement/logging/CheckoutEventLoggerCoverageTest.kt new file mode 100644 index 0000000..926ae0a --- /dev/null +++ b/cardmanagement/src/test/java/com/checkout/cardmanagement/logging/CheckoutEventLoggerCoverageTest.kt @@ -0,0 +1,60 @@ +package com.checkout.cardmanagement.logging + +import com.checkout.eventlogger.domain.model.Event +import org.junit.Assert.assertNotNull +import org.junit.Before +import org.junit.Test +import org.mockito.Mockito.mock +import org.mockito.Mockito.verify +import org.mockito.kotlin.eq +import org.mockito.kotlin.whenever +import com.checkout.eventlogger.CheckoutEventLogger as EventLogger + +internal class CheckoutEventLoggerCoverageTest { + private val logEventUtils: LogEventUtils = mock() + private val eventLogger: EventLogger = mock() + private val event: Event = mock() + private lateinit var logger: CheckoutEventLogger + + @Before + fun setup() { + logger = CheckoutEventLogger(logEventUtils, eventLogger) + } + + @Test + fun `sessionID should be a non null UUID-shaped string`() { + val sessionId = logger.sessionID + + assertNotNull(sessionId) + // UUID format: 8-4-4-4-12 = 36 chars + assertNotNull(java.util.UUID.fromString(sessionId)) + } + + @Test + fun `sessionID should be stable across reads`() { + val first = logger.sessionID + val second = logger.sessionID + + assert(first == second) + } + + @Test + fun `log with additionalInfo should pass map into buildEvent and log to inner logger`() { + val additionalInfo = mapOf("k1" to "v1", "k2" to "v2") + val logEvent = LogEvent.CardList(listOf("c1"), emptySet()) + whenever(logEventUtils.buildEvent(eq(logEvent), eq(null), eq(additionalInfo))) + .thenReturn(event) + + logger.log(event = logEvent, startedAt = null, additionalInfo = additionalInfo) + + verify(eventLogger).logEvent(event) + } + + @Test + fun `default constructor should produce non null logger`() { + val direct = CheckoutEventLogger() + + assertNotNull(direct) + assertNotNull(direct.sessionID) + } +} diff --git a/cardmanagement/src/test/java/com/checkout/cardmanagement/model/CardSecureDataResultTest.kt b/cardmanagement/src/test/java/com/checkout/cardmanagement/model/CardSecureDataResultTest.kt new file mode 100644 index 0000000..5c2476b --- /dev/null +++ b/cardmanagement/src/test/java/com/checkout/cardmanagement/model/CardSecureDataResultTest.kt @@ -0,0 +1,142 @@ +package com.checkout.cardmanagement.model + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +internal class CardSecureDataResultTest { + @Test + fun `Success should expose data`() { + val data = "secret" + + val success = CardSecureDataResult.Success(data) + + assertEquals(data, success.data) + assertTrue(success is CardSecureDataResult) + } + + @Test + fun `Success equals should rely on data`() { + val first = CardSecureDataResult.Success("payload") + val second = CardSecureDataResult.Success("payload") + val different = CardSecureDataResult.Success("other") + + assertEquals(first, second) + assertEquals(first.hashCode(), second.hashCode()) + assertNotEquals(first, different) + } + + @Test + fun `Success copy should change data while remaining Success`() { + val original = CardSecureDataResult.Success("payload") + + val copy = original.copy(data = "new") + + assertEquals("new", copy.data) + } + + @Test + fun `AuthenticationFailure should expose message and tokenType`() { + val error = + CardSecureDataResult.Error.AuthenticationFailure( + message = MESSAGE, + tokenType = "session", + ) + + assertEquals(MESSAGE, error.message) + assertEquals("session", error.tokenType) + assertTrue(error is CardSecureDataResult.Error) + } + + @Test + fun `AuthenticationFailure copy should preserve other fields`() { + val error = + CardSecureDataResult.Error + .AuthenticationFailure(MESSAGE, "session") + .copy(message = "new") + + assertEquals("new", error.message) + assertEquals("session", error.tokenType) + } + + @Test + fun `Unauthenticated should expose message`() { + val error = CardSecureDataResult.Error.Unauthenticated(MESSAGE) + + assertEquals(MESSAGE, error.message) + assertTrue(error is CardSecureDataResult.Error) + } + + @Test + fun `ConnectionIssue should expose message and cause`() { + val cause = RuntimeException("boom") + val error = + CardSecureDataResult.Error.ConnectionIssue( + message = MESSAGE, + cause = cause, + ) + + assertEquals(MESSAGE, error.message) + assertEquals(cause, error.cause) + } + + @Test + fun `ConnectionIssue should accept null cause`() { + val error = CardSecureDataResult.Error.ConnectionIssue(MESSAGE, null) + + assertNull(error.cause) + } + + @Test + fun `PanNotViewed should expose message`() { + val error = CardSecureDataResult.Error.PanNotViewed(MESSAGE) + + assertEquals(MESSAGE, error.message) + assertTrue(error is CardSecureDataResult.Error) + } + + @Test + fun `UnableToPerformOperation should expose message and default null cause`() { + val error = CardSecureDataResult.Error.UnableToPerformOperation(MESSAGE) + + assertEquals(MESSAGE, error.message) + assertNull(error.cause) + } + + @Test + fun `UnableToPerformOperation should expose provided cause`() { + val cause = IllegalStateException() + val error = CardSecureDataResult.Error.UnableToPerformOperation(MESSAGE, cause) + + assertEquals(cause, error.cause) + } + + @Test + fun `UnsupportedApiVersion should expose version and message`() { + val error = + CardSecureDataResult.Error.UnsupportedApiVersion( + version = 21, + message = MESSAGE, + ) + + assertEquals(21, error.version) + assertEquals(MESSAGE, error.message) + } + + @Test + fun `Error data classes should support equality by content`() { + val error1 = CardSecureDataResult.Error.Unauthenticated(MESSAGE) + val error2 = CardSecureDataResult.Error.Unauthenticated(MESSAGE) + val different = CardSecureDataResult.Error.Unauthenticated("other") + + assertEquals(error1, error2) + assertEquals(error1.hashCode(), error2.hashCode()) + assertNotEquals(error1, different) + } + + private companion object { + private const val MESSAGE = "MESSAGE" + } +} diff --git a/cardmanagement/src/test/java/com/checkout/cardmanagement/model/CardStateManagementSuspendTest.kt b/cardmanagement/src/test/java/com/checkout/cardmanagement/model/CardStateManagementSuspendTest.kt new file mode 100644 index 0000000..c882649 --- /dev/null +++ b/cardmanagement/src/test/java/com/checkout/cardmanagement/model/CardStateManagementSuspendTest.kt @@ -0,0 +1,367 @@ +package com.checkout.cardmanagement.model + +import com.checkout.cardmanagement.logging.CheckoutEventLogger +import com.checkout.cardmanagement.logging.LogEvent +import com.checkout.cardmanagement.logging.LogEventSource.ACTIVATE_CARD +import com.checkout.cardmanagement.logging.LogEventSource.REVOKE_CARD +import com.checkout.cardmanagement.logging.LogEventSource.SUSPEND_CARD +import com.checkout.cardmanagement.model.CardRevokeReason.STOLEN +import com.checkout.cardmanagement.model.CardState.ACTIVE +import com.checkout.cardmanagement.model.CardState.INACTIVE +import com.checkout.cardmanagement.model.CardState.REVOKED +import com.checkout.cardmanagement.model.CardState.SUSPENDED +import com.checkout.cardmanagement.model.CardSuspendReason.LOST +import com.checkout.cardmanagement.utils.CoroutineScopeOwner +import com.checkout.cardnetwork.CardService +import com.checkout.cardnetwork.common.model.CardNetworkError +import com.checkout.cardnetwork.common.model.CardNetworkError.ServerIssue +import com.checkout.cardnetwork.common.model.CardNetworkError.Unauthenticated +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.mockito.Mockito.mock +import org.mockito.Mockito.verify +import org.mockito.Mockito.`when` +import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.argumentCaptor + +@OptIn(ExperimentalCoroutinesApi::class) +internal class CardStateManagementSuspendTest { + private val service: CardService = mock() + private val manager: com.checkout.cardmanagement.CheckoutCardManager = mock() + private val logger: CheckoutEventLogger = mock() + private val sessionTokenFlow = MutableStateFlow("SESSION_TOKEN") + + private val testScope = TestScope(UnconfinedTestDispatcher()) + private val testCoroutineScopeOwner = + object : CoroutineScopeOwner { + override val scope: CoroutineScope = testScope + + override fun cancel() {} + } + + private lateinit var card: Card + + @Before + fun setup() { + `when`(manager.logger).thenReturn(logger) + `when`(manager.sessionToken).thenReturn(sessionTokenFlow) + `when`(manager.service).thenReturn(service) + `when`(manager.coroutineScope).thenReturn(testCoroutineScopeOwner) + } + + @After + fun tearDown() { + testScope.cancel() + } + + // -- activate -- + + @Test + fun `suspend activate returns Success on flow success`() = + runBlocking { + card = createCard(INACTIVE) + `when`(service.activateCard(any(), any())) + .thenReturn(flow { emit(Result.success(Unit)) }) + + val result = card.activate() + + assertTrue(result is CardOperationResult.Success) + } + + @Test + fun `suspend activate returns InvalidStateTransition when current state is not allowed`() = + runBlocking { + card = createCard(ACTIVE) + + val result = card.activate() + + assertTrue(result is CardOperationResult.Error.InvalidStateTransition) + val error = result as CardOperationResult.Error.InvalidStateTransition + assertEquals(ACTIVE, error.currentState) + assertEquals(ACTIVE, error.requestedState) + } + + @Test + fun `suspend activate returns Unauthenticated when session token is null`() = + runBlocking { + card = createCard(INACTIVE) + sessionTokenFlow.value = null + + val result = card.activate() + + assertTrue(result is CardOperationResult.Error.Unauthenticated) + } + + @Test + fun `suspend activate returns mapped Error when flow emits failure`() = + runBlocking { + card = createCard(INACTIVE) + `when`(service.activateCard(any(), any())) + .thenReturn(flow { emit(Result.failure(ServerIssue)) }) + + val result = card.activate() + + assertTrue(result is CardOperationResult.Error) + } + + @Test + fun `suspend activate logs failure when flow emits failure`() = + runBlocking { + card = createCard(INACTIVE) + `when`(service.activateCard(any(), any())) + .thenReturn(flow { emit(Result.failure(ServerIssue)) }) + + card.activate() + + assertFailureLogged(ACTIVATE_CARD, ServerIssue) + } + + @Test + fun `suspend activate returns Error when flow throws`() = + runBlocking { + card = createCard(INACTIVE) + `when`(service.activateCard(any(), any())) + .thenReturn(flow { throw ServerIssue }) + + val result = card.activate() + + assertTrue(result is CardOperationResult.Error) + } + + @Test + fun `suspend activate logs StateManagement on success`() = + runBlocking { + card = createCard(INACTIVE) + `when`(service.activateCard(any(), any())) + .thenReturn(flow { emit(Result.success(Unit)) }) + + card.activate() + + assertStateManagementLogged( + expectedReason = null, + expectedRequestedState = ACTIVE, + ) + } + + // -- suspend -- + + @Test + fun `suspend suspend returns Success on flow success`() = + runBlocking { + card = createCard(ACTIVE) + `when`(service.suspendCard(any(), any(), any())) + .thenReturn(flow { emit(Result.success(Unit)) }) + + val result = card.suspend(LOST) + + assertTrue(result is CardOperationResult.Success) + } + + @Test + fun `suspend suspend returns Success with null reason`() = + runBlocking { + card = createCard(ACTIVE) + `when`(service.suspendCard(any(), anyOrNull(), any())) + .thenReturn(flow { emit(Result.success(Unit)) }) + + val result = card.suspend(null) + + assertTrue(result is CardOperationResult.Success) + } + + @Test + fun `suspend suspend returns InvalidStateTransition when current state is REVOKED`() = + runBlocking { + card = createCard(REVOKED) + + val result = card.suspend(LOST) + + assertTrue(result is CardOperationResult.Error.InvalidStateTransition) + } + + @Test + fun `suspend suspend returns Unauthenticated when session token is null`() = + runBlocking { + card = createCard(ACTIVE) + sessionTokenFlow.value = null + + val result = card.suspend(LOST) + + assertTrue(result is CardOperationResult.Error.Unauthenticated) + } + + @Test + fun `suspend suspend returns Error when flow throws`() = + runBlocking { + card = createCard(ACTIVE) + `when`(service.suspendCard(any(), any(), any())) + .thenReturn(flow { throw Unauthenticated }) + + val result = card.suspend(LOST) + + assertTrue(result is CardOperationResult.Error) + } + + @Test + fun `suspend suspend logs failure when flow emits failure`() = + runBlocking { + card = createCard(ACTIVE) + `when`(service.suspendCard(any(), any(), any())) + .thenReturn(flow { emit(Result.failure(ServerIssue)) }) + + card.suspend(LOST) + + assertFailureLogged(SUSPEND_CARD, ServerIssue) + } + + // -- revoke -- + + @Test + fun `suspend revoke returns Success on flow success`() = + runBlocking { + card = createCard(INACTIVE) + `when`(service.revokeCard(any(), any(), any())) + .thenReturn(flow { emit(Result.success(Unit)) }) + + val result = card.revoke(STOLEN) + + assertTrue(result is CardOperationResult.Success) + } + + @Test + fun `suspend revoke returns Success with null reason`() = + runBlocking { + card = createCard(INACTIVE) + `when`(service.revokeCard(any(), anyOrNull(), any())) + .thenReturn(flow { emit(Result.success(Unit)) }) + + val result = card.revoke(null) + + assertTrue(result is CardOperationResult.Success) + } + + @Test + fun `suspend revoke returns InvalidStateTransition when already REVOKED`() = + runBlocking { + card = createCard(REVOKED) + + val result = card.revoke(STOLEN) + + assertTrue(result is CardOperationResult.Error.InvalidStateTransition) + } + + @Test + fun `suspend revoke returns Unauthenticated when session token is null`() = + runBlocking { + card = createCard(INACTIVE) + sessionTokenFlow.value = null + + val result = card.revoke(STOLEN) + + assertTrue(result is CardOperationResult.Error.Unauthenticated) + } + + @Test + fun `suspend revoke logs failure when flow emits failure`() = + runBlocking { + card = createCard(INACTIVE) + `when`(service.revokeCard(any(), any(), any())) + .thenReturn(flow { emit(Result.failure(ServerIssue)) }) + + card.revoke(STOLEN) + + assertFailureLogged(REVOKE_CARD, ServerIssue) + } + + @Test + fun `suspend revoke logs StateManagement on success`() = + runBlocking { + card = createCard(ACTIVE) + `when`(service.revokeCard(any(), any(), any())) + .thenReturn(flow { emit(Result.success(Unit)) }) + + card.revoke(STOLEN) + + assertStateManagementLogged( + expectedReason = "reported_stolen", + expectedRequestedState = REVOKED, + ) + } + + @Test + fun `suspend revoke returns Error when flow throws`() = + runBlocking { + card = createCard(INACTIVE) + `when`(service.revokeCard(any(), any(), any())) + .thenReturn(flow { throw ServerIssue }) + + val result = card.revoke(STOLEN) + + assertTrue(result is CardOperationResult.Error) + } + + @Test + fun `suspend suspend logs StateManagement on success with reason`() = + runBlocking { + card = createCard(ACTIVE) + `when`(service.suspendCard(any(), any(), any())) + .thenReturn(flow { emit(Result.success(Unit)) }) + + card.suspend(LOST) + + assertStateManagementLogged( + expectedReason = "suspected_lost", + expectedRequestedState = SUSPENDED, + ) + } + + private fun assertFailureLogged( + expectedSource: String, + expectedError: CardNetworkError, + ) { + val eventCaptor = argumentCaptor() + verify(logger).log(eventCaptor.capture(), any(), any()) + assertTrue(eventCaptor.firstValue is LogEvent.Failure) + (eventCaptor.firstValue as LogEvent.Failure).let { event -> + assertEquals(expectedSource, event.source) + assertEquals(expectedError, event.error) + } + } + + private fun assertStateManagementLogged( + expectedReason: String?, + expectedRequestedState: CardState, + ) { + val eventCaptor = argumentCaptor() + verify(logger).log(eventCaptor.capture(), any(), any()) + assertTrue(eventCaptor.firstValue is LogEvent.StateManagement) + (eventCaptor.firstValue as LogEvent.StateManagement).let { event -> + assertEquals(card.id, event.cardId) + assertEquals(card.state, event.originalState) + assertEquals(expectedRequestedState, event.requestedState) + assertEquals(expectedReason, event.reason) + } + } + + private fun createCard(state: CardState): Card = + Card( + state = state, + id = "CARD_ID", + panLast4Digits = "1234", + expiryDate = CardExpiryDate("11", "25"), + cardholderName = "John Smith", + manager = manager, + ) +} diff --git a/cardmanagement/src/test/java/com/checkout/cardmanagement/model/CardTest.kt b/cardmanagement/src/test/java/com/checkout/cardmanagement/model/CardTest.kt index ebe5548..800f0e2 100644 --- a/cardmanagement/src/test/java/com/checkout/cardmanagement/model/CardTest.kt +++ b/cardmanagement/src/test/java/com/checkout/cardmanagement/model/CardTest.kt @@ -36,5 +36,89 @@ internal class CardTest { assertEquals(networkCard.expiryMonth, card.expiryDate.month) assertEquals(networkCard.displayName, card.cardholderName) assertEquals(networkCard.id, card.id) + assertEquals(CardType.VIRTUAL, card.type) + assertEquals(CardScheme.VISA, card.cardScheme) + } + + @Test + fun `fromNetworkCard should map physical card type correctly`() { + val networkCard = Fixtures.NETWORK_CARD_PHYSICAL + val card = + Card.fromNetworkCard( + networkCard = networkCard, + manager = cardManager, + ) + + assertEquals(CardType.PHYSICAL, card.type) + } + + @Test + fun `fromNetworkCard should map unknown card type to UNKNOWN`() { + val networkCard = Fixtures.NETWORK_CARD.copy(type = "prepaid") + val card = + Card.fromNetworkCard( + networkCard = networkCard, + manager = cardManager, + ) + + assertEquals(CardType.UNKNOWN, card.type) + } + + @Test + fun `card type defaults to UNKNOWN`() { + val card = + Card( + panLast4Digits = "8888", + expiryDate = CardExpiryDate("05", "2045"), + cardholderName = "CARD_HOLDER_NAME", + id = "1234567890", + manager = cardManager, + ) + assertEquals(CardType.UNKNOWN, card.type) + } + + @Test + fun `fromNetworkCard should map visa scheme correctly`() { + val networkCard = Fixtures.NETWORK_CARD + val card = Card.fromNetworkCard(networkCard = networkCard, manager = cardManager) + + assertEquals(CardScheme.VISA, card.cardScheme) + } + + @Test + fun `fromNetworkCard should map mastercard scheme correctly`() { + val networkCard = Fixtures.NETWORK_CARD_PHYSICAL + val card = Card.fromNetworkCard(networkCard = networkCard, manager = cardManager) + + assertEquals(CardScheme.MASTERCARD, card.cardScheme) + } + + @Test + fun `fromNetworkCard should map null scheme to UNKNOWN`() { + val networkCard = Fixtures.NETWORK_CARD.copy(scheme = null) + val card = Card.fromNetworkCard(networkCard = networkCard, manager = cardManager) + + assertEquals(CardScheme.UNKNOWN, card.cardScheme) + } + + @Test + fun `fromNetworkCard should map unrecognised scheme to UNKNOWN`() { + val networkCard = Fixtures.NETWORK_CARD.copy(scheme = "amex") + val card = Card.fromNetworkCard(networkCard = networkCard, manager = cardManager) + + assertEquals(CardScheme.UNKNOWN, card.cardScheme) + } + + @Test + fun `cardScheme defaults to UNKNOWN`() { + val card = + Card( + panLast4Digits = "8888", + expiryDate = CardExpiryDate("05", "2045"), + cardholderName = "CARD_HOLDER_NAME", + id = "1234567890", + manager = cardManager, + ) + assertEquals(CardScheme.UNKNOWN, card.cardScheme) } } diff --git a/cardmanagement/src/test/java/com/checkout/cardmanagement/model/ExtensionsTest.kt b/cardmanagement/src/test/java/com/checkout/cardmanagement/model/ExtensionsTest.kt index e0c07d4..2bc6a1e 100644 --- a/cardmanagement/src/test/java/com/checkout/cardmanagement/model/ExtensionsTest.kt +++ b/cardmanagement/src/test/java/com/checkout/cardmanagement/model/ExtensionsTest.kt @@ -32,4 +32,59 @@ internal class ExtensionsTest { .fromNetworkCardState(), ) } + + @Test + fun `toCardType should map physical string to PHYSICAL`() { + assertEquals(CardType.PHYSICAL, "physical".toCardType()) + } + + @Test + fun `toCardType should map virtual string to VIRTUAL`() { + assertEquals(CardType.VIRTUAL, "virtual".toCardType()) + } + + @Test + fun `toCardType should be case insensitive`() { + assertEquals(CardType.PHYSICAL, "Physical".toCardType()) + assertEquals(CardType.PHYSICAL, "PHYSICAL".toCardType()) + assertEquals(CardType.VIRTUAL, "Virtual".toCardType()) + assertEquals(CardType.VIRTUAL, "VIRTUAL".toCardType()) + } + + @Test + fun `toCardType should map unknown values to UNKNOWN`() { + assertEquals(CardType.UNKNOWN, "prepaid".toCardType()) + assertEquals(CardType.UNKNOWN, "".toCardType()) + assertEquals(CardType.UNKNOWN, "other".toCardType()) + } + + @Test + fun `toCardScheme should map visa string to VISA`() { + assertEquals(CardScheme.VISA, "visa".toCardScheme()) + } + + @Test + fun `toCardScheme should map mastercard string to MASTERCARD`() { + assertEquals(CardScheme.MASTERCARD, "mastercard".toCardScheme()) + } + + @Test + fun `toCardScheme should be case insensitive`() { + assertEquals(CardScheme.VISA, "Visa".toCardScheme()) + assertEquals(CardScheme.VISA, "VISA".toCardScheme()) + assertEquals(CardScheme.MASTERCARD, "Mastercard".toCardScheme()) + assertEquals(CardScheme.MASTERCARD, "MASTERCARD".toCardScheme()) + } + + @Test + fun `toCardScheme should map null to UNKNOWN`() { + assertEquals(CardScheme.UNKNOWN, null.toCardScheme()) + } + + @Test + fun `toCardScheme should map unknown values to UNKNOWN`() { + assertEquals(CardScheme.UNKNOWN, "amex".toCardScheme()) + assertEquals(CardScheme.UNKNOWN, "".toCardScheme()) + assertEquals(CardScheme.UNKNOWN, "other".toCardScheme()) + } } diff --git a/cardmanagement/src/test/java/com/checkout/cardmanagement/model/ProvisioningConfigurationTest.kt b/cardmanagement/src/test/java/com/checkout/cardmanagement/model/ProvisioningConfigurationTest.kt new file mode 100644 index 0000000..b04c379 --- /dev/null +++ b/cardmanagement/src/test/java/com/checkout/cardmanagement/model/ProvisioningConfigurationTest.kt @@ -0,0 +1,200 @@ +package com.checkout.cardmanagement.model + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +internal class ProvisioningConfigurationTest { + @Test + fun `should expose constructor properties`() { + val config = createConfig() + + assertEquals(ISSUER_ID, config.issuerID) + assertEquals(SERVICE_URL, config.serviceURL) + assertEquals(DIGITAL_CARD_URL, config.digitalCardURL) + assertTrue(config.serviceRSAExponent.contentEquals(EXPONENT)) + assertTrue(config.serviceRSAModulus.contentEquals(MODULUS)) + assertEquals(VISA_CLIENT_APP_ID, config.visaClientAppId) + } + + @Test + fun `should expose null visaClientAppId when not provided`() { + val config = + ProvisioningConfiguration( + issuerID = ISSUER_ID, + serviceRSAExponent = EXPONENT.copyOf(), + serviceRSAModulus = MODULUS.copyOf(), + serviceURL = SERVICE_URL, + digitalCardURL = DIGITAL_CARD_URL, + ) + + assertNull(config.visaClientAppId) + } + + @Test + fun `equals should return true for same instance`() { + val config = createConfig() + + assertTrue(config == config) + } + + @Test + fun `equals should return true when content is the same`() { + val config1 = createConfig() + val config2 = createConfig() + + assertEquals(config1, config2) + assertEquals(config1.hashCode(), config2.hashCode()) + } + + @Test + fun `equals should return false when issuerID differs`() { + val config1 = createConfig() + val config2 = createConfig().copy(issuerID = "OTHER") + + assertNotEquals(config1, config2) + } + + @Test + fun `equals should return false when serviceRSAExponent differs`() { + val config1 = createConfig() + val config2 = createConfig().copy(serviceRSAExponent = byteArrayOf(9, 9, 9)) + + assertNotEquals(config1, config2) + } + + @Test + fun `equals should return false when serviceRSAModulus differs`() { + val config1 = createConfig() + val config2 = createConfig().copy(serviceRSAModulus = byteArrayOf(9, 9, 9)) + + assertNotEquals(config1, config2) + } + + @Test + fun `equals should return false when serviceURL differs`() { + val config1 = createConfig() + val config2 = createConfig().copy(serviceURL = "OTHER") + + assertNotEquals(config1, config2) + } + + @Test + fun `equals should return false when digitalCardURL differs`() { + val config1 = createConfig() + val config2 = createConfig().copy(digitalCardURL = "OTHER") + + assertNotEquals(config1, config2) + } + + @Test + fun `equals should return false when visaClientAppId differs`() { + val config1 = createConfig() + val config2 = createConfig().copy(visaClientAppId = "OTHER") + + assertNotEquals(config1, config2) + } + + @Test + fun `equals should return false when visaClientAppId is null for one but not the other`() { + val config1 = createConfig() + val config2 = createConfig().copy(visaClientAppId = null) + + assertNotEquals(config1, config2) + } + + @Test + fun `equals should return true when both have null visaClientAppId`() { + val config1 = createConfig().copy(visaClientAppId = null) + val config2 = createConfig().copy(visaClientAppId = null) + + assertEquals(config1, config2) + assertEquals(config1.hashCode(), config2.hashCode()) + } + + @Test + fun `equals should return false against null`() { + val config = createConfig() + + assertFalse(config.equals(null)) + } + + @Test + fun `equals should return false against different type`() { + val config = createConfig() + + assertFalse(config.equals("not a config")) + } + + @Test + fun `hashCode should be different when content differs`() { + val config1 = createConfig() + val config2 = createConfig().copy(issuerID = "OTHER") + + assertNotEquals(config1.hashCode(), config2.hashCode()) + } + + @Test + fun `toNetworkConfig should produce a non null network ProvisioningConfiguration`() { + val config = createConfig() + + val networkConfig = config.toNetworkConfig() + + assertNotNull(networkConfig) + } + + @Test + fun `toNetworkConfig should produce different network configs for different inputs`() { + val networkConfig1 = createConfig().toNetworkConfig() + val networkConfig2 = createConfig().copy(issuerID = "OTHER").toNetworkConfig() + + assertNotEquals(networkConfig1, networkConfig2) + } + + @Test + fun `toNetworkConfig should produce equal configs for equal inputs`() { + val networkConfig1 = createConfig().toNetworkConfig() + val networkConfig2 = createConfig().toNetworkConfig() + + assertEquals(networkConfig1, networkConfig2) + } + + @Test + fun `toNetworkConfig should produce different network configs when visaClientAppId differs`() { + val networkConfig1 = createConfig().toNetworkConfig() + val networkConfig2 = createConfig().copy(visaClientAppId = null).toNetworkConfig() + + assertNotEquals(networkConfig1, networkConfig2) + } + + @Test + fun `toNetworkConfig should produce equal network configs when both have null visaClientAppId`() { + val networkConfig1 = createConfig().copy(visaClientAppId = null).toNetworkConfig() + val networkConfig2 = createConfig().copy(visaClientAppId = null).toNetworkConfig() + + assertEquals(networkConfig1, networkConfig2) + } + + private fun createConfig() = + ProvisioningConfiguration( + issuerID = ISSUER_ID, + serviceRSAExponent = EXPONENT.copyOf(), + serviceRSAModulus = MODULUS.copyOf(), + serviceURL = SERVICE_URL, + digitalCardURL = DIGITAL_CARD_URL, + visaClientAppId = VISA_CLIENT_APP_ID, + ) + + private companion object { + private const val ISSUER_ID = "ISSUER_ID" + private const val SERVICE_URL = "SERVICE_URL" + private const val DIGITAL_CARD_URL = "DIGITAL_CARD_URL" + private const val VISA_CLIENT_APP_ID = "VISA_CLIENT_APP_ID" + private val EXPONENT = byteArrayOf(1, 2, 3) + private val MODULUS = byteArrayOf(4, 5, 6) + } +}