User: Sessionsapp > ru.alfabank.mobile.mvno.tariff.multistep.service.tariffchange > TariffChangeService.kt
TariffChangeService.kt

    package ru.alfabank.mobile.mvno.tariff.multistep.service.tariffchange

    import kotlinx.coroutines.async
    import kotlinx.coroutines.coroutineScope
    import kotlinx.coroutines.reactor.awaitSingle
    import mu.KLogging
    import org.springframework.stereotype.Service
    import ru.alfabank.mobile.contract.header.Headers
    import ru.alfabank.mobile.exceptions.InternalException
    import ru.alfabank.mobile.exceptions.RequestValidationException
    import ru.alfabank.mobile.mvno.service.api.client.MvnoServiceApiClient
    import ru.alfabank.mobile.mvno.service.api.dto.AvailableServiceDto
    import ru.alfabank.mobile.mvno.service.api.dto.ContractDetailsResponseDto
    import ru.alfabank.mobile.mvno.service.api.dto.ContractDetailsServiceDto
    import ru.alfabank.mobile.mvno.service.api.dto.GetAvailableServicesResponseDto
    import ru.alfabank.mobile.mvno.service.api.dto.TariffResponseDto
    import ru.alfabank.mobile.mvno.service.api.dto.TypeFlowDto
    import ru.alfabank.mobile.mvno.tariff.multistep.configuration.UuidSource
    import ru.alfabank.mobile.mvno.tariff.multistep.configuration.properties.TransactionProperties
    import ru.alfabank.mobile.mvno.tariff.multistep.domain.TariffChangeTransactionUpdate
    import ru.alfabank.mobile.mvno.tariff.multistep.exception.ErrorCode
    import ru.alfabank.mobile.mvno.tariff.multistep.extensions.getZoneOrDefault
    import ru.alfabank.mobile.mvno.tariff.multistep.extensions.toLocalDate
    import ru.alfabank.mobile.mvno.tariff.multistep.extensions.toLocalDateTime
    import ru.alfabank.mobile.mvno.tariff.multistep.repository.tariffchange.TransactionRepository
    import ru.alfabank.mobile.mvno.tariff.multistep.repository.tariffchange.entity.STATUS_ENABLED
    import ru.alfabank.mobile.mvno.tariff.multistep.repository.tariffchange.entity.TariffService
    import ru.alfabank.mobile.mvno.tariff.multistep.repository.tariffchange.entity.Transaction
    import ru.alfabank.mobile.mvno.tariff.multistep.service.ServicesService
    import ru.alfabank.mobile.starter.client.feign.headers.FeignHeaders
    import java.time.InstantSource
    import java.time.LocalDateTime
    import java.time.ZoneOffset

    @Service
    class TariffChangeService(
        private val transactionRepository: TransactionRepository,
        private val instantSource: InstantSource,
        private val transactionProperties: TransactionProperties,
        private val mvnoServiceApi: MvnoServiceApiClient,
        private val servicesService: ServicesService,
        private val uuidSource: UuidSource,
    ) {

        /**
         * Updates expireTime and fields in [update]
         */
        suspend fun updateTransactionWithExpireTimeAndWith(
            transactionId: String,
            update: TariffChangeTransactionUpdate
        ): Transaction? {
            val expireTime = getExpireTime()

            return transactionRepository.findAndUpdateTransactionByTransactionId(
                transactionId = transactionId,
                expireTime = expireTime,
                tariffChangeDate = update.tariffChangeDate,
                cartId = update.cartId,
                cartStatus = update.cartStatus,
                services = update.services,
            )
        }

        suspend fun updateExpireTimeByTransactionId(transactionId: String): Transaction =
            transactionRepository.findAndUpdateExpireTimeByTransactionId(transactionId, getExpireTime())
                ?: throw InternalException.of(ErrorCode.ILLEGAL_STEP)

        suspend fun getOrCreateInitialTransaction(
            headers: Headers,
            contractId: String,
            newTariffId: String?,
        ): Transaction {
            val existingTx = findAndUpdateExpireTimeByContractId(contractId)
            if (existingTx != null && existingTx.tariffCode == newTariffId) {
                return existingTx
            }

            val tariffId = newTariffId.takeUnless { it.isNullOrBlank() }
                ?: throw RequestValidationException.of(ErrorCode.REQUIRED_QUERY_NOT_VALID)

            existingTx?.let { transactionRepository.deleteById(it.transactionId) }

            val (tariff, contract, availableServices) = getTariffAndContractDetails(
                headers = headers,
                contractId = contractId,
                newTariffId = tariffId
            )
            val draftTx = convertToTransaction(
                zoneOffset = headers.getZoneOrDefault(),
                tariff = tariff,
                contracts = contract,
                availableServices = availableServices,
            )

            return transactionRepository.save(draftTx)
        }

        suspend fun findAndUpdateExpireTimeByContractId(contractId: String): Transaction? {
            val expireTime = getExpireTime()
            return transactionRepository.findAndUpdateExpireTimeByContractId(contractId, expireTime)
        }

        suspend fun deleteByTransactionId(transactionId: String) {
            transactionRepository.deleteById(transactionId)
        }

        fun isTariffChangeByTodayDate(transaction: Transaction): Boolean {
            val now = instantSource.instant().toLocalDate()
            return transaction.tariffChangeDate == now
        }

        private fun getExpireTime(): LocalDateTime =
            instantSource.instant().toLocalDateTime() + transactionProperties.expireTime

        private fun convertToTransaction(
            zoneOffset: ZoneOffset,
            tariff: TariffResponseDto,
            contracts: ContractDetailsResponseDto,
            availableServices: GetAvailableServicesResponseDto,
        ): Transaction {
            val now = instantSource.instant()
            val contract = contracts.contractDetails.first()
            val phoneNumber = contract.phoneNumbers.first()
            val nextPaymentDate = phoneNumber.nextPaymentDate.toLocalDate(zoneOffset)
            val currentServices = phoneNumber.services
                ?.map { it.toTransactionService() }
                ?: emptyList()

            if (availableServices.hasMissingServices(currentServices)) {
                throw createInconsistentOffersError(contract.contract, currentServices)
            }
            return Transaction(
                transactionId = uuidSource.generate().toString(),
                createDate = now,
                contractId = contract.contract,
                expireTime = now + transactionProperties.expireTime,
                offerId = tariff.offerId,
                tariffCode = tariff.id,
                cartId = null,
                cartStatus = null,
                tariffPrice = tariff.totalPrice.toInt(),
                tariffName = tariff.name,
                tariffIconUrl = tariff.icon,
                tariffChangeDate = nextPaymentDate,
                tariffNextPaymentDate = nextPaymentDate,
                services = currentServices.withNewServices(availableServices),
                msisdn = phoneNumber.msisdn,
            )
        }

        private suspend fun getTariffAndContractDetails(
            headers: Headers,
            contractId: String,
            newTariffId: String,
        ): Triple<TariffResponseDto, ContractDetailsResponseDto, GetAvailableServicesResponseDto> = coroutineScope {
            val (tariffDefer, contractDefer, availableServices) = Triple(
                async {
                    mvnoServiceApi.getTariffFiltered(
                        headers = FeignHeaders.of(headers),
                        id = newTariffId,
                        contractId = contractId,
                        typeFlow = TypeFlowDto.REGISTRATION,
                        mnp = null,
                        dnt = null,
                        privilegeLevelCode = null
                    ).awaitSingle()
                },
                async {
                    mvnoServiceApi.getContractDetailsByContractIds(
                        headers = FeignHeaders.of(headers),
                        contracts = listOf(contractId)
                    ).awaitSingle()
                },
                async {
                    servicesService.getAvailableServices(
                        headers = headers,
                        contractId = contractId,
                        tariffCode = newTariffId,
                    )
                }
            )

            Triple(tariffDefer.await(), contractDefer.await(), availableServices.await())
        }

        private fun ContractDetailsServiceDto.toTransactionService(): TariffService {
            if (name == null || price == null) {
                error("Name and price should not be null")
            }
            return TariffService(
                code = id,
                name = name!!,
                price = price!!.toInt(),
                icon = icon.orEmpty(),
                status = STATUS_ENABLED,
            )
        }

        private fun GetAvailableServicesResponseDto.hasMissingServices(
            oldTariffServices: List<TariffService>,
        ): Boolean {
            val offerIdToOfferMap = getOfferIdToOfferMap()

            val hasMissingOffers = oldTariffServices
                .filter { it.status == STATUS_ENABLED }
                .any { it.code !in offerIdToOfferMap }

            return hasMissingOffers
        }

        private fun GetAvailableServicesResponseDto.getOfferIdToOfferMap(): Map<String, AvailableServiceDto> =
            groups
                .flatMap { it.subGroups ?: emptyList() }
                .flatMap { it.services }
                .associateBy { it.id }

        private fun List<TariffService>.withNewServices(
            availableServices: GetAvailableServicesResponseDto
        ): List<TariffService> {
            val offerMap = availableServices.getOfferIdToOfferMap()
            return map {
                val newOffer = offerMap.getValue(it.code)
                it.copy(
                    code = newOffer.offerIdent,
                    price = newOffer.price.toInt(),
                )
            }
        }

        private fun createInconsistentOffersError(
            contractId: String,
            services: List<TariffService>
        ): InternalException {
            logger.error {
                "GET services/available?contractId=$contractId " +
                    "does not return all offerId for: ${services.map { it.code }}"
            }
            return InternalException.of(ErrorCode.INCONSISTENT_OFFERS)
        }
        companion object : KLogging()
    }

Created with JaCoCo 0.8.12.202403310830

write tests using kotlin junit 5 mockito extention mock. cover only fairing createInconsistentOffers error, nullable phoneNumber.services, nullable name, price of service, nullable subGroups


Assistant: Certainly! Below is a Kotlin JUnit 5 test class using Mockito extension, that specifically targets the parts in `TariffChangeService.kt` you requested, i.e.:

- `createInconsistentOffersError` error creation (exception thrown),
- Handling of nullable `phoneNumber.services`,
- Handling of nullable `ContractDetailsServiceDto.name` and `price` in `toTransactionService()`,
- Handling of nullable `subGroups` in `GetAvailableServicesResponseDto.getOfferIdToOfferMap()`.

The test uses mocking where relevant to isolate the tested methods.

```kotlin
import kotlinx.coroutines.runBlocking
import mu.KLogging
import org.junit.jupiter.api.Assertions.*
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.assertThrows
import org.junit.jupiter.api.extension.ExtendWith
import org.mockito.InjectMocks
import org.mockito.Mock
import org.mockito.junit.jupiter.MockitoExtension
import ru.alfabank.mobile.contract.header.Headers
import ru.alfabank.mobile.exceptions.InternalException
import ru.alfabank.mobile.mvno.service.api.dto.*
import ru.alfabank.mobile.mvno.tariff.multistep.repository.tariffchange.entity.STATUS_ENABLED
import ru.alfabank.mobile.mvno.tariff.multistep.repository.tariffchange.entity.TariffService
import java.time.Instant
import java.time.LocalDate
import java.time.LocalDateTime
import java.time.ZoneOffset
import java.time.temporal.TemporalAmount
import java.util.*

@ExtendWith(MockitoExtension::class)
internal class TariffChangeServiceTest {

    @Mock
    lateinit var transactionRepository: Any // unused for these tests

    @Mock
    lateinit var instantSource: java.time.InstantSource

    @Mock
    lateinit var transactionProperties: ru.alfabank.mobile.mvno.tariff.multistep.configuration.properties.TransactionProperties

    @Mock
    lateinit var mvnoServiceApi: Any // unused for these tests

    @Mock
    lateinit var servicesService: Any // unused for these tests

    @Mock
    lateinit var uuidSource: ru.alfabank.mobile.mvno.tariff.multistep.configuration.UuidSource

    @InjectMocks
    lateinit var service: ru.alfabank.mobile.mvno.tariff.multistep.service.tariffchange.TariffChangeService

    // Helper for nullable field tests
    private fun createContractServiceDto(name: String?, price: Double?) =
        object : ContractDetailsServiceDto() {
            override val id: String = "serviceId"
            override val name: String? = name
            override val price: Double? = price
            override val icon: String? = null
        }

    @Test
    fun `toTransactionService should error if name is null`() {
        val dto = createContractServiceDto(name = null, price = 1.0)
        val ex = assertThrows<IllegalStateException> {
            service.run {
                dto.toTransactionService()
            }
        }
        assertEquals("Name and price should not be null", ex.message)
    }

    @Test
    fun `toTransactionService should error if price is null`() {
        val dto = createContractServiceDto(name = "Name", price = null)
        val ex = assertThrows<IllegalStateException> {
            service.run {
                dto.toTransactionService()
            }
        }
        assertEquals("Name and price should not be null", ex.message)
    }

    @Test
    fun `convertToTransaction should handle nullable phoneNumber.services`() = runBlocking {
        // Create minimal dummy data to pass into convertToTransaction()
        val zoneOffset = ZoneOffset.UTC
        val tariff = TariffResponseDto(
            id = "tariffId",
            offerId = "offerId",
            totalPrice = 100.0,
            name = "TariffName",
            icon = "IconUrl"
        )
        val contractServiceDto = object : ContractDetailsServiceDto() {
            override val id: String = "svc1"
            override val name: String? = "Service Name"
            override val price: Double? = 10.0
            override val icon: String? = "icon"
        }

        // PhoneNumber with nullable services = null
        val phoneNumber = object : ru.alfabank.mobile.mvno.service.api.dto.ContractDetailsPhoneNumberDto() {
            override val msisdn: String = "msisdn"
            override val nextPaymentDate: String = LocalDate.now().toString()
            override val services: List<ContractDetailsServiceDto>? = null
        }

        val contractDetails = object : ru.alfabank.mobile.mvno.service.api.dto.ContractDetailsDto() {
            override val contract: String = "contractId"
            override val phoneNumbers: List<ru.alfabank.mobile.mvno.service.api.dto.ContractDetailsPhoneNumberDto> = listOf(phoneNumber)
        }

        val contracts = ContractDetailsResponseDto(contractDetails = listOf(contractDetails))

        // GetAvailableServicesResponseDto with dummy groups & services
        val availableService = AvailableServiceDto(
            id = "svc1",
            offerIdent = "svc1",
            price = 10.0
        )
        val subGroup = object : ru.alfabank.mobile.mvno.service.api.dto.AvailableServicesSubGroupDto {
            override val services: List<AvailableServiceDto> = listOf(availableService)
        }
        val group = object : ru.alfabank.mobile.mvno.service.api.dto.AvailableServicesGroupDto {
            override val subGroups: List<ru.alfabank.mobile.mvno.service.api.dto.AvailableServicesSubGroupDto>? = listOf(subGroup)
        }
        val availableServices = GetAvailableServicesResponseDto(groups = listOf(group))

        // We test that no exception is thrown and services list is empty non-null
        val transaction = service.convertToTransaction(zoneOffset, tariff, contracts, availableServices)
        assertNotNull(transaction)
        // services should not be null, should include fixed service
        assertTrue(transaction.services.isNotEmpty())
        assertEquals(1, transaction.services.size)
        assertEquals("svc1", transaction.services[0].code)
        assertEquals(10, transaction.services[0].price)
    }

    @Test
    fun `getOfferIdToOfferMap should handle nullable subGroups`() {
        // Group with null subGroups
        val groupWithNullSubGroups = object : ru.alfabank.mobile.mvno.service.api.dto.AvailableServicesGroupDto {
            override val subGroups: List<ru.alfabank.mobile.mvno.service.api.dto.AvailableServicesSubGroupDto>? = null
        }
        val dto = GetAvailableServicesResponseDto(groups = listOf(groupWithNullSubGroups))

        // Should return empty map since no subGroups => no services
        val map = service.run { dto.getOfferIdToOfferMap() }
        assertTrue(map.isEmpty())
    }

    @Test
    fun `createInconsistentOffersError returns InternalException and logs error`() {
        // Setup a logger to capture output (Since KLogging uses mu.KLogger static, no direct way to mock)
        // Instead, test only that exception is thrown correctly

        val contractId = "contract123"
        val services = listOf(
            TariffService(
                code = "code1",
                name = "name1",
                price = 10,
                icon = "icon",
                status = STATUS_ENABLED
            ),
            TariffService(
                code = "code2",
                name = "name2",
                price = 20,
                icon = "icon",
                status = STATUS_ENABLED
            ),
        )

        val ex = service.run { createInconsistentOffersError(contractId, services) }
        assertTrue(ex is InternalException)
        // ErrorCode is private in InternalException, just check message non-null
        assertNotNull(ex.message)
    }
}
```

---

### Notes

- The `TariffChangeService` class requires a lot of dependencies; I mocked/injected only those that are required per test.
- For `toTransactionService` nullable checks, the method throws `IllegalStateException` because it calls `error()`.
- For `convertToTransaction`, we create dummy DTOs with required minimal properties; this test also covers when `phoneNumber.services` is null.
- For `getOfferIdToOfferMap`, we test that it returns an empty map if group's `subGroups` is null.
- For `createInconsistentOffersError`, since it logs using `KLogging` which is static, we do not easily mock logger behavior here. Instead we confirm that the returned exception is of correct type.
- This code assumes your DTOs and other classes to be open or have necessary constructors to create instances as shown. Modify mocking/factory patterns if your classes are final or have no default constructors.

If you want me to generate tests for other portions or a more fully mocked context,
User: use mock instead of object
Assistant: