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: Certainly! Here's the revised test class with Mockito mocks instead of anonymous objects for DTOs and other inputs. This approach uses Mockito’s `mock()` and `whenever()` to set up the DTO fields instead of concrete instantiations.

```kotlin
import kotlinx.coroutines.runBlocking
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.Mockito.*
import org.mockito.junit.jupiter.MockitoExtension
import org.mockito.kotlin.mock
import org.mockito.kotlin.whenever
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 ru.alfabank.mobile.mvno.tariff.multistep.service.tariffchange.TariffChangeService
import java.time.*

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

    private val transactionRepository = mock<Any>()
    private val instantSource = mock<java.time.InstantSource>()
    private val transactionProperties = mock<ru.alfabank.mobile.mvno.tariff.multistep.configuration.properties.TransactionProperties>()
    private val mvnoServiceApi = mock<Any>()
    private val servicesService = mock<Any>()
    private val uuidSource = mock<ru.alfabank.mobile.mvno.tariff.multistep.configuration.UuidSource>()

    private val service = TariffChangeService(
        transactionRepository = transactionRepository as any,
        instantSource = instantSource,
        transactionProperties = transactionProperties,
        mvnoServiceApi = mvnoServiceApi as any,
        servicesService = servicesService as any,
        uuidSource = uuidSource,
    )

    @Test
    fun `toTransactionService should error if name is null`() {
        val dto = mock<ContractDetailsServiceDto> {
            on { id } doReturn "id1"
            on { name } doReturn null
            on { price } doReturn 10.0
            on { icon } doReturn null
        }
        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 = mock<ContractDetailsServiceDto> {
            on { id } doReturn "id2"
            on { name } doReturn "name"
            on { price } doReturn null
            on { icon } doReturn 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 {
        val zoneOffset = ZoneOffset.UTC

        val tariff = mock<TariffResponseDto> {
            on { id } doReturn "tariffId"
            on { offerId } doReturn "offerId"
            on { totalPrice } doReturn 100.0
            on { name } doReturn "TariffName"
            on { icon } doReturn "IconUrl"
        }

        val phoneNumber = mock<ContractDetailsPhoneNumberDto> {
            on { msisdn } doReturn "msisdn1"
            on { nextPaymentDate } doReturn LocalDate.now().toString()
            on { services } doReturn null  // nullable test here
        }

        val contractDetail = mock<ContractDetailsDto> {
            on { contract } doReturn "contractId"
            on { phoneNumbers } doReturn listOf(phoneNumber)
        }

        val contracts = mock<ContractDetailsResponseDto> {
            on { contractDetails } doReturn listOf(contractDetail)
        }

        val availableService = mock<AvailableServiceDto> {
            on { id } doReturn "svc1"
            on { offerIdent } doReturn "svc1"
            on { price } doReturn 10.0
        }

        val subGroup = mock<AvailableServicesSubGroupDto> {
            on { services } doReturn listOf(availableService)
        }

        val group = mock<AvailableServicesGroupDto> {
            on { subGroups } doReturn listOf(subGroup)
        }

        val availableServices = mock<GetAvailableServicesResponseDto> {
            on { groups } doReturn listOf(group)
        }

        val instantNow = Instant.now()
        whenever(instantSource.instant()).thenReturn(instantNow)
        whenever(transactionProperties.expireTime).thenReturn(Duration.ofHours(1))

        val transaction = service.convertToTransaction(zoneOffset, tariff, contracts, availableServices)

        assertNotNull(transaction)
        // services list is not null and contains 1 mapped service
        assertEquals(1, transaction.services.size)
        assertEquals("svc1", transaction.services[0].code)
        assertEquals(10, transaction.services[0].price)
    }

    @Test
    fun `getOfferIdToOfferMap should handle nullable subGroups`() {
        val groupWithNullSubGroups = mock<AvailableServicesGroupDto> {
            on { subGroups } doReturn null
        }

        val dto = mock<GetAvailableServicesResponseDto> {
            on { groups } doReturn listOf(groupWithNullSubGroups)
        }

        val map = service.run { dto.getOfferIdToOfferMap() }
        assertTrue(map.isEmpty())
    }

    @Test
    fun `createInconsistentOffersError returns InternalException and logs error`() {
        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)
        assertNotNull(ex.message)
    }
}
```

---

### What changed?

- Used `mock<T>() { }` with `on { field } doReturn value` (standard Mockito-Kotlin syntax) instead of anonymous objects.
- Mockito mocks provide flexibility if class is open / interface.
- `instantSource.instant()` and `transactionProperties.expireTime` are mocked for `convertToTransaction`.
- We still test exception thrown on null `name` and `price`.
- We verify that nullable `subGroups` returns empty map.
- `createInconsistentOffersError` test still verifies the returned exception is correct.

### Notes

- Make sure your DTO classes are open or interfaces to be mockable.
- For code using `.toTransactionService()`, if `ContractDetailsServiceDto` is a Kotlin data class or final class missing open modifier, you might need to switch to a helper wrapper or a subclass instead.
- This example assumes the presence of Mockito-Kotlin (e.g. `org.mockito.kotlin`) for nicer syntax.

If you want me to help with any other test or details, just ask!
User: do not test private methods. use only public
Assistant: Understood! I'll provide Kotlin unit tests using JUnit 5 and Mockito that **only test the public methods** of `TariffChangeService`, while covering the requested cases by invoking public APIs that exercise the code paths involving:

- The exception thrown by `createInconsistentOffersError()` (indirectly via public methods),
- Handling of nullable `phoneNumber.services`,
- Handling of nullable `name` and `price` in services,
- Handling of nullable `subGroups`.

Because `createInconsistentOffersError` is private, tests will trigger it indirectly by causing inconsistent offers in a scenario reachable from the public API.

---

### Test strategy for requested coverage using **only public methods**:

- `getOrCreateInitialTransaction` will internally call `convertToTransaction` and `getTariffAndContractDetails`.
- By mocking `servicesService.getAvailableServices` to return inconsistent services, we can cause it to throw `InternalException` due to `createInconsistentOffersError`.
- For nullable `phoneNumber.services`, provide a mock contract details where `phoneNumber.services == null` and ensure no NPE or errors.
- For nullable `name` and `price`, those are checked in `toTransactionService()`. This method is private, so to test the behavior indirectly, mock `mvnoServiceApi.getContractDetailsByContractIds` to return contracts with services having null `name` or `price` — this will trigger the error when `getOrCreateInitialTransaction` runs.
- For nullable `subGroups`, mock `servicesService.getAvailableServices` to return groups with null `subGroups` and verify no error in `getOrCreateInitialTransaction`.

---

### Code example

```kotlin
import kotlinx.coroutines.runBlocking
import org.junit.jupiter.api.Assertions.*
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.extension.ExtendWith
import org.mockito.kotlin.*
import org.mockito.junit.jupiter.MockitoExtension
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.*
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.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.service.ServicesService
import ru.alfabank.mobile.mvno.tariff.multistep.service.tariffchange.TariffChangeService
import java.time.*
import java.util.*

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

    private val transactionRepository: TransactionRepository = mock()
    private val instantSource: java.time.InstantSource = mock()
    private val transactionProperties: TransactionProperties = mock()
    private val mvnoServiceApi: MvnoServiceApiClient = mock()
    private val servicesService: ServicesService = mock()
    private val uuidSource: UuidSource = mock()

    private val service = TariffChangeService(
        transactionRepository = transactionRepository,
        instantSource = instantSource,
        transactionProperties = transactionProperties,
        mvnoServiceApi = mvnoServiceApi,
        servicesService = servicesService,
        uuidSource = uuidSource,
    )

    private val headers: Headers = mock()
    private val contractId = "contract-1"
    private val tariffId = "tariff-1"

    @Test
    fun `getOrCreateInitialTransaction throws InternalException if inconsistent offers`() = runBlocking {
        // Setup tariff data
        val tariff = mock<TariffResponseDto>().apply {
            whenever(this.id).thenReturn(tariffId)
            whenever(this.offerId).thenReturn("offerId")
            whenever(this.totalPrice).thenReturn(100.0)
            whenever(this.name).thenReturn("Tariff Name")
            whenever(this.icon).thenReturn("iconUrl")
        }

        // Setup contract with services that have a valid name and price
        val validService = mock<ContractDetailsServiceDto>().apply {
            whenever(this.id).thenReturn("service1")
            whenever(this.name).thenReturn("Valid Service")
            whenever(this.price).thenReturn(10.0)
            whenever(this.icon).thenReturn(null)
        }
        val phoneNumber = mock<ContractDetailsPhoneNumberDto>().apply {
            whenever(this.msisdn).thenReturn("msisdn1")
            whenever(this.nextPaymentDate).thenReturn(LocalDate.now().toString())
            whenever(this.services).thenReturn(listOf(validService))
        }
        val contractDetails = mock<ContractDetailsDto>().apply {
            whenever(this.contract).thenReturn(contractId)
            whenever(this.phoneNumbers).thenReturn(listOf(phoneNumber))
        }
        val contractDetailsResponse = mock<ContractDetailsResponseDto>().apply {
            whenever(this.contractDetails).thenReturn(listOf(contractDetails))
        }

        // Setup available services that miss one of the existing services (to trigger inconsistent offers)
        val availableService = AvailableServiceDto(
            id = "some-other-service",
            offerIdent = "some-other-service",
            price = 20.0
        )
        val subGroup = mock<AvailableServicesSubGroupDto>().apply {
            whenever(this.services).thenReturn(listOf(availableService))
        }
        val group = mock<AvailableServicesGroupDto>().apply {
            whenever(this.subGroups).thenReturn(listOf(subGroup))
        }
        val availableServices = GetAvailableServicesResponseDto(groups = listOf(group))

        // Mock deps called by getTariffAndContractDetails
        whenever(mvnoServiceApi.getTariffFiltered(any(), eq(tariffId), eq(contractId), any(), anyOrNull(), anyOrNull(), anyOrNull()))
            .thenReturn(Mono.just(tariff)) // note: your code uses reactive Mono - use whatever correct mocking you have for these
        whenever(mvnoServiceApi.getContractDetailsByContractIds(any(), eq(listOf(contractId))))
            .thenReturn(Mono.just(contractDetailsResponse))

        whenever(servicesService.getAvailableServices(any(), eq(contractId), eq(tariffId)))
            .thenReturn(kotlinx.coroutines.reactor.mono { availableServices })

        whenever(uuidSource.generate()).thenReturn(UUID.randomUUID())

        // Mock expireTime and instant
        val now = Instant.now()
        whenever(instantSource.instant()).thenReturn(now)
        whenever(transactionProperties.expireTime).thenReturn(Duration.ofHours(1))

        val ex = assertThrows<InternalException> {
            runBlocking {
                service.getOrCreateInitialTransaction(headers, contractId, tariffId)
            }
        }
        // This error corresponds to ErrorCode.INCONSISTENT_OFFERS triggered inside createInconsistentOffersError
        assertEquals("ErrorCode.INCONSISTENT_OFFERS", ex.errorCode.name)
    }

    @Test
    fun `getOrCreateInitialTransaction should pass when phoneNumber.services is null`() = runBlocking {
        val tariff = mock<TariffResponseDto>().apply {
            whenever(this.id).thenReturn(tariffId)
            whenever(this.offerId).thenReturn("offerId")
            whenever(this.totalPrice).thenReturn(100.0)
            whenever(this.name).thenReturn("Tariff Name")
            whenever(this.icon).thenReturn("iconUrl")
        }

        val phoneNumber = mock<ContractDetailsPhoneNumberDto>().apply {
            whenever(this.msisdn).thenReturn("msisdn1")
            whenever(this.nextPaymentDate).thenReturn(LocalDate.now().toString())
            whenever(this.services).thenReturn(null) // services null test
        }

        val contractDetails = mock<ContractDetailsDto>().apply {
            whenever(this.contract).thenReturn(contractId)
            whenever(this.phoneNumbers).thenReturn(listOf(phoneNumber))
        }
        val contractDetailsResponse = mock<ContractDetailsResponseDto>().apply {
            whenever(this.contractDetails).thenReturn(listOf(contractDetails))
        }

        val availableService = AvailableServiceDto(
            id = "svc1",
            offerIdent = "svc1",
            price = 15.0
        )
        val subGroup = mock<AvailableServicesSubGroupDto>().apply {
            whenever(this.services).thenReturn(listOf(availableService))
        }
        val group = mock<AvailableServicesGroupDto>().apply {
            whenever(this.subGroups).thenReturn(listOf(subGroup))
        }
        val availableServices = GetAvailableServicesResponseDto(groups = listOf(group))


        whenever(mvnoServiceApi.getTariffFiltered(any(), eq(tariffId), eq(contractId), any(), anyOrNull(), anyOrNull(), anyOrNull()))
            .thenReturn(Mono.just(tariff))
        whenever(mvnoServiceApi.getContractDetailsByContractIds(any(), eq(listOf(contractId))))
            .thenReturn(Mono.just(contractDetailsResponse))

        whenever(servicesService.getAvailableServices(any(), eq(contractId), eq(tariffId)))
            .thenReturn(kotlinx.coroutines.reactor.mono { availableServices })

        whenever(uuidSource.generate()).thenReturn(UUID.randomUUID())

        val now = Instant.now()
        whenever(instantSource.instant()).thenReturn(now)
        whenever(transactionProperties.expireTime).thenReturn(Duration.ofHours(1))

        val transaction = service.getOrCreateInitialTransaction(headers, contractId, tariffId)
        assertEquals(contractId, transaction.contractId)
        assertNotNull(transaction.services)
        assertEquals(1, transaction.services.size)
        assertEquals("svc1", transaction.services[0].code)
    }

    @Test
    fun `getOrCreateInitialTransaction throws error if service name or price is null in contract details`() = runBlocking {
        val tariff = mock<TariffResponseDto>().apply {
            whenever(this.id).thenReturn(tariffId)
            whenever(this.offerId).thenReturn("offerId")
            whenever(this.totalPrice).thenReturn(100.0)
            whenever(this.name).thenReturn("Tariff Name")
            whenever(this.icon).thenReturn("iconUrl")
        }

        val invalidServiceWithNullName = mock<ContractDetailsServiceDto>().apply {
            whenever(this.id).thenReturn("id1")
            whenever(this.name).thenReturn(null) // invalid for test
            whenever(this.price).thenReturn(100.0)
            whenever(this.icon).thenReturn(null)
        }

        val phoneNumber = mock<ContractDetailsPhoneNumberDto>().apply {
            whenever(this.msisdn).thenReturn("msisdn1")
            whenever(this.nextPaymentDate).thenReturn(LocalDate.now().toString())
            whenever(this.services).thenReturn(listOf(invalidServiceWithNullName))
        }

        val contractDetails = mock<ContractDetailsDto>().apply {
            whenever(this.contract).thenReturn(contractId)
            whenever(this.phoneNumbers).thenReturn(listOf(phoneNumber))
        }

        val contractDetailsResponse = mock<ContractDetailsResponseDto>().apply {
            whenever(this.contractDetails).thenReturn(listOf(contractDetails))
        }

        val availableService = AvailableServiceDto(
            id = "id1",
            offerIdent = "id1",
            price = 100.0
        )
        val subGroup = mock<AvailableServicesSubGroupDto>().apply {
            whenever(this.services).thenReturn(listOf(availableService))
        }
        val group = mock<AvailableServicesGroupDto>().apply {
            whenever(this.subGroups).thenReturn(listOf(subGroup))
        }
        val availableServices = GetAvailableServicesResponseDto(groups = listOf(group))


        whenever(mvnoServiceApi.getTariffFiltered(any(), eq(tariffId), eq(contractId), any(), anyOrNull(), anyOrNull(), anyOrNull()))
            .thenReturn(Mono.just(tariff))
        whenever(mvnoServiceApi.getContractDetailsByContractIds(any(), eq(listOf(contractId))))
            .thenReturn(Mono.just(contractDetailsResponse))

        whenever(servicesService.getAvailableServices(any(), eq(contractId), eq(tariffId)))
            .thenReturn(kotlinx.coroutines.reactor.mono { availableServices })

        whenever(uuidSource.generate()).thenReturn(UUID.randomUUID())
        val now = Instant.now()
        whenever(instantSource.instant()).thenReturn(now)
        whenever(transactionProperties.expireTime).thenReturn(Duration.ofHours(1))

        val ex = assertThrows<IllegalStateException> {
            service.getOrCreateInitialTransaction(headers, contractId, tariffId)
        }
        assertEquals("Name and price should not be null", ex.message)
    }

    @Test
    fun `getOrCreateInitialTransaction handles available services with null subGroups`() = runBlocking {
        val tariff = mock<TariffResponseDto>().apply {
            whenever(this.id).thenReturn(tariffId)
            whenever(this.offerId).thenReturn("offerId")
            whenever(this.totalPrice).thenReturn(100.0)
            whenever(this.name).thenReturn("Tariff Name")
            whenever(this.icon).thenReturn("iconUrl")
        }

        val serviceDto = mock<ContractDetailsServiceDto>().apply {
            whenever(this.id).thenReturn("svc1")
            whenever(this.name).thenReturn("Service 1")
            whenever(this.price).thenReturn(100.0)
            whenever(this.icon).thenReturn(null)
        }

        val phoneNumber = mock<ContractDetailsPhoneNumberDto>().apply {
            whenever(this.msisdn).thenReturn("msisdn1")
            whenever(this.nextPaymentDate).thenReturn(LocalDate.now().toString())
            whenever(this.services).thenReturn(listOf(serviceDto))
        }

        val contractDetails = mock<ContractDetailsDto>().apply {
            whenever(this.contract).thenReturn(contractId)
            whenever(this.phoneNumbers).thenReturn(listOf(phoneNumber))
        }

        val contractDetailsResponse = mock<ContractDetailsResponseDto>().apply {
            whenever(this.contractDetails).thenReturn(listOf(contractDetails))
        }

        val groupWithNullSubGroups = mock<AvailableServicesGroupDto>().apply {
            whenever(this.subGroups).thenReturn(null)
        }

        val availableServices = GetAvailableServicesResponseDto(groups = listOf(groupWithNullSubGroups))

        whenever(mvnoServiceApi.getTariffFiltered(any(), eq(tariffId), eq(contractId), any(), anyOrNull(), anyOrNull(), anyOrNull()))
            .thenReturn(Mono.just(tariff))
        whenever(mvnoServiceApi.getContractDetailsByContractIds(any(), eq(listOf(contractId))))
            .thenReturn(Mono.just(contractDetailsResponse))

        whenever(servicesService.getAvailableServices(any(), eq(contractId), eq(tariffId)))
            .thenReturn(kotlinx.coroutines.reactor.mono { availableServices })

        whenever(uuidSource.generate()).thenReturn(UUID.randomUUID())
        val now = Instant.now()
        whenever(instantSource.instant()).thenReturn(now)
        whenever(transactionProperties.expireTime).thenReturn(Duration.ofHours(1))

        val transaction = service.getOrCreateInitialTransaction(headers, contractId, tariffId)
        assertEquals(contractId, transaction.contractId)
        // Services list should be the original converted ones since available services were empty due to null subGroups
        assertTrue
User: send prev answer again
Assistant: