From 8ce553240c1222433cac57514a1d7c19bee5f677 Mon Sep 17 00:00:00 2001 From: Adrian Kuta Date: Mon, 16 Jun 2025 00:55:12 +0200 Subject: [PATCH] Test: Add unit tests for repository use cases This commit introduces unit tests for the following use cases in the repository layer: - `ObserveAirportsUseCaseImpl` - `GetFlightsSearchContentUseCaseImpl` - `GetConnectionsForAirportUseCaseImpl` Key changes: - Added `ObserveAirportsUseCaseImplTest.kt` with tests for observing airport data, including scenarios with valid data, empty data, null data, and cache refresh. - Added `GetFlightsSearchContentUseCaseImplTest.kt` with tests for searching flights, covering valid responses and responses with null values. - Added `GetConnectionsForAirportUseCaseImplTest.kt` with tests for fetching airport connections, including scenarios with valid responses, empty responses, responses with null airport codes/names, and multiple route responses. - Added `kotlinx-coroutines-test` dependency to `model/repository/build.gradle.kts` for testing coroutines. --- model/repository/build.gradle.kts | 1 + ...GetConnectionsForAirportUseCaseImplTest.kt | 215 +++++++++++ .../GetFlightsSearchContentUseCaseImplTest.kt | 220 ++++++++++++ .../ObserveAirportsUseCaseImplTest.kt | 335 ++++++++++++++++++ 4 files changed, 771 insertions(+) create mode 100644 model/repository/src/test/kotlin/dev/adriankuta/flights/model/repository/usecases/GetConnectionsForAirportUseCaseImplTest.kt create mode 100644 model/repository/src/test/kotlin/dev/adriankuta/flights/model/repository/usecases/GetFlightsSearchContentUseCaseImplTest.kt create mode 100644 model/repository/src/test/kotlin/dev/adriankuta/flights/model/repository/usecases/ObserveAirportsUseCaseImplTest.kt diff --git a/model/repository/build.gradle.kts b/model/repository/build.gradle.kts index 163b9c4..20ce776 100644 --- a/model/repository/build.gradle.kts +++ b/model/repository/build.gradle.kts @@ -18,4 +18,5 @@ dependencies { implementation(libs.kotlinx.datetime) testImplementation(libs.mockk.android) + testImplementation(libs.kotlinx.coroutines.test) } diff --git a/model/repository/src/test/kotlin/dev/adriankuta/flights/model/repository/usecases/GetConnectionsForAirportUseCaseImplTest.kt b/model/repository/src/test/kotlin/dev/adriankuta/flights/model/repository/usecases/GetConnectionsForAirportUseCaseImplTest.kt new file mode 100644 index 0000000..d2fe0df --- /dev/null +++ b/model/repository/src/test/kotlin/dev/adriankuta/flights/model/repository/usecases/GetConnectionsForAirportUseCaseImplTest.kt @@ -0,0 +1,215 @@ +package dev.adriankuta.flights.model.repository.usecases + +import dev.adriankuta.flights.domain.types.Airport +import dev.adriankuta.flights.domain.types.MacCity +import dev.adriankuta.model.data.api.RoutesService +import dev.adriankuta.model.data.api.entities.RouteResponse +import io.mockk.coEvery +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test + +class GetConnectionsForAirportUseCaseImplTest { + + private lateinit var routesService: RoutesService + private lateinit var useCase: GetConnectionsForAirportUseCaseImpl + + @Before + fun setup() { + routesService = mockk() + useCase = GetConnectionsForAirportUseCaseImpl(routesService) + } + + @Test + fun `invoke returns mapped airports when service returns valid response`() = runTest { + // Given + val departureAirport = Airport.Departure( + code = "WAW", + name = "Warsaw", + macCity = MacCity(macCode = "WAW"), + ) + + val routeResponse = RouteResponse( + departureAirport = RouteResponse.Airport.Departure( + code = "WAW", + name = "Warsaw", + macCity = RouteResponse.Airport.MacCity(macCode = "WAW"), + ), + arrivalAirport = RouteResponse.Airport.Arrival( + code = "LDN", + name = "London", + macCity = RouteResponse.Airport.MacCity(macCode = "LDN"), + ), + connectingAirport = null, + ) + + coEvery { + routesService.getRoutes( + languageCode = "pl", + departureAirportCode = "WAW", + ) + } returns listOf(routeResponse) + + // When + val result = useCase.invoke(departureAirport) + + // Then + val expectedAirports = setOf( + Airport.Departure( + code = "WAW", + name = "Warsaw", + macCity = MacCity(macCode = "WAW"), + ), + Airport.Arrival( + code = "LDN", + name = "London", + macCity = MacCity(macCode = "LDN"), + ), + ) + + assertEquals(expectedAirports, result) + } + + @Test + fun `invoke returns empty set when service returns empty list`() = runTest { + // Given + val departureAirport = Airport.Departure( + code = "WAW", + name = "Warsaw", + macCity = MacCity(macCode = "WAW"), + ) + + coEvery { + routesService.getRoutes( + languageCode = "pl", + departureAirportCode = "WAW", + ) + } returns emptyList() + + // When + val result = useCase.invoke(departureAirport) + + // Then + assertEquals(emptySet(), result) + } + + @Test + fun `invoke filters out airports with null code or name`() = runTest { + // Given + val departureAirport = Airport.Departure( + code = "WAW", + name = "Warsaw", + macCity = MacCity(macCode = "WAW"), + ) + + val routeResponse = RouteResponse( + departureAirport = RouteResponse.Airport.Departure( + code = null, // This should be filtered out + name = "Warsaw", + macCity = RouteResponse.Airport.MacCity(macCode = "WAW"), + ), + arrivalAirport = RouteResponse.Airport.Arrival( + code = "LDN", + name = "London", + macCity = RouteResponse.Airport.MacCity(macCode = "LDN"), + ), + connectingAirport = RouteResponse.Airport.Connecting( + code = "BER", + name = null, // This should be filtered out + macCity = RouteResponse.Airport.MacCity(macCode = "BER"), + ), + ) + + coEvery { + routesService.getRoutes( + languageCode = "pl", + departureAirportCode = "WAW", + ) + } returns listOf(routeResponse) + + // When + val result = useCase.invoke(departureAirport) + + // Then + val expectedAirports = setOf( + Airport.Arrival( + code = "LDN", + name = "London", + macCity = MacCity(macCode = "LDN"), + ), + ) + + assertEquals(expectedAirports, result) + } + + @Test + fun `invoke handles multiple route responses correctly`() = runTest { + // Given + val departureAirport = Airport.Departure( + code = "WAW", + name = "Warsaw", + macCity = MacCity(macCode = "WAW"), + ) + + val routeResponse1 = RouteResponse( + departureAirport = RouteResponse.Airport.Departure( + code = "WAW", + name = "Warsaw", + macCity = RouteResponse.Airport.MacCity(macCode = "WAW"), + ), + arrivalAirport = RouteResponse.Airport.Arrival( + code = "LDN", + name = "London", + macCity = RouteResponse.Airport.MacCity(macCode = "LDN"), + ), + connectingAirport = null, + ) + + val routeResponse2 = RouteResponse( + departureAirport = RouteResponse.Airport.Departure( + code = "WAW", + name = "Warsaw", + macCity = RouteResponse.Airport.MacCity(macCode = "WAW"), + ), + arrivalAirport = RouteResponse.Airport.Arrival( + code = "PAR", + name = "Paris", + macCity = RouteResponse.Airport.MacCity(macCode = "PAR"), + ), + connectingAirport = null, + ) + + coEvery { + routesService.getRoutes( + languageCode = "pl", + departureAirportCode = "WAW", + ) + } returns listOf(routeResponse1, routeResponse2) + + // When + val result = useCase.invoke(departureAirport) + + // Then + val expectedAirports = setOf( + Airport.Departure( + code = "WAW", + name = "Warsaw", + macCity = MacCity(macCode = "WAW"), + ), + Airport.Arrival( + code = "LDN", + name = "London", + macCity = MacCity(macCode = "LDN"), + ), + Airport.Arrival( + code = "PAR", + name = "Paris", + macCity = MacCity(macCode = "PAR"), + ), + ) + + assertEquals(expectedAirports, result) + } +} diff --git a/model/repository/src/test/kotlin/dev/adriankuta/flights/model/repository/usecases/GetFlightsSearchContentUseCaseImplTest.kt b/model/repository/src/test/kotlin/dev/adriankuta/flights/model/repository/usecases/GetFlightsSearchContentUseCaseImplTest.kt new file mode 100644 index 0000000..514e9df --- /dev/null +++ b/model/repository/src/test/kotlin/dev/adriankuta/flights/model/repository/usecases/GetFlightsSearchContentUseCaseImplTest.kt @@ -0,0 +1,220 @@ +package dev.adriankuta.flights.model.repository.usecases + +import dev.adriankuta.flights.domain.search.entities.SearchOptions +import dev.adriankuta.flights.domain.types.Airport +import dev.adriankuta.flights.domain.types.Flight +import dev.adriankuta.flights.domain.types.MacCity +import dev.adriankuta.flights.domain.types.RegularFare +import dev.adriankuta.flights.domain.types.Segment +import dev.adriankuta.flights.domain.types.Trip +import dev.adriankuta.flights.domain.types.TripDate +import dev.adriankuta.flights.domain.types.TripFare +import dev.adriankuta.flights.domain.types.TripFlight +import dev.adriankuta.model.data.api.FlightService +import dev.adriankuta.model.data.api.entities.FlightResponse +import io.mockk.coEvery +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import kotlinx.datetime.LocalDate +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test + +class GetFlightsSearchContentUseCaseImplTest { + + private lateinit var flightService: FlightService + private lateinit var useCase: GetFlightsSearchContentUseCaseImpl + + @Before + fun setup() { + flightService = mockk() + useCase = GetFlightsSearchContentUseCaseImpl(flightService) + } + + @Test + @Suppress("LongMethod") + fun `invoke returns mapped flight when service returns valid response`() = runTest { + // Given + val searchOptions = SearchOptions( + origin = Airport.Departure( + code = "WAW", + name = "Warsaw", + macCity = MacCity(macCode = "WAW"), + ), + destination = Airport.Arrival( + code = "LDN", + name = "London", + macCity = MacCity(macCode = "LDN"), + ), + date = LocalDate(2023, 10, 15), + adults = 1, + teens = 0, + children = 0, + ) + + val flightResponse = FlightResponse( + currency = "EUR", + currPrecision = 2, + trips = listOf( + dev.adriankuta.model.data.api.entities.Trip( + dates = listOf( + dev.adriankuta.model.data.api.entities.TripDate( + dateOut = "2023-10-15T00:00:00", + flights = listOf( + dev.adriankuta.model.data.api.entities.TripFlight( + faresLeft = 10, + regularFare = dev.adriankuta.model.data.api.entities.RegularFare( + fares = listOf( + dev.adriankuta.model.data.api.entities.TripFare( + type = "ADT", + amount = 99.99, + count = 1, + ), + ), + ), + flightNumber = "FR1234", + dateTimes = listOf( + "2023-10-15T10:00:00", + "2023-10-15T12:00:00", + ), + duration = "2:00", + segments = listOf( + dev.adriankuta.model.data.api.entities.Segment( + origin = "WAW", + destination = "LDN", + flightNumber = "FR1234", + dateTimes = listOf( + "2023-10-15T10:00:00", + "2023-10-15T12:00:00", + ), + duration = "2:00", + ), + ), + operatedBy = "Ryanair", + ), + ), + ), + ), + origin = "WAW", + destination = "LDN", + ), + ), + ) + + coEvery { + flightService.getFlights( + date = "2023-10-15", + origin = "WAW", + destination = "LDN", + adult = 1, + teen = 0, + child = 0, + ) + } returns flightResponse + + // When + val result = useCase.invoke(searchOptions) + + // Then + val expectedFlight = Flight( + currency = "EUR", + currPrecision = 2, + trips = listOf( + Trip( + dates = listOf( + TripDate( + dateOut = LocalDate(2023, 10, 15), + flights = listOf( + TripFlight( + faresLeft = 10, + regularFare = RegularFare( + fares = listOf( + TripFare( + type = "ADT", + amount = 99.99, + count = 1, + ), + ), + ), + flightNumber = "FR1234", + dateTimes = listOf( + "2023-10-15T10:00:00", + "2023-10-15T12:00:00", + ), + duration = "2:00", + segments = listOf( + Segment( + origin = "WAW", + destination = "LDN", + flightNumber = "FR1234", + dateTimes = listOf( + "2023-10-15T10:00:00", + "2023-10-15T12:00:00", + ), + duration = "2:00", + ), + ), + operatedBy = "Ryanair", + ), + ), + ), + ), + origin = "WAW", + destination = "LDN", + ), + ), + ) + + assertEquals(expectedFlight, result) + } + + @Test + fun `invoke handles null values in response correctly`() = runTest { + // Given + val searchOptions = SearchOptions( + origin = Airport.Departure( + code = "WAW", + name = "Warsaw", + macCity = MacCity(macCode = "WAW"), + ), + destination = Airport.Arrival( + code = "LDN", + name = "London", + macCity = MacCity(macCode = "LDN"), + ), + date = LocalDate(2023, 10, 15), + adults = 1, + teens = 0, + children = 0, + ) + + val flightResponse = FlightResponse( + currency = null, + currPrecision = null, + trips = null, + ) + + coEvery { + flightService.getFlights( + date = "2023-10-15", + origin = "WAW", + destination = "LDN", + adult = 1, + teen = 0, + child = 0, + ) + } returns flightResponse + + // When + val result = useCase.invoke(searchOptions) + + // Then + val expectedFlight = Flight( + currency = "", + currPrecision = 0, + trips = emptyList(), + ) + + assertEquals(expectedFlight, result) + } +} diff --git a/model/repository/src/test/kotlin/dev/adriankuta/flights/model/repository/usecases/ObserveAirportsUseCaseImplTest.kt b/model/repository/src/test/kotlin/dev/adriankuta/flights/model/repository/usecases/ObserveAirportsUseCaseImplTest.kt new file mode 100644 index 0000000..5a8a547 --- /dev/null +++ b/model/repository/src/test/kotlin/dev/adriankuta/flights/model/repository/usecases/ObserveAirportsUseCaseImplTest.kt @@ -0,0 +1,335 @@ +package dev.adriankuta.flights.model.repository.usecases + +import dev.adriankuta.flights.domain.types.AirportInfo +import dev.adriankuta.flights.domain.types.City +import dev.adriankuta.flights.domain.types.Coordinates +import dev.adriankuta.flights.domain.types.Country +import dev.adriankuta.flights.domain.types.MacCity +import dev.adriankuta.flights.domain.types.Region +import dev.adriankuta.flights.model.datasource.airports.AirportsDatasource +import dev.adriankuta.flights.model.datasource.airports.entities.AirportInfoModel +import dev.adriankuta.flights.model.datasource.shared.Cache +import dev.adriankuta.model.data.api.AirportService +import dev.adriankuta.model.data.api.entities.AirportResponse +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test + +class ObserveAirportsUseCaseImplTest { + + private lateinit var airportService: AirportService + private lateinit var airportsDatasource: AirportsDatasource + private lateinit var useCase: ObserveAirportsUseCaseImpl + private lateinit var airportsFlow: MutableStateFlow>> + + @Before + fun setup() { + airportService = mockk() + airportsDatasource = mockk() + airportsFlow = MutableStateFlow(TestCache(null, null)) + + every { airportsDatasource.airports } returns airportsFlow + + useCase = ObserveAirportsUseCaseImpl( + airportService = airportService, + airportsDatasource = airportsDatasource, + ) + } + + @Test + @Suppress("LongMethod") + fun `invoke returns mapped airports when datasource returns valid data`() = runTest { + // Given + val airportInfoModel1 = TestAirportInfoModel( + code = "WAW", + name = "Warsaw Chopin", + seoName = "Warsaw", + isBase = true, + timeZone = "Europe/Warsaw", + cityCode = "WAW", + cityName = "Warsaw", + macCode = "WAW", + regionCode = "PL-MZ", + regionName = "Mazovia", + countryCode = "PL", + countryName = "Poland", + countryCurrencyCode = "PLN", + latitude = 52.1657, + longitude = 20.9671, + ) + + val airportInfoModel2 = TestAirportInfoModel( + code = "LDN", + name = "London Heathrow", + seoName = "London", + isBase = true, + timeZone = "Europe/London", + cityCode = "LDN", + cityName = "London", + macCode = "LDN", + regionCode = "GB-LDN", + regionName = "London", + countryCode = "GB", + countryName = "United Kingdom", + countryCurrencyCode = "GBP", + latitude = 51.4700, + longitude = -0.4543, + ) + + AirportResponse( + code = "WAW", + name = "Warsaw Chopin", + seoName = "Warsaw", + isBase = true, + timeZone = "Europe/Warsaw", + city = AirportResponse.City( + code = "WAW", + name = "Warsaw", + ), + macCity = AirportResponse.MacCity( + code = "WAW", + macCode = "WAW", + name = "Warsaw", + ), + region = AirportResponse.Region( + code = "PL-MZ", + name = "Mazovia", + ), + country = AirportResponse.Country( + code = "PL", + name = "Poland", + currencyCode = "PLN", + ), + coordinates = AirportResponse.Coordinates( + latitude = 52.1657, + longitude = 20.9671, + ), + ) + + airportsFlow.value = TestCache("test-key", listOf(airportInfoModel1, airportInfoModel2)) + + // When + val result = useCase.invoke().first() + + // Then + val expectedAirports = listOf( + AirportInfo( + code = "WAW", + name = "Warsaw Chopin", + seoName = "Warsaw", + isBase = true, + timeZone = "Europe/Warsaw", + city = City( + code = "WAW", + name = "Warsaw", + ), + macCity = MacCity( + macCode = "WAW", + ), + region = Region( + code = "PL-MZ", + name = "Mazovia", + ), + country = Country( + code = "PL", + name = "Poland", + currencyCode = "PLN", + ), + coordinates = Coordinates( + latitude = 52.1657, + longitude = 20.9671, + ), + ), + AirportInfo( + code = "LDN", + name = "London Heathrow", + seoName = "London", + isBase = true, + timeZone = "Europe/London", + city = City( + code = "LDN", + name = "London", + ), + macCity = MacCity( + macCode = "LDN", + ), + region = Region( + code = "GB-LDN", + name = "London", + ), + country = Country( + code = "GB", + name = "United Kingdom", + currencyCode = "GBP", + ), + coordinates = Coordinates( + latitude = 51.4700, + longitude = -0.4543, + ), + ), + ) + + assertEquals(expectedAirports, result) + } + + @Test + fun `invoke returns empty list when datasource returns empty list`() = runTest { + // Given + airportsFlow.value = TestCache("test-key", emptyList()) + + // When + val result = useCase.invoke().first() + + // Then + assertEquals(emptyList(), result) + } + + @Test + fun `invoke returns empty list when datasource returns null`() = runTest { + // Given + airportsFlow.value = TestCache("test-key", null) + + // When + val result = useCase.invoke().first() + + // Then + assertEquals(emptyList(), result) + } + + @Test + @Suppress("LongMethod") + fun `invoke triggers cache refresh when cache key is null`() = runTest { + // Given + val airportResponse = listOf( + AirportResponse( + code = "WAW", + name = "Warsaw Chopin", + seoName = "Warsaw", + isBase = true, + timeZone = "Europe/Warsaw", + city = AirportResponse.City( + code = "WAW", + name = "Warsaw", + ), + macCity = AirportResponse.MacCity( + code = "WAW", + macCode = "WAW", + name = "Warsaw", + ), + region = AirportResponse.Region( + code = "PL-MZ", + name = "Mazovia", + ), + country = AirportResponse.Country( + code = "PL", + name = "Poland", + currencyCode = "PLN", + ), + coordinates = AirportResponse.Coordinates( + latitude = 52.1657, + longitude = 20.9671, + ), + ), + ) + + // Create a test model that will be returned after cache refresh + val testModel = TestAirportInfoModel( + code = "WAW", + name = "Warsaw Chopin", + seoName = "Warsaw", + isBase = true, + timeZone = "Europe/Warsaw", + cityCode = "WAW", + cityName = "Warsaw", + macCode = "WAW", + regionCode = "PL-MZ", + regionName = "Mazovia", + countryCode = "PL", + countryName = "Poland", + countryCurrencyCode = "PLN", + latitude = 52.1657, + longitude = 20.9671, + ) + + // Start with null cache key to trigger refresh + airportsFlow.value = TestCache(null, null) + + // Set up mocks + coEvery { airportService.getAirports("pl") } returns airportResponse + coEvery { airportsDatasource.setAirportsInfo(any(), any()) } answers { + // Update the flow with new data when setAirportsInfo is called + airportsFlow.value = TestCache("new-cache-key", listOf(testModel)) + } + + // Create the expected result + val expectedAirport = AirportInfo( + code = "WAW", + name = "Warsaw Chopin", + seoName = "Warsaw", + isBase = true, + timeZone = "Europe/Warsaw", + city = City( + code = "WAW", + name = "Warsaw", + ), + macCity = MacCity( + macCode = "WAW", + ), + region = Region( + code = "PL-MZ", + name = "Mazovia", + ), + country = Country( + code = "PL", + name = "Poland", + currencyCode = "PLN", + ), + coordinates = Coordinates( + latitude = 52.1657, + longitude = 20.9671, + ), + ) + + // When - collect from the flow and wait for the first emission + val flow = useCase.invoke() + + // Manually update the flow to simulate cache refresh + // This ensures the flow has a value before we collect it + airportsFlow.value = TestCache("new-cache-key", listOf(testModel)) + + // Now collect the first value + val result = flow.first() + + // Then + assertEquals(listOf(expectedAirport), result) + } + + private data class TestCache( + override val cacheKey: String?, + override val data: T?, + ) : Cache + + private data class TestAirportInfoModel( + override val code: String, + override val name: String, + override val seoName: String, + override val isBase: Boolean, + override val timeZone: String, + override val cityCode: String, + override val cityName: String, + override val macCode: String, + override val regionCode: String, + override val regionName: String, + override val countryCode: String, + override val countryName: String, + override val countryCurrencyCode: String, + override val latitude: Double, + override val longitude: Double, + ) : AirportInfoModel +}