diff --git a/.github/workflows/check-tests.yml b/.github/workflows/check-tests.yml new file mode 100644 index 0000000..5fced3a --- /dev/null +++ b/.github/workflows/check-tests.yml @@ -0,0 +1,44 @@ +name: Check tests + +on: + workflow_dispatch: + push: + branches: + - develop + - main + pull_request: + +jobs: + build: + name: 📲 Build Android Project + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + submodules: recursive + + - name: Setup build cache + uses: actions/cache@v4 + with: + key: $gradle-build-cache + path: | + ~/.gradle/caches + ~/.gradle/wrapper + ~/.gradle/gradle-build-cache + + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + java-version: '17' + distribution: 'temurin' + + - name: Grant execute permission for gradlew + run: chmod +x gradlew + + - name: Run tests + id: run_tests + run: ./gradlew test + continue-on-error: false diff --git a/README.md b/README.md index baecf05..50f2337 100644 --- a/README.md +++ b/README.md @@ -1,62 +1,75 @@ ## 📱 Overview -An Android application that displays a list of random users fetched from an external API. Features a modern and intuitive UI built with Jetpack Compose, allowing users to filter, delete, and manage profiles. +An Android application that displays a list of random users fetched from an external API. Features a modern and intuitive UI built with Jetpack Compose, allowing users to filter and delete profiles. + ## ✨ Features -- **User List**: Browse through random user profiles with detailed information -- **Filtering**: Filter users by various criteria -- **Local Storage**: Access previously loaded users while offline -- **Reactive UI**: Real-time updates when data changes +- **User list**: Browse through random user profiles with detailed information. Infinite scroll implemented +- **User details**: View detailed information about each user +- **Filtering**: Filter users by name, surname and email +- **Delete**: Remove users from the list + ## 🛠️ Tech Stack -| Category | Technologies | -|----------|--------------| -| **Core** | Kotlin, Coroutines, Flow | -| **UI** | Jetpack Compose | -| **Architecture** | Clean Architecture, MVI Pattern | -| **Dependency Injection** | Hilt | -| **Networking** | Retrofit | -| **Local Storage** | Room | -| **Functional Programming** | Arrow | -| **Testing** | Turbine, Mockk, Roborazzi, Robolectric | -| **Planned** | MockWebServer | +| Category | Technologies | +|----------|-------------------------------------------------------| +| **Core** | Kotlin, Coroutines, Flow | +| **UI** | Jetpack Compose | +| **Architecture** | Clean Architecture, MVI Pattern | +| **Dependency Injection** | Hilt | +| **Networking** | Retrofit | +| **Local Storage** | Room, SharedPreferences | +| **Functional Programming** | Arrow | +| **Testing** | Turbine, Mockk, Roborazzi, Robolectric, MockWebServer | + ## 🏗️ Architecture The project follows **Clean Architecture** principles with an MVI (Model-View-Intent) pattern: -- **Presentation Layer**: Compose UI components and ViewModels -- **Domain Layer**: Business logic encapsulated in UseCases +- **Presentation Layer**: Compose UI components and ViewModels modularized by feature +- **Domain Layer**: Business logic encapsulated in UseCases and repositories - **Data Layer**: Repository implementations and data sources -- **Core Module**: Shared utilities and reusable components +- **Core Module**: Shared utilities and reusable components like api, database and preferences ``` app/ -├── core/ # Core utilities and extensions -├── data/ # Data layer implementations -├── domain/ # Business logic and use cases -└── presentation/ # UI components and ViewModels +├── core/ # Core modules +├── data/ # Data layer +├── domain/ # Domain layer +└── presentation/ # Feature modules + └── users/ # User list feature ``` + ## 🧪 Testing -- **Unit Tests**: Cover ViewModels, data and parts of the core layers +- **Unit Tests**: Cover ViewModels, usecases and data layer - **UI Tests**: Screenshot testing with Roborazzi implemented -- **Integration Tests**: Planned for UseCase to DataSource layers using MockWebServer and Room +- **Integration Tests**: VM integration tests with mockwebserver + ## 🚀 Future Improvements -- Add integration tests for UseCase to DataSource layers using MockWebServer +- Manage strings in core-presentation module +- Add ui-components to core-presentation module or to a new ds module - Expand test coverage across the application +- Integration tests from roborazzi to data layer +- Compose navigation tests +- Kover for coverage reports +- Konsists for code quality +- Ktlint for code style +- Add screenshot results in PR to see the differences + ## 🔧 Getting Started 1. Clone the repository ```bash - git clone https://github.com/yourusername/random-users.git + git clone https://github.com/xavierpellvidal/random-users.git ``` 2. Open the project in Android Studio 3. Sync the project with Gradle @@ -66,4 +79,3 @@ app/ ## 📝 License This project is licensed under the MIT License - see the LICENSE file for details -``` diff --git a/buildSrc/src/main/kotlin/AppVersions.kt b/buildSrc/src/main/kotlin/AppVersions.kt index c741911..e961c65 100644 --- a/buildSrc/src/main/kotlin/AppVersions.kt +++ b/buildSrc/src/main/kotlin/AppVersions.kt @@ -4,8 +4,8 @@ object AppVersions { const val APPLICATION_ID = "com.random.user" const val COMPILE_SDK = 35 const val MIN_SDK = 26 - const val APP_VERSION_CODE = 2 - const val APP_VERSION_NAME = "1.1.0" + const val APP_VERSION_CODE = 3 + const val APP_VERSION_NAME = "1.2.0" const val JVM_TARGET = "17" val javaVersion = JavaVersion.VERSION_17 } diff --git a/core/api/build.gradle.kts b/core/api/build.gradle.kts index c53dd27..99b1d20 100644 --- a/core/api/build.gradle.kts +++ b/core/api/build.gradle.kts @@ -39,8 +39,8 @@ dependencies { implementation(libs.retrofit.gson) implementation(libs.okhttp) implementation(libs.okhttp.logging.interceptor) + implementation(libs.hilt.testing) + implementation(libs.mockwebserver) ksp(libs.com.google.dagger.hilt.compiler) - - testImplementation(libs.bundles.test.unit) } diff --git a/core/api/src/main/kotlin/com/random/users/api/api/UsersApi.kt b/core/api/src/main/kotlin/com/random/users/api/api/UsersApi.kt index 1d64746..9ccd6c7 100644 --- a/core/api/src/main/kotlin/com/random/users/api/api/UsersApi.kt +++ b/core/api/src/main/kotlin/com/random/users/api/api/UsersApi.kt @@ -15,6 +15,7 @@ interface UsersApi { ): Either companion object { + const val TIMEOUT_SECONDS = 30L const val BASE_URL = "https://api.randomuser.me/" } } diff --git a/core/api/src/main/kotlin/com/random/users/api/di/ApiModule.kt b/core/api/src/main/kotlin/com/random/users/api/di/ApiModule.kt index d994328..2f3c6ab 100644 --- a/core/api/src/main/kotlin/com/random/users/api/di/ApiModule.kt +++ b/core/api/src/main/kotlin/com/random/users/api/di/ApiModule.kt @@ -2,6 +2,7 @@ package com.random.users.api.di import arrow.retrofit.adapter.either.EitherCallAdapterFactory import com.random.users.api.api.UsersApi +import com.random.users.api.api.UsersApi.Companion.TIMEOUT_SECONDS import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -11,6 +12,7 @@ import okhttp3.logging.HttpLoggingInterceptor import okhttp3.logging.HttpLoggingInterceptor.Level import retrofit2.Retrofit import retrofit2.converter.gson.GsonConverterFactory +import java.util.concurrent.TimeUnit import javax.inject.Singleton @Module @@ -38,6 +40,9 @@ object ApiModule { return OkHttpClient .Builder() .addInterceptor(logging) + .connectTimeout(TIMEOUT_SECONDS, TimeUnit.SECONDS) + .writeTimeout(TIMEOUT_SECONDS, TimeUnit.SECONDS) + .readTimeout(TIMEOUT_SECONDS, TimeUnit.SECONDS) .build() } } diff --git a/core/api/src/main/kotlin/com/random/users/api/di/TestApiModule.kt b/core/api/src/main/kotlin/com/random/users/api/di/TestApiModule.kt new file mode 100644 index 0000000..f2e6ea2 --- /dev/null +++ b/core/api/src/main/kotlin/com/random/users/api/di/TestApiModule.kt @@ -0,0 +1,56 @@ +package com.random.users.api.di + +import arrow.retrofit.adapter.either.EitherCallAdapterFactory +import com.random.users.api.api.UsersApi +import dagger.Module +import dagger.Provides +import dagger.hilt.components.SingletonComponent +import dagger.hilt.testing.TestInstallIn +import okhttp3.OkHttpClient +import okhttp3.logging.HttpLoggingInterceptor +import okhttp3.mockwebserver.MockWebServer +import retrofit2.Retrofit +import retrofit2.converter.gson.GsonConverterFactory +import javax.inject.Singleton + +@Module +@TestInstallIn( + components = [SingletonComponent::class], + replaces = [ApiModule::class], +) +object TestApiModule { + @Provides + @Singleton + fun provideOkHttpClient(): OkHttpClient { + val logging = HttpLoggingInterceptor() + logging.setLevel(HttpLoggingInterceptor.Level.BODY) + return OkHttpClient + .Builder() + .addInterceptor(logging) + .build() + } + + @Provides + @Singleton + fun provideUsersApi(retrofit: Retrofit): UsersApi = retrofit.create(UsersApi::class.java) + + @Provides + @Singleton + fun provideRetrofit( + okHttpClient: OkHttpClient, + mockWebServer: MockWebServer, + ): Retrofit { + mockWebServer.start() + return Retrofit + .Builder() + .baseUrl(mockWebServer.url("/")) + .addConverterFactory(GsonConverterFactory.create()) + .addCallAdapterFactory(EitherCallAdapterFactory.create()) + .client(okHttpClient) + .build() + } + + @Provides + @Singleton + fun provideMockWebServer(): MockWebServer = MockWebServer() +} diff --git a/core/database/build.gradle.kts b/core/database/build.gradle.kts index 05a3fff..357174b 100644 --- a/core/database/build.gradle.kts +++ b/core/database/build.gradle.kts @@ -35,9 +35,9 @@ android { dependencies { implementation(libs.bundles.layer.data) implementation(libs.room.ktx) + implementation(libs.room.testing) + implementation(libs.hilt.testing) ksp(libs.com.google.dagger.hilt.compiler) ksp(libs.room.compiler) - - testImplementation(libs.bundles.test.unit) } diff --git a/core/database/src/main/kotlin/com/random/users/database/di/TestDatabaseModule.kt b/core/database/src/main/kotlin/com/random/users/database/di/TestDatabaseModule.kt new file mode 100644 index 0000000..fb761ad --- /dev/null +++ b/core/database/src/main/kotlin/com/random/users/database/di/TestDatabaseModule.kt @@ -0,0 +1,32 @@ +package com.random.users.database.di + +import android.content.Context +import androidx.room.Room +import com.random.users.database.RandomUsersDatabase +import com.random.users.database.dao.UserDao +import dagger.Module +import dagger.Provides +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import dagger.hilt.testing.TestInstallIn +import javax.inject.Singleton + +@Module +@TestInstallIn( + components = [SingletonComponent::class], + replaces = [DatabaseModule::class], +) +object TestDatabaseModule { + @Provides + @Singleton + fun provideDatabase( + @ApplicationContext appContext: Context, + ) = Room + .inMemoryDatabaseBuilder(appContext, RandomUsersDatabase::class.java) + .allowMainThreadQueries() + .build() + + @Provides + @Singleton + fun provideUserDao(db: RandomUsersDatabase): UserDao = db.userDao() +} diff --git a/core/preferences/build.gradle.kts b/core/preferences/build.gradle.kts index 828c689..f643b90 100644 --- a/core/preferences/build.gradle.kts +++ b/core/preferences/build.gradle.kts @@ -34,8 +34,7 @@ android { dependencies { implementation(libs.bundles.layer.data) + implementation(libs.hilt.testing) ksp(libs.com.google.dagger.hilt.compiler) - - testImplementation(libs.bundles.test.unit) } diff --git a/core/preferences/src/main/kotlin/com/random/users/preferences/di/TestPreferencesModule.kt b/core/preferences/src/main/kotlin/com/random/users/preferences/di/TestPreferencesModule.kt new file mode 100644 index 0000000..58d4933 --- /dev/null +++ b/core/preferences/src/main/kotlin/com/random/users/preferences/di/TestPreferencesModule.kt @@ -0,0 +1,31 @@ +package com.random.users.preferences.di + +import android.content.Context +import android.content.SharedPreferences +import com.random.users.preferences.manager.PreferencesManager +import dagger.Module +import dagger.Provides +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import dagger.hilt.testing.TestInstallIn +import javax.inject.Singleton + +@Module +@TestInstallIn( + components = [SingletonComponent::class], + replaces = [PreferencesModule::class], +) +object TestPreferencesModule { + private const val TEST_PREFERENCES_NAME = "test_preferences" + + @Provides + @Singleton + fun provideFakeSharedPreferences( + @ApplicationContext context: Context, + ): SharedPreferences = context.getSharedPreferences(TEST_PREFERENCES_NAME, Context.MODE_PRIVATE) + + @Provides + @Singleton + fun providePreferencesManager(sharedPreferences: SharedPreferences): PreferencesManager = + PreferencesManager(sharedPreferences) +} diff --git a/core/presentation/build.gradle.kts b/core/presentation/build.gradle.kts index 4bf6c1c..2847c87 100644 --- a/core/presentation/build.gradle.kts +++ b/core/presentation/build.gradle.kts @@ -45,7 +45,4 @@ dependencies { implementation(libs.androidx.activity.compose) ksp(libs.com.google.dagger.hilt.compiler) - - testImplementation(libs.bundles.test.unit) - testImplementation(libs.bundles.test.compose) } diff --git a/core/test/build.gradle.kts b/core/test/build.gradle.kts index d40783a..c187512 100644 --- a/core/test/build.gradle.kts +++ b/core/test/build.gradle.kts @@ -30,11 +30,17 @@ android { kotlinOptions { jvmTarget = AppVersions.JVM_TARGET } + testOptions { + unitTests { + isIncludeAndroidResources = true + } + } } dependencies { implementation(libs.bundles.layer.data) ksp(libs.com.google.dagger.hilt.compiler) implementation(libs.bundles.test.compose) + testImplementation(libs.bundles.test.unit) } diff --git a/core/test/src/main/kotlin/com/random/users/test/model/getUsersListResponseJson.kt b/core/test/src/main/kotlin/com/random/users/test/model/getUsersListResponseJson.kt new file mode 100644 index 0000000..61a2dbc --- /dev/null +++ b/core/test/src/main/kotlin/com/random/users/test/model/getUsersListResponseJson.kt @@ -0,0 +1,1716 @@ +package com.random.users.test.model + +val getUserListResponsePage1Json = + """ +{ + "results": [ + { + "gender": "female", + "name": { + "title": "Mrs", + "first": "Josepha", + "last": "Van Slooten" + }, + "location": { + "street": { + "number": 7476, + "name": "Klaas Risstraat" + }, + "city": "Feanwalden", + "state": "Zuid-Holland", + "country": "Netherlands", + "postcode": "6690 FJ", + "coordinates": { + "latitude": "37.0200", + "longitude": "63.9935" + }, + "timezone": { + "offset": "+4:00", + "description": "Abu Dhabi, Muscat, Baku, Tbilisi" + } + }, + "email": "josepha.vanslooten@example.com", + "login": { + "uuid": "1", + "username": "silverswan307", + "password": "1031", + "salt": "DNXXVi7c", + "md5": "ea9db62bd657a660dab17cef41e7b919", + "sha1": "128074425933d31421891e00fafb02dad6fb72bc", + "sha256": "ba4a33b2ab88585213daaa06ecc5b19aa61d03e289e6a1889c98e77a9c56245e" + }, + "dob": { + "date": "1950-06-13T04:25:41.108Z", + "age": 74 + }, + "registered": { + "date": "2014-12-04T09:56:39.459Z", + "age": 10 + }, + "phone": "(070) 8310840", + "cell": "(06) 68510177", + "id": { + "name": "BSN", + "value": "35780981" + }, + "picture": { + "large": "https://randomuser.me/api/portraits/women/96.jpg", + "medium": "https://randomuser.me/api/portraits/med/women/96.jpg", + "thumbnail": "https://randomuser.me/api/portraits/thumb/women/96.jpg" + }, + "nat": "NL" + }, + { + "gender": "male", + "name": { + "title": "Mr", + "first": "Felix", + "last": "Hansen" + }, + "location": { + "street": { + "number": 3893, + "name": "Solparken" + }, + "city": "Roedovre", + "state": "Nordjylland", + "country": "Denmark", + "postcode": 26549, + "coordinates": { + "latitude": "-17.6922", + "longitude": "-144.9417" + }, + "timezone": { + "offset": "0:00", + "description": "Western Europe Time, London, Lisbon, Casablanca" + } + }, + "email": "felix.hansen@example.com", + "login": { + "uuid": "2", + "username": "angrybutterfly440", + "password": "return", + "salt": "iigHH0qB", + "md5": "4d85055c03bac7724c4b88fbb54ce9ba", + "sha1": "185d3b9665710ba42ed32e0acdd02f3b49fed5cd", + "sha256": "4071c18ddd77b0ca5af68c3d774ce79dc5df991d24a6c415207a9da5cda1176e" + }, + "dob": { + "date": "1998-01-13T00:21:35.346Z", + "age": 27 + }, + "registered": { + "date": "2015-01-21T20:57:05.563Z", + "age": 10 + }, + "phone": "27582140", + "cell": "16992488", + "id": { + "name": "CPR", + "value": "120198-4273" + }, + "picture": { + "large": "https://randomuser.me/api/portraits/men/69.jpg", + "medium": "https://randomuser.me/api/portraits/med/men/69.jpg", + "thumbnail": "https://randomuser.me/api/portraits/thumb/men/69.jpg" + }, + "nat": "DK" + }, + { + "gender": "male", + "name": { + "title": "Mr", + "first": "Pranav", + "last": "Gupta" + }, + "location": { + "street": { + "number": 2288, + "name": "Tank Bund Rd" + }, + "city": "Dharmavaram", + "state": "Puducherry", + "country": "India", + "postcode": 31611, + "coordinates": { + "latitude": "-22.5974", + "longitude": "-94.1764" + }, + "timezone": { + "offset": "-3:00", + "description": "Brazil, Buenos Aires, Georgetown" + } + }, + "email": "pranav.gupta@example.com", + "login": { + "uuid": "3", + "username": "whitebird338", + "password": "vvvvvvvv", + "salt": "gsrLAlfr", + "md5": "cc104f06bfbda6cd4954729ddbb82972", + "sha1": "a4fdbf6d81b8295aec72895ed24820dd0107e00e", + "sha256": "ea81a600cfdfd19d78f950bad0dbc2a8ca7081046c4bded3c5eb287423cf3e19" + }, + "dob": { + "date": "1996-01-16T00:02:18.918Z", + "age": 29 + }, + "registered": { + "date": "2019-02-20T04:18:37.962Z", + "age": 6 + }, + "phone": "9084650508", + "cell": "8587017105", + "id": { + "name": "UIDAI", + "value": "237690287303" + }, + "picture": { + "large": "https://randomuser.me/api/portraits/men/77.jpg", + "medium": "https://randomuser.me/api/portraits/med/men/77.jpg", + "thumbnail": "https://randomuser.me/api/portraits/thumb/men/77.jpg" + }, + "nat": "IN" + }, + { + "gender": "female", + "name": { + "title": "Mrs", + "first": "Käthi", + "last": "Wurst" + }, + "location": { + "street": { + "number": 9953, + "name": "Birkenweg" + }, + "city": "Bad Teinach-Zavelstein", + "state": "Niedersachsen", + "country": "Germany", + "postcode": 92961, + "coordinates": { + "latitude": "-84.5439", + "longitude": "-134.6555" + }, + "timezone": { + "offset": "0:00", + "description": "Western Europe Time, London, Lisbon, Casablanca" + } + }, + "email": "kathi.wurst@example.com", + "login": { + "uuid": "4", + "username": "goldensnake878", + "password": "drew", + "salt": "QnsgoCXw", + "md5": "120998bda1c199ee3fa90d183a44d757", + "sha1": "8f3b4e3dcefbb3c84a6456d8d0e6cade0d8dc41b", + "sha256": "521002c954b681167edece9a613c01f0e525a3ae4f20d505d33140d7dc1b5d24" + }, + "dob": { + "date": "1959-05-19T22:10:59.102Z", + "age": 65 + }, + "registered": { + "date": "2005-01-23T21:37:19.295Z", + "age": 20 + }, + "phone": "0236-0968353", + "cell": "0174-4190414", + "id": { + "name": "SVNR", + "value": "14 190559 W 514" + }, + "picture": { + "large": "https://randomuser.me/api/portraits/women/27.jpg", + "medium": "https://randomuser.me/api/portraits/med/women/27.jpg", + "thumbnail": "https://randomuser.me/api/portraits/thumb/women/27.jpg" + }, + "nat": "DE" + }, + { + "gender": "female", + "name": { + "title": "Ms", + "first": "Indie", + "last": "Wang" + }, + "location": { + "street": { + "number": 6927, + "name": "Cambridge Terrace" + }, + "city": "Wellington", + "state": "Auckland", + "country": "New Zealand", + "postcode": 80424, + "coordinates": { + "latitude": "-76.9649", + "longitude": "-84.6366" + }, + "timezone": { + "offset": "+2:00", + "description": "Kaliningrad, South Africa" + } + }, + "email": "indie.wang@example.com", + "login": { + "uuid": "5", + "username": "beautifultiger224", + "password": "shaolin", + "salt": "4TvG4Hgf", + "md5": "515d8840c159e61eeb73924908974db5", + "sha1": "63723ae9c79d7704c972ad04a5474089119a9952", + "sha256": "3302f989cb3de8ec2e727dba1cd32fabb5d62965855e5ffce5d985b6fc3e7b31" + }, + "dob": { + "date": "1980-04-15T18:02:40.704Z", + "age": 45 + }, + "registered": { + "date": "2004-10-31T23:08:13.522Z", + "age": 20 + }, + "phone": "(027)-156-8998", + "cell": "(293)-279-9056", + "id": { + "name": "", + "value": null + }, + "picture": { + "large": "https://randomuser.me/api/portraits/women/86.jpg", + "medium": "https://randomuser.me/api/portraits/med/women/86.jpg", + "thumbnail": "https://randomuser.me/api/portraits/thumb/women/86.jpg" + }, + "nat": "NZ" + }, + { + "gender": "male", + "name": { + "title": "Mr", + "first": "Kuzman", + "last": "Anđelić" + }, + "location": { + "street": { + "number": 4720, + "name": "Porodice Ristić" + }, + "city": "Bosilegrad", + "state": "North Banat", + "country": "Serbia", + "postcode": 77512, + "coordinates": { + "latitude": "38.3831", + "longitude": "-71.2108" + }, + "timezone": { + "offset": "-1:00", + "description": "Azores, Cape Verde Islands" + } + }, + "email": "kuzman.andelic@example.com", + "login": { + "uuid": "6", + "username": "brownpanda718", + "password": "diver1", + "salt": "pMt71QHl", + "md5": "dbb6737d340ab6a5a7d7e6c5e8617e7a", + "sha1": "733923fb26bf708f49c5c072148126e0cb75997d", + "sha256": "0df48583c838da70152e31ad7069ca7338d8dd3143804a567683348ff09a8428" + }, + "dob": { + "date": "1977-01-11T16:48:30.071Z", + "age": 48 + }, + "registered": { + "date": "2020-10-08T04:23:35.874Z", + "age": 4 + }, + "phone": "024-9733-745", + "cell": "065-9391-510", + "id": { + "name": "SID", + "value": "329693642" + }, + "picture": { + "large": "https://randomuser.me/api/portraits/men/51.jpg", + "medium": "https://randomuser.me/api/portraits/med/men/51.jpg", + "thumbnail": "https://randomuser.me/api/portraits/thumb/men/51.jpg" + }, + "nat": "RS" + }, + { + "gender": "male", + "name": { + "title": "Mr", + "first": "Alexander", + "last": "Macdonald" + }, + "location": { + "street": { + "number": 1244, + "name": "Victoria Ave" + }, + "city": "Chipman", + "state": "Saskatchewan", + "country": "Canada", + "postcode": "A7Q 3G6", + "coordinates": { + "latitude": "-86.1438", + "longitude": "157.9625" + }, + "timezone": { + "offset": "-11:00", + "description": "Midway Island, Samoa" + } + }, + "email": "alexander.macdonald@example.com", + "login": { + "uuid": "7", + "username": "lazyleopard442", + "password": "sheepdog", + "salt": "Q3LM5O0W", + "md5": "ec87e5576741bd7d678406f9f1f07cb4", + "sha1": "db07baeeb9fd777d59143271d57cda5c859f7212", + "sha256": "6ac30c881a256a16467843a3fab42e3ca52018280c3e1ff18ed4ca83a08d0b34" + }, + "dob": { + "date": "1985-11-05T16:21:16.832Z", + "age": 39 + }, + "registered": { + "date": "2018-04-30T02:37:05.366Z", + "age": 6 + }, + "phone": "I45 Z60-3334", + "cell": "Y23 F47-3466", + "id": { + "name": "SIN", + "value": "117718502" + }, + "picture": { + "large": "https://randomuser.me/api/portraits/men/1.jpg", + "medium": "https://randomuser.me/api/portraits/med/men/1.jpg", + "thumbnail": "https://randomuser.me/api/portraits/thumb/men/1.jpg" + }, + "nat": "CA" + }, + { + "gender": "female", + "name": { + "title": "Mrs", + "first": "Sandra", + "last": "Faure" + }, + "location": { + "street": { + "number": 583, + "name": "Place de L'Abbé-Franz-Stock" + }, + "city": "Colombes", + "state": "Dordogne", + "country": "France", + "postcode": 55365, + "coordinates": { + "latitude": "70.5960", + "longitude": "14.9052" + }, + "timezone": { + "offset": "+4:00", + "description": "Abu Dhabi, Muscat, Baku, Tbilisi" + } + }, + "email": "sandra.faure@example.com", + "login": { + "uuid": "8", + "username": "purplepeacock710", + "password": "chelle", + "salt": "EupiQyqy", + "md5": "c1cfb174db113baad0244f5ea32d15ba", + "sha1": "c293b97a94fade9ba798bf3741816285ba27fecc", + "sha256": "f502f2ae64e96ecd83d193820f0ea20ceaaccce92907b992fa39a77efefe3645" + }, + "dob": { + "date": "1958-12-20T02:36:34.390Z", + "age": 66 + }, + "registered": { + "date": "2002-07-12T02:16:23.479Z", + "age": 22 + }, + "phone": "04-11-93-23-91", + "cell": "06-29-86-93-29", + "id": { + "name": "INSEE", + "value": "2581134043821 25" + }, + "picture": { + "large": "https://randomuser.me/api/portraits/women/75.jpg", + "medium": "https://randomuser.me/api/portraits/med/women/75.jpg", + "thumbnail": "https://randomuser.me/api/portraits/thumb/women/75.jpg" + }, + "nat": "FR" + }, + { + "gender": "male", + "name": { + "title": "Mr", + "first": "Borja", + "last": "Giménez" + }, + "location": { + "street": { + "number": 2294, + "name": "Calle de Téllez" + }, + "city": "Torrejón de Ardoz", + "state": "Asturias", + "country": "Spain", + "postcode": 53813, + "coordinates": { + "latitude": "-19.3128", + "longitude": "-171.3009" + }, + "timezone": { + "offset": "+5:45", + "description": "Kathmandu" + } + }, + "email": "borja.gimenez@example.com", + "login": { + "uuid": "9", + "username": "orangedog726", + "password": "shot", + "salt": "ac67mKMY", + "md5": "b9829501eb4d0c72e561311e97af4c1f", + "sha1": "5bd91739d87b285a14374c96f1ab2d7af71cf2b1", + "sha256": "a296a510705bd60cfc5062b36a52eefc8973b90e6f5ecb3621cbf10e2b4a4455" + }, + "dob": { + "date": "2001-02-17T06:09:27.329Z", + "age": 24 + }, + "registered": { + "date": "2005-11-17T06:56:42.526Z", + "age": 19 + }, + "phone": "904-903-137", + "cell": "694-171-691", + "id": { + "name": "DNI", + "value": "01714079-X" + }, + "picture": { + "large": "https://randomuser.me/api/portraits/men/82.jpg", + "medium": "https://randomuser.me/api/portraits/med/men/82.jpg", + "thumbnail": "https://randomuser.me/api/portraits/thumb/men/82.jpg" + }, + "nat": "ES" + }, + { + "gender": "male", + "name": { + "title": "Mr", + "first": "Carl", + "last": "Garza" + }, + "location": { + "street": { + "number": 6178, + "name": "Smokey Ln" + }, + "city": "Pearland", + "state": "Georgia", + "country": "United States", + "postcode": 49541, + "coordinates": { + "latitude": "78.2839", + "longitude": "-54.9246" + }, + "timezone": { + "offset": "+5:00", + "description": "Ekaterinburg, Islamabad, Karachi, Tashkent" + } + }, + "email": "carl.garza@example.com", + "login": { + "uuid": "10", + "username": "bluefish309", + "password": "chaser", + "salt": "l2Vu87Hj", + "md5": "fa865b1af309faaeab3ab58d3cf5d0a4", + "sha1": "381425b73e94fae50e0a3750b73f0059f309bbf7", + "sha256": "853a710ade76e34c7bfc71828adba3cfba41c2e6ef85bb999a945ae70bd423c2" + }, + "dob": { + "date": "1983-07-28T12:49:25.461Z", + "age": 41 + }, + "registered": { + "date": "2014-02-03T21:54:16.388Z", + "age": 11 + }, + "phone": "(792) 768-6349", + "cell": "(510) 625-7250", + "id": { + "name": "SSN", + "value": "024-69-1119" + }, + "picture": { + "large": "https://randomuser.me/api/portraits/men/23.jpg", + "medium": "https://randomuser.me/api/portraits/med/men/23.jpg", + "thumbnail": "https://randomuser.me/api/portraits/thumb/men/23.jpg" + }, + "nat": "US" + }, + { + "gender": "female", + "name": { + "title": "Ms", + "first": "Amelia", + "last": "Kumar" + }, + "location": { + "street": { + "number": 6201, + "name": "College Road" + }, + "city": "Taupo", + "state": "Marlborough", + "country": "New Zealand", + "postcode": 16873, + "coordinates": { + "latitude": "51.9290", + "longitude": "108.9934" + }, + "timezone": { + "offset": "+9:30", + "description": "Adelaide, Darwin" + } + }, + "email": "amelia.kumar@example.com", + "login": { + "uuid": "11", + "username": "lazybutterfly984", + "password": "aileen", + "salt": "Iz0VwYV8", + "md5": "73d19a642dba48e1efdd7f1785c114d1", + "sha1": "8ab62a4a0bc6c6b634811c3e338654cec551396f", + "sha256": "fa06b8f52b38581f91e4d965d8545b030c805109669c747f630bb3f682f32696" + }, + "dob": { + "date": "1981-07-02T04:21:33.185Z", + "age": 43 + }, + "registered": { + "date": "2006-09-15T09:50:32.144Z", + "age": 18 + }, + "phone": "(464)-056-3509", + "cell": "(664)-753-9639", + "id": { + "name": "", + "value": null + }, + "picture": { + "large": "https://randomuser.me/api/portraits/women/71.jpg", + "medium": "https://randomuser.me/api/portraits/med/women/71.jpg", + "thumbnail": "https://randomuser.me/api/portraits/thumb/women/71.jpg" + }, + "nat": "NZ" + }, + { + "gender": "female", + "name": { + "title": "Ms", + "first": "Patricia", + "last": "Burke" + }, + "location": { + "street": { + "number": 6124, + "name": "Green Lane" + }, + "city": "Carrick-on-Suir", + "state": "Fingal", + "country": "Ireland", + "postcode": 20723, + "coordinates": { + "latitude": "58.0955", + "longitude": "3.7608" + }, + "timezone": { + "offset": "-5:00", + "description": "Eastern Time (US & Canada), Bogota, Lima" + } + }, + "email": "patricia.burke@example.com", + "login": { + "uuid": "12", + "username": "tinyswan195", + "password": "airwolf", + "salt": "jAsP6jnO", + "md5": "956dc63590394f90621d54a84c2ff8d9", + "sha1": "975a6540535a833de1308db2b9d3c7f80fd6a0f3", + "sha256": "ff9661668c5cff4ee7ce55d854eee76b9d5248ba04cec21e7f2e5c4dcb0cbf91" + }, + "dob": { + "date": "1974-10-17T18:35:14.154Z", + "age": 50 + }, + "registered": { + "date": "2014-11-12T15:16:56.054Z", + "age": 10 + }, + "phone": "051-519-3184", + "cell": "081-860-0352", + "id": { + "name": "PPS", + "value": "2080264T" + }, + "picture": { + "large": "https://randomuser.me/api/portraits/women/77.jpg", + "medium": "https://randomuser.me/api/portraits/med/women/77.jpg", + "thumbnail": "https://randomuser.me/api/portraits/thumb/women/77.jpg" + }, + "nat": "IE" + }, + { + "gender": "female", + "name": { + "title": "Mrs", + "first": "Ashley", + "last": "Taylor" + }, + "location": { + "street": { + "number": 9071, + "name": "Homestead Rd" + }, + "city": "Bunbury", + "state": "Victoria", + "country": "Australia", + "postcode": 3609, + "coordinates": { + "latitude": "-44.5935", + "longitude": "-174.4859" + }, + "timezone": { + "offset": "+5:00", + "description": "Ekaterinburg, Islamabad, Karachi, Tashkent" + } + }, + "email": "ashley.taylor@example.com", + "login": { + "uuid": "13", + "username": "redmouse104", + "password": "bigmike", + "salt": "puoIM8c6", + "md5": "1ab7ed076e62047a077954e1e746bceb", + "sha1": "843158934c8ff3843e4cde3ca0c1f16434b73409", + "sha256": "16fe5fdcb44c3baf264e852dd262f142c5fcbec55763c3f65b37e9d8ee1f338c" + }, + "dob": { + "date": "1945-02-12T23:37:08.128Z", + "age": 80 + }, + "registered": { + "date": "2002-03-26T05:04:49.764Z", + "age": 23 + }, + "phone": "00-6785-1546", + "cell": "0421-837-688", + "id": { + "name": "TFN", + "value": "240309476" + }, + "picture": { + "large": "https://randomuser.me/api/portraits/women/15.jpg", + "medium": "https://randomuser.me/api/portraits/med/women/15.jpg", + "thumbnail": "https://randomuser.me/api/portraits/thumb/women/15.jpg" + }, + "nat": "AU" + }, + { + "gender": "male", + "name": { + "title": "Mr", + "first": "Budimir", + "last": "Isaković" + }, + "location": { + "street": { + "number": 9709, + "name": "Ljiljane Jovanović" + }, + "city": "Temerin", + "state": "South Banat", + "country": "Serbia", + "postcode": 59515, + "coordinates": { + "latitude": "-48.7053", + "longitude": "71.5556" + }, + "timezone": { + "offset": "+3:00", + "description": "Baghdad, Riyadh, Moscow, St. Petersburg" + } + }, + "email": "budimir.isakovic@example.com", + "login": { + "uuid": "14", + "username": "smallfrog663", + "password": "polly", + "salt": "ZH0xv5b6", + "md5": "0cd884b08359fb809e80ef02d700c3b5", + "sha1": "ab82ba53acae77145557f694eb139e306b862c69", + "sha256": "d5551bef863ab6d8af6429cc2de77c4958016d593646120abbb5f3e606b4a9f1" + }, + "dob": { + "date": "1996-04-27T19:47:54.458Z", + "age": 28 + }, + "registered": { + "date": "2011-11-27T10:24:18.035Z", + "age": 13 + }, + "phone": "031-3132-169", + "cell": "060-0635-671", + "id": { + "name": "SID", + "value": "148945470" + }, + "picture": { + "large": "https://randomuser.me/api/portraits/men/86.jpg", + "medium": "https://randomuser.me/api/portraits/med/men/86.jpg", + "thumbnail": "https://randomuser.me/api/portraits/thumb/men/86.jpg" + }, + "nat": "RS" + }, + { + "gender": "female", + "name": { + "title": "Mademoiselle", + "first": "Melissa", + "last": "Mathieu" + }, + "location": { + "street": { + "number": 4489, + "name": "Place du 8 Novembre 1942" + }, + "city": "Saint-Légier-La Chiésaz", + "state": "Glarus", + "country": "Switzerland", + "postcode": 4027, + "coordinates": { + "latitude": "33.0288", + "longitude": "158.1784" + }, + "timezone": { + "offset": "+10:00", + "description": "Eastern Australia, Guam, Vladivostok" + } + }, + "email": "melissa.mathieu@example.com", + "login": { + "uuid": "15", + "username": "ticklishgorilla700", + "password": "packard", + "salt": "ozRJfFdw", + "md5": "73ead56bb98f057dbb18f9028045b813", + "sha1": "d96456d9606526e5711f82bd7e7e1d2324aed12b", + "sha256": "675382a0499256964acf7a27363afe3f6c0374352dfe55fcd33992135a4cca25" + }, + "dob": { + "date": "1948-02-29T02:49:08.437Z", + "age": 77 + }, + "registered": { + "date": "2009-01-05T07:46:36.689Z", + "age": 16 + }, + "phone": "075 847 49 86", + "cell": "075 533 68 11", + "id": { + "name": "AVS", + "value": "756.0928.7456.76" + }, + "picture": { + "large": "https://randomuser.me/api/portraits/women/8.jpg", + "medium": "https://randomuser.me/api/portraits/med/women/8.jpg", + "thumbnail": "https://randomuser.me/api/portraits/thumb/women/8.jpg" + }, + "nat": "CH" + } + ], + "info": { + "seed": "7f2d2aa852defdce", + "results": 15, + "page": 1, + "version": "1.4" + } +} + """.trimIndent() + +val getUserListResponsePage2Json = + """ + { + "results": [ + { + "gender": "female", + "name": { + "title": "Mrs", + "first": "Peremisla", + "last": "Seredyuk" + }, + "location": { + "street": { + "number": 9877, + "name": "Mitropolita Lipkivskogo" + }, + "city": "Bahmut", + "state": "Zhitomirska", + "country": "Ukraine", + "postcode": 98107, + "coordinates": { + "latitude": "77.1761", + "longitude": "147.4159" + }, + "timezone": { + "offset": "-5:00", + "description": "Eastern Time (US & Canada), Bogota, Lima" + } + }, + "email": "peremisla.seredyuk@example.com", + "login": { + "uuid": "aca4f803-fb5c-4f15-b659-a26e16010eab", + "username": "organicmouse258", + "password": "english", + "salt": "RvU8euSy", + "md5": "f7579c41c82a3c4235c4a1fd473d25cc", + "sha1": "25080da7304c1027ddbf983ce7dec6b6d53322b8", + "sha256": "8b34964364e2632f86d142e2351244c55235b56f61e0db48301652ca3c6784c7" + }, + "dob": { + "date": "1966-03-25T11:48:55.573Z", + "age": 59 + }, + "registered": { + "date": "2020-02-23T10:49:31.416Z", + "age": 5 + }, + "phone": "(068) A10-8666", + "cell": "(097) W85-2117", + "id": { + "name": "", + "value": null + }, + "picture": { + "large": "https://randomuser.me/api/portraits/women/8.jpg", + "medium": "https://randomuser.me/api/portraits/med/women/8.jpg", + "thumbnail": "https://randomuser.me/api/portraits/thumb/women/8.jpg" + }, + "nat": "UA" + }, + { + "gender": "female", + "name": { + "title": "Miss", + "first": "Eileen", + "last": "Warren" + }, + "location": { + "street": { + "number": 432, + "name": "Hillcrest Rd" + }, + "city": "Cape Coral", + "state": "South Dakota", + "country": "United States", + "postcode": 98989, + "coordinates": { + "latitude": "31.8258", + "longitude": "47.1161" + }, + "timezone": { + "offset": "-4:00", + "description": "Atlantic Time (Canada), Caracas, La Paz" + } + }, + "email": "eileen.warren@example.com", + "login": { + "uuid": "dd9412bf-b4e4-4405-acfd-1fe099b9c2a0", + "username": "silverdog693", + "password": "mister", + "salt": "1f74bKLT", + "md5": "d259069df5b475b6610fd2dcd8f7a552", + "sha1": "c90dc86e0812f7d179f58be02cdbeb03ce57923b", + "sha256": "f3dc5c938036a6bc4cfd0c0e0e32d492b9787d0efb65509dbdbad1e38ce894e2" + }, + "dob": { + "date": "1945-02-11T06:39:10.597Z", + "age": 80 + }, + "registered": { + "date": "2021-11-03T20:18:23.782Z", + "age": 3 + }, + "phone": "(735) 975-3367", + "cell": "(273) 789-3590", + "id": { + "name": "SSN", + "value": "289-26-1672" + }, + "picture": { + "large": "https://randomuser.me/api/portraits/women/52.jpg", + "medium": "https://randomuser.me/api/portraits/med/women/52.jpg", + "thumbnail": "https://randomuser.me/api/portraits/thumb/women/52.jpg" + }, + "nat": "US" + }, + { + "gender": "female", + "name": { + "title": "Mrs", + "first": "Simona", + "last": "Grushevskiy" + }, + "location": { + "street": { + "number": 6021, + "name": "Priladniy provulok" + }, + "city": "Kam'yanka-Buzka", + "state": "Avtonomna Respublika Krim", + "country": "Ukraine", + "postcode": 38593, + "coordinates": { + "latitude": "85.0881", + "longitude": "14.2578" + }, + "timezone": { + "offset": "+4:30", + "description": "Kabul" + } + }, + "email": "simona.grushevskiy@example.com", + "login": { + "uuid": "a48fc855-9b0c-46ae-b59f-13ee4a2ac4fe", + "username": "crazygoose691", + "password": "april1", + "salt": "wKjYMKQd", + "md5": "76465acf374c58755c1a95a8cbaba0e4", + "sha1": "ad7ab85e2a6601d3947849565a18e92e390e0cf2", + "sha256": "32ace1cd905fd0c6e3245f0c4ba959f691abec282eb64bc20c7b999662150526" + }, + "dob": { + "date": "1977-02-04T12:40:15.022Z", + "age": 48 + }, + "registered": { + "date": "2016-01-25T18:24:18.381Z", + "age": 9 + }, + "phone": "(066) K57-7522", + "cell": "(098) E86-2597", + "id": { + "name": "", + "value": null + }, + "picture": { + "large": "https://randomuser.me/api/portraits/women/11.jpg", + "medium": "https://randomuser.me/api/portraits/med/women/11.jpg", + "thumbnail": "https://randomuser.me/api/portraits/thumb/women/11.jpg" + }, + "nat": "UA" + }, + { + "gender": "male", + "name": { + "title": "Mr", + "first": "Javier", + "last": "Bailey" + }, + "location": { + "street": { + "number": 9693, + "name": "Pecan Acres Ln" + }, + "city": "Adelaide", + "state": "Australian Capital Territory", + "country": "Australia", + "postcode": 2678, + "coordinates": { + "latitude": "58.2422", + "longitude": "-96.5787" + }, + "timezone": { + "offset": "-6:00", + "description": "Central Time (US & Canada), Mexico City" + } + }, + "email": "javier.bailey@example.com", + "login": { + "uuid": "8a9b6055-92c9-49a0-945a-9a1168bb843d", + "username": "greentiger605", + "password": "deejay", + "salt": "KyixbWKu", + "md5": "673c52225141b1c63f5eb3e91ef85aae", + "sha1": "1d0c4d800f53d9745a99d15d045b8843bb1c3bc5", + "sha256": "e602f75db2c8554d7ac547918822514c3e8f07fda2e06a30b38f8a5f37da12cd" + }, + "dob": { + "date": "1975-04-10T03:40:44.453Z", + "age": 50 + }, + "registered": { + "date": "2014-06-06T12:40:55.017Z", + "age": 10 + }, + "phone": "07-8408-5651", + "cell": "0440-269-392", + "id": { + "name": "TFN", + "value": "031298897" + }, + "picture": { + "large": "https://randomuser.me/api/portraits/men/70.jpg", + "medium": "https://randomuser.me/api/portraits/med/men/70.jpg", + "thumbnail": "https://randomuser.me/api/portraits/thumb/men/70.jpg" + }, + "nat": "AU" + }, + { + "gender": "male", + "name": { + "title": "Mr", + "first": "Ranko", + "last": "Terzić" + }, + "location": { + "street": { + "number": 1748, + "name": "Stanićeva" + }, + "city": "Blace", + "state": "Toplica", + "country": "Serbia", + "postcode": 87315, + "coordinates": { + "latitude": "-58.7298", + "longitude": "128.0302" + }, + "timezone": { + "offset": "-2:00", + "description": "Mid-Atlantic" + } + }, + "email": "ranko.terzic@example.com", + "login": { + "uuid": "a38eb057-03f9-4007-9626-0c9546e889bb", + "username": "beautifulzebra807", + "password": "aurora", + "salt": "kSO1Ue8p", + "md5": "fc8ae5667b3aa5ef39af5be98efbcb6b", + "sha1": "1e50536f63f0dc1e63459f6ea9839b6679b3887f", + "sha256": "eebc5b303d2b85ab9aaa991c17d015b7a797c714c5d4784fd3568e32590f735c" + }, + "dob": { + "date": "1973-08-31T16:19:40.002Z", + "age": 51 + }, + "registered": { + "date": "2020-01-15T07:48:57.374Z", + "age": 5 + }, + "phone": "030-4357-344", + "cell": "065-7527-717", + "id": { + "name": "SID", + "value": "830845895" + }, + "picture": { + "large": "https://randomuser.me/api/portraits/men/15.jpg", + "medium": "https://randomuser.me/api/portraits/med/men/15.jpg", + "thumbnail": "https://randomuser.me/api/portraits/thumb/men/15.jpg" + }, + "nat": "RS" + }, + { + "gender": "male", + "name": { + "title": "Mr", + "first": "علی", + "last": "زارعی" + }, + "location": { + "street": { + "number": 8582, + "name": "خالد اسلامبولی" + }, + "city": "کاشان", + "state": "خراسان رضوی", + "country": "Iran", + "postcode": 90089, + "coordinates": { + "latitude": "57.9607", + "longitude": "-15.4329" + }, + "timezone": { + "offset": "-9:00", + "description": "Alaska" + } + }, + "email": "aaly.zraay@example.com", + "login": { + "uuid": "0d8b8360-4af6-4e30-a23f-ddeb4d7ad52c", + "username": "bigfrog838", + "password": "wordpass", + "salt": "LWuPbjBH", + "md5": "fd672b4b3d8bc601100d953bff0730a5", + "sha1": "1b196afc6c895c945d5c6d634f64ee46e1f36513", + "sha256": "28d197d2b09319daff380ba67c1e81cf996a3b2850ab5ec3e10057a98e13ced0" + }, + "dob": { + "date": "1998-11-22T22:31:05.583Z", + "age": 26 + }, + "registered": { + "date": "2013-12-25T12:40:35.953Z", + "age": 11 + }, + "phone": "014-29527346", + "cell": "0937-023-8599", + "id": { + "name": "", + "value": null + }, + "picture": { + "large": "https://randomuser.me/api/portraits/men/45.jpg", + "medium": "https://randomuser.me/api/portraits/med/men/45.jpg", + "thumbnail": "https://randomuser.me/api/portraits/thumb/men/45.jpg" + }, + "nat": "IR" + }, + { + "gender": "male", + "name": { + "title": "Mr", + "first": "Arkadiy", + "last": "Korniiec" + }, + "location": { + "street": { + "number": 7120, + "name": "Abrikosoviy provulok" + }, + "city": "Luck", + "state": "Mikolayivska", + "country": "Ukraine", + "postcode": 37045, + "coordinates": { + "latitude": "-66.6025", + "longitude": "144.9396" + }, + "timezone": { + "offset": "+11:00", + "description": "Magadan, Solomon Islands, New Caledonia" + } + }, + "email": "arkadiy.korniiec@example.com", + "login": { + "uuid": "d2badb1f-411f-4c8e-af17-59b6779b93cc", + "username": "whitebird629", + "password": "grinch", + "salt": "SRidRRPW", + "md5": "f64dac43779c5620009dfac19b70a415", + "sha1": "4dd5cb89d68b589dca6dd7eeb6e4d3ee0f3f6964", + "sha256": "eeb391bcf82d6671bb24d815339826776601e49095778d39fef4c5d3c60c03cb" + }, + "dob": { + "date": "1971-10-19T10:47:24.228Z", + "age": 53 + }, + "registered": { + "date": "2019-01-03T03:10:27.746Z", + "age": 6 + }, + "phone": "(098) H06-3750", + "cell": "(098) H09-0846", + "id": { + "name": "", + "value": null + }, + "picture": { + "large": "https://randomuser.me/api/portraits/men/2.jpg", + "medium": "https://randomuser.me/api/portraits/med/men/2.jpg", + "thumbnail": "https://randomuser.me/api/portraits/thumb/men/2.jpg" + }, + "nat": "UA" + }, + { + "gender": "male", + "name": { + "title": "Mr", + "first": "Stanoje", + "last": "Živojinović" + }, + "location": { + "street": { + "number": 7470, + "name": "Petra Markovića" + }, + "city": "Žabari", + "state": "Prizren", + "country": "Serbia", + "postcode": 32373, + "coordinates": { + "latitude": "-67.5521", + "longitude": "92.6955" + }, + "timezone": { + "offset": "+1:00", + "description": "Brussels, Copenhagen, Madrid, Paris" + } + }, + "email": "stanoje.zivojinovic@example.com", + "login": { + "uuid": "e50e69df-9939-441d-bd4e-9c5a0278f25d", + "username": "redgoose989", + "password": "shawn", + "salt": "FcFIOSK8", + "md5": "0535ea6c3563ccb4a8c1ad7faf3efc98", + "sha1": "b7bd01bae80204d6943c819bcbbcd24e7a60c6a6", + "sha256": "a77c67dbdb7ae53d38104042b251476a4496a0f2b7021b091fe59e653ed755bb" + }, + "dob": { + "date": "1951-08-11T13:16:47.294Z", + "age": 73 + }, + "registered": { + "date": "2010-02-20T18:16:56.394Z", + "age": 15 + }, + "phone": "033-2653-305", + "cell": "068-8662-268", + "id": { + "name": "SID", + "value": "600458878" + }, + "picture": { + "large": "https://randomuser.me/api/portraits/men/95.jpg", + "medium": "https://randomuser.me/api/portraits/med/men/95.jpg", + "thumbnail": "https://randomuser.me/api/portraits/thumb/men/95.jpg" + }, + "nat": "RS" + }, + { + "gender": "female", + "name": { + "title": "Miss", + "first": "Anujna", + "last": "Nand" + }, + "location": { + "street": { + "number": 8202, + "name": "Kazimar St" + }, + "city": "Agra", + "state": "Telangana", + "country": "India", + "postcode": 58207, + "coordinates": { + "latitude": "82.0291", + "longitude": "29.9459" + }, + "timezone": { + "offset": "-3:30", + "description": "Newfoundland" + } + }, + "email": "anujna.nand@example.com", + "login": { + "uuid": "7163f93c-67a4-4b03-9e8e-ef006fb0894b", + "username": "tinywolf501", + "password": "working", + "salt": "QEoi3g7l", + "md5": "ac55ab88b45ad93c2b0e32dd734568d5", + "sha1": "b2564cf31abe582e1c765592d4d5ebecd6c3ccfb", + "sha256": "f41a7d56c5eeb2449d56daaf09cd1c6d3fe86bced2fab616caaaf437323efae4" + }, + "dob": { + "date": "1984-05-26T20:35:53.049Z", + "age": 40 + }, + "registered": { + "date": "2018-11-21T01:25:53.829Z", + "age": 6 + }, + "phone": "7140844135", + "cell": "9101784521", + "id": { + "name": "UIDAI", + "value": "021432743442" + }, + "picture": { + "large": "https://randomuser.me/api/portraits/women/46.jpg", + "medium": "https://randomuser.me/api/portraits/med/women/46.jpg", + "thumbnail": "https://randomuser.me/api/portraits/thumb/women/46.jpg" + }, + "nat": "IN" + }, + { + "gender": "male", + "name": { + "title": "Mr", + "first": "Mikael", + "last": "Rintala" + }, + "location": { + "street": { + "number": 122, + "name": "Hatanpään Valtatie" + }, + "city": "Mäntsälä", + "state": "Lapland", + "country": "Finland", + "postcode": 17927, + "coordinates": { + "latitude": "37.0038", + "longitude": "-130.8129" + }, + "timezone": { + "offset": "+10:00", + "description": "Eastern Australia, Guam, Vladivostok" + } + }, + "email": "mikael.rintala@example.com", + "login": { + "uuid": "0bbac6e0-97d0-4c40-a5b8-0b41f0d6ad4c", + "username": "orangewolf910", + "password": "whitney", + "salt": "y97zN8fd", + "md5": "a31ba935ae81b0d62a078d5b8ada83a9", + "sha1": "8e3bb2ab6d7987e211b13e61fa5a9866d983e12b", + "sha256": "6d4a3e34f022d5b9942529f2ff811e0ba62e012fc907dd66210d4ed8b0edcc4a" + }, + "dob": { + "date": "1991-02-12T02:04:04.533Z", + "age": 34 + }, + "registered": { + "date": "2020-10-29T19:23:40.627Z", + "age": 4 + }, + "phone": "03-575-547", + "cell": "048-439-41-06", + "id": { + "name": "HETU", + "value": "NaNNA981undefined" + }, + "picture": { + "large": "https://randomuser.me/api/portraits/men/91.jpg", + "medium": "https://randomuser.me/api/portraits/med/men/91.jpg", + "thumbnail": "https://randomuser.me/api/portraits/thumb/men/91.jpg" + }, + "nat": "FI" + }, + { + "gender": "male", + "name": { + "title": "Mr", + "first": "طاها", + "last": "سلطانی نژاد" + }, + "location": { + "street": { + "number": 271, + "name": "برادران سلیمانی" + }, + "city": "ایلام", + "state": "گیلان", + "country": "Iran", + "postcode": 22792, + "coordinates": { + "latitude": "89.2543", + "longitude": "-0.7480" + }, + "timezone": { + "offset": "-12:00", + "description": "Eniwetok, Kwajalein" + } + }, + "email": "th.sltnynjd@example.com", + "login": { + "uuid": "baf93808-9ab9-4fca-9f16-fb53a95508ed", + "username": "smallzebra356", + "password": "filthy", + "salt": "pjuycu0V", + "md5": "c6f737f5b9a26d741d141ea318e1979d", + "sha1": "cf77da63aafd7f8e55bfb5f50331310b5a8dcc0f", + "sha256": "8ed1d35fd93001515e85f7966ed552492ae17e8deef31b086edece4bdafcc6d0" + }, + "dob": { + "date": "1947-05-28T22:07:59.687Z", + "age": 77 + }, + "registered": { + "date": "2018-06-22T02:31:11.180Z", + "age": 6 + }, + "phone": "039-82624057", + "cell": "0916-628-6996", + "id": { + "name": "", + "value": null + }, + "picture": { + "large": "https://randomuser.me/api/portraits/men/56.jpg", + "medium": "https://randomuser.me/api/portraits/med/men/56.jpg", + "thumbnail": "https://randomuser.me/api/portraits/thumb/men/56.jpg" + }, + "nat": "IR" + }, + { + "gender": "female", + "name": { + "title": "Mrs", + "first": "Anja", + "last": "Führer" + }, + "location": { + "street": { + "number": 7725, + "name": "Marktplatz" + }, + "city": "Emsdetten", + "state": "Mecklenburg-Vorpommern", + "country": "Germany", + "postcode": 75787, + "coordinates": { + "latitude": "31.4884", + "longitude": "52.9724" + }, + "timezone": { + "offset": "-1:00", + "description": "Azores, Cape Verde Islands" + } + }, + "email": "anja.fuhrer@example.com", + "login": { + "uuid": "34bcb006-8eeb-4d8c-9934-d60e8a732e1f", + "username": "organicdog179", + "password": "talon", + "salt": "by4bFkNR", + "md5": "d6cb614d6292bf279b7298e540c1e26b", + "sha1": "13dad11a29e222574cde0b1a8f7215997c76aa13", + "sha256": "10d05795c0b2c5fc26f2abc90064f517f9b2e78681e04c080d1bb3958ee319b2" + }, + "dob": { + "date": "1947-06-16T22:48:03.055Z", + "age": 77 + }, + "registered": { + "date": "2004-02-27T14:30:25.429Z", + "age": 21 + }, + "phone": "0893-2644298", + "cell": "0178-6468635", + "id": { + "name": "SVNR", + "value": "57 160647 F 911" + }, + "picture": { + "large": "https://randomuser.me/api/portraits/women/7.jpg", + "medium": "https://randomuser.me/api/portraits/med/women/7.jpg", + "thumbnail": "https://randomuser.me/api/portraits/thumb/women/7.jpg" + }, + "nat": "DE" + }, + { + "gender": "male", + "name": { + "title": "Mr", + "first": "Marc", + "last": "Soto" + }, + "location": { + "street": { + "number": 8615, + "name": "Hogan St" + }, + "city": "Ontario", + "state": "New Mexico", + "country": "United States", + "postcode": 63717, + "coordinates": { + "latitude": "-57.8323", + "longitude": "172.1837" + }, + "timezone": { + "offset": "+10:00", + "description": "Eastern Australia, Guam, Vladivostok" + } + }, + "email": "marc.soto@example.com", + "login": { + "uuid": "ef7bd743-71c7-4725-8f20-2ce684104275", + "username": "lazytiger592", + "password": "christin", + "salt": "wyt8r9DJ", + "md5": "3e6da237d0163e91f8562d9a31728b5d", + "sha1": "777573900d19031f506174af82f9c5d674025ab0", + "sha256": "94b3e896073cf3816c1666c70cda427f258ce0e14bca3b9f33b00be109d5fed7" + }, + "dob": { + "date": "1982-02-18T22:33:16.092Z", + "age": 43 + }, + "registered": { + "date": "2018-08-19T23:16:07.708Z", + "age": 6 + }, + "phone": "(355) 302-1148", + "cell": "(577) 808-7666", + "id": { + "name": "SSN", + "value": "572-23-7927" + }, + "picture": { + "large": "https://randomuser.me/api/portraits/men/50.jpg", + "medium": "https://randomuser.me/api/portraits/med/men/50.jpg", + "thumbnail": "https://randomuser.me/api/portraits/thumb/men/50.jpg" + }, + "nat": "US" + }, + { + "gender": "male", + "name": { + "title": "Mr", + "first": "Hadrien", + "last": "Lemaire" + }, + "location": { + "street": { + "number": 6642, + "name": "Place des 44 Enfants D'Izieu" + }, + "city": "Paris", + "state": "Marne", + "country": "France", + "postcode": 63467, + "coordinates": { + "latitude": "-43.6437", + "longitude": "97.3369" + }, + "timezone": { + "offset": "0:00", + "description": "Western Europe Time, London, Lisbon, Casablanca" + } + }, + "email": "hadrien.lemaire@example.com", + "login": { + "uuid": "65674559-cf02-4841-aff6-af10f9eedee2", + "username": "sadbear662", + "password": "hello1", + "salt": "F7NHrTPs", + "md5": "81d576e3a21de0e7e692fb840f1cee23", + "sha1": "0f4ee0305b48cd17da98dd7d1003bb8c48aab081", + "sha256": "46cb6705010b7c9b8363c67fbad745275ea5ffe442106f04b17d9b0237752f65" + }, + "dob": { + "date": "1944-09-16T21:00:35.527Z", + "age": 80 + }, + "registered": { + "date": "2021-04-20T00:40:05.686Z", + "age": 4 + }, + "phone": "02-88-74-58-07", + "cell": "06-36-88-84-90", + "id": { + "name": "INSEE", + "value": "1440888276492 63" + }, + "picture": { + "large": "https://randomuser.me/api/portraits/men/54.jpg", + "medium": "https://randomuser.me/api/portraits/med/men/54.jpg", + "thumbnail": "https://randomuser.me/api/portraits/thumb/men/54.jpg" + }, + "nat": "FR" + }, + { + "gender": "female", + "name": { + "title": "Ms", + "first": "Emilie", + "last": "Scott" + }, + "location": { + "street": { + "number": 5695, + "name": "Alfred St" + }, + "city": "Souris", + "state": "Saskatchewan", + "country": "Canada", + "postcode": "P6Z 2C5", + "coordinates": { + "latitude": "4.3664", + "longitude": "86.3577" + }, + "timezone": { + "offset": "-9:00", + "description": "Alaska" + } + }, + "email": "emilie.scott@example.com", + "login": { + "uuid": "996ce76f-630a-4ee2-9585-0022e2325605", + "username": "orangezebra912", + "password": "kaylee", + "salt": "fnmiDKi5", + "md5": "0b77f14ef8b9f270d03feed56553ccff", + "sha1": "cf12c6cdca27441cb1a35d9f8be2fdcf9d200ca0", + "sha256": "b8d3fa0887fef69626fe98ef1fb2a4f69cba7d240d86b5650965ced2578247a6" + }, + "dob": { + "date": "1991-12-26T22:05:58.779Z", + "age": 33 + }, + "registered": { + "date": "2007-06-22T11:15:38.538Z", + "age": 17 + }, + "phone": "S48 G24-9392", + "cell": "J12 U52-4827", + "id": { + "name": "SIN", + "value": "531176873" + }, + "picture": { + "large": "https://randomuser.me/api/portraits/women/25.jpg", + "medium": "https://randomuser.me/api/portraits/med/women/25.jpg", + "thumbnail": "https://randomuser.me/api/portraits/thumb/women/25.jpg" + }, + "nat": "CA" + } + ], + "info": { + "seed": "7f2d2aa852defdce", + "results": 15, + "page": 2, + "version": "1.4" + } + } + """.trimIndent() + +val getUserListErrorResponseJson = + """ + { + error: "Uh oh, something has gone wrong. Please tweet us @randomapi about the issue. Thank you." + } + """.trimIndent() diff --git a/presentation/users/src/test/kotlin/com/random/users/users/rules/DispatcherRules.kt b/core/test/src/main/kotlin/com/random/users/test/rules/DispatcherRules.kt similarity index 90% rename from presentation/users/src/test/kotlin/com/random/users/users/rules/DispatcherRules.kt rename to core/test/src/main/kotlin/com/random/users/test/rules/DispatcherRules.kt index 10529d5..a79dd0f 100644 --- a/presentation/users/src/test/kotlin/com/random/users/users/rules/DispatcherRules.kt +++ b/core/test/src/main/kotlin/com/random/users/test/rules/DispatcherRules.kt @@ -1,6 +1,5 @@ -package com.random.users.users.rules +package com.random.users.test.rules -import io.mockk.unmockkAll import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.StandardTestDispatcher @@ -22,6 +21,5 @@ class MainDispatcherRule( override fun finished(description: Description) { super.finished(description) Dispatchers.resetMain() - unmockkAll() } } diff --git a/data/build.gradle.kts b/data/build.gradle.kts index 3362d4d..65b0081 100644 --- a/data/build.gradle.kts +++ b/data/build.gradle.kts @@ -36,6 +36,9 @@ dependencies { implementation(libs.bundles.layer.data) implementation(libs.arrow.core.retrofit) + implementation(libs.hilt.testing) + implementation(libs.test.corutines) + ksp(libs.com.google.dagger.hilt.compiler) implementation(project(":domain")) diff --git a/data/src/main/kotlin/com/random/users/data/datasource/SeedPreferencesDataSource.kt b/data/src/main/kotlin/com/random/users/data/datasource/SeedPreferencesDataSource.kt index 7821f08..20b2c74 100644 --- a/data/src/main/kotlin/com/random/users/data/datasource/SeedPreferencesDataSource.kt +++ b/data/src/main/kotlin/com/random/users/data/datasource/SeedPreferencesDataSource.kt @@ -4,7 +4,6 @@ import arrow.core.Either import com.random.users.domain.models.UsersErrors import com.random.users.preferences.manager.PreferencesManager import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import javax.inject.Inject @@ -12,7 +11,7 @@ internal class SeedPreferencesDataSource @Inject constructor( private val preferencesManager: PreferencesManager, - private val dispatcher: CoroutineDispatcher = Dispatchers.IO, + private val dispatcher: CoroutineDispatcher, ) : SeedLocalDataSource { override suspend fun getSeed() = withContext(dispatcher) { diff --git a/data/src/main/kotlin/com/random/users/data/datasource/UsersApiDataSource.kt b/data/src/main/kotlin/com/random/users/data/datasource/UsersApiDataSource.kt index 54e97b0..021f7a0 100644 --- a/data/src/main/kotlin/com/random/users/data/datasource/UsersApiDataSource.kt +++ b/data/src/main/kotlin/com/random/users/data/datasource/UsersApiDataSource.kt @@ -3,7 +3,6 @@ package com.random.users.data.datasource import com.random.users.api.api.UsersApi import com.random.users.domain.models.UsersErrors import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import javax.inject.Inject @@ -11,7 +10,7 @@ internal class UsersApiDataSource @Inject constructor( private val usersApi: UsersApi, - private val dispatcher: CoroutineDispatcher = Dispatchers.IO, + private val dispatcher: CoroutineDispatcher, ) : UsersRemoteDataSource { override suspend fun getUsers( page: Int, @@ -23,6 +22,8 @@ internal class UsersApiDataSource page = page, results = results, seed = seed, - ).mapLeft { UsersErrors.NetworkError } + ).mapLeft { + UsersErrors.NetworkError + } } } diff --git a/data/src/main/kotlin/com/random/users/data/datasource/UsersDatabaseDataSource.kt b/data/src/main/kotlin/com/random/users/data/datasource/UsersDatabaseDataSource.kt index 27319ff..30c271e 100644 --- a/data/src/main/kotlin/com/random/users/data/datasource/UsersDatabaseDataSource.kt +++ b/data/src/main/kotlin/com/random/users/data/datasource/UsersDatabaseDataSource.kt @@ -5,7 +5,6 @@ import com.random.users.database.dao.UserDao import com.random.users.database.model.DeletedUserEntity import com.random.users.domain.models.UsersErrors import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import javax.inject.Inject @@ -13,7 +12,7 @@ internal class UsersDatabaseDataSource @Inject constructor( private val userDao: UserDao, - private val dispatcher: CoroutineDispatcher = Dispatchers.IO, + private val dispatcher: CoroutineDispatcher, ) : UsersLocalDataSource { override suspend fun getDeletedUsers() = withContext(dispatcher) { diff --git a/data/src/main/kotlin/com/random/users/data/di/DataModule.kt b/data/src/main/kotlin/com/random/users/data/di/DataModule.kt index a2ca9fb..b1fe97a 100644 --- a/data/src/main/kotlin/com/random/users/data/di/DataModule.kt +++ b/data/src/main/kotlin/com/random/users/data/di/DataModule.kt @@ -15,6 +15,7 @@ import dagger.Module import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent +import kotlinx.coroutines.CoroutineDispatcher import javax.inject.Singleton @Module @@ -22,16 +23,24 @@ import javax.inject.Singleton object DataModule { @Provides @Singleton - fun provideUsersRemoteDataSource(usersApi: UsersApi): UsersRemoteDataSource = UsersApiDataSource(usersApi) + fun provideUsersRemoteDataSource( + usersApi: UsersApi, + dispatcher: CoroutineDispatcher, + ): UsersRemoteDataSource = UsersApiDataSource(usersApi, dispatcher) @Provides @Singleton - fun provideUsersLocalDataSource(userDao: UserDao): UsersLocalDataSource = UsersDatabaseDataSource(userDao) + fun provideUsersLocalDataSource( + userDao: UserDao, + dispatcher: CoroutineDispatcher, + ): UsersLocalDataSource = UsersDatabaseDataSource(userDao, dispatcher) @Provides @Singleton - fun provideSeedLocalDataSource(preferencesManager: PreferencesManager): SeedLocalDataSource = - SeedPreferencesDataSource(preferencesManager) + fun provideSeedLocalDataSource( + preferencesManager: PreferencesManager, + dispatcher: CoroutineDispatcher, + ): SeedLocalDataSource = SeedPreferencesDataSource(preferencesManager, dispatcher) @Provides fun provideUsersRepository( diff --git a/data/src/main/kotlin/com/random/users/data/di/DispatchersModule.kt b/data/src/main/kotlin/com/random/users/data/di/DispatchersModule.kt new file mode 100644 index 0000000..96c3d0a --- /dev/null +++ b/data/src/main/kotlin/com/random/users/data/di/DispatchersModule.kt @@ -0,0 +1,17 @@ +package com.random.users.data.di + +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +object DispatchersModule { + @Provides + @Singleton + fun provideDispatcher(): CoroutineDispatcher = Dispatchers.IO +} diff --git a/data/src/main/kotlin/com/random/users/data/di/TestDispatchersModule.kt b/data/src/main/kotlin/com/random/users/data/di/TestDispatchersModule.kt new file mode 100644 index 0000000..93ba312 --- /dev/null +++ b/data/src/main/kotlin/com/random/users/data/di/TestDispatchersModule.kt @@ -0,0 +1,20 @@ +package com.random.users.data.di + +import dagger.Module +import dagger.Provides +import dagger.hilt.components.SingletonComponent +import dagger.hilt.testing.TestInstallIn +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.test.StandardTestDispatcher +import javax.inject.Singleton + +@Module +@TestInstallIn( + components = [SingletonComponent::class], + replaces = [DispatchersModule::class], +) +object TestDispatchersModule { + @Provides + @Singleton + fun provideDispatcher(): CoroutineDispatcher = StandardTestDispatcher() +} diff --git a/data/src/main/kotlin/com/random/users/data/repository/UsersRepositoryImpl.kt b/data/src/main/kotlin/com/random/users/data/repository/UsersRepositoryImpl.kt index 33adbc5..34da23d 100644 --- a/data/src/main/kotlin/com/random/users/data/repository/UsersRepositoryImpl.kt +++ b/data/src/main/kotlin/com/random/users/data/repository/UsersRepositoryImpl.kt @@ -29,11 +29,12 @@ internal class UsersRepositoryImpl results = results, seed = currentSeed, ).map { users -> - val newSeed = users.info.toDomain() - if (currentSeed != users.info.seed) { - seedLocalDataSource.saveSeed(newSeed) + users.results.toDomain().also { + val newSeed = users.info.toDomain() + if (currentSeed != newSeed) { + seedLocalDataSource.saveSeed(newSeed) + } } - users.results.toDomain() } } diff --git a/data/src/test/kotlin/com/random/users/data/repository/UsersRepositoryUnitTest.kt b/data/src/test/kotlin/com/random/users/data/repository/UsersRepositoryUnitTest.kt index 28f6560..2101987 100644 --- a/data/src/test/kotlin/com/random/users/data/repository/UsersRepositoryUnitTest.kt +++ b/data/src/test/kotlin/com/random/users/data/repository/UsersRepositoryUnitTest.kt @@ -33,14 +33,12 @@ class UsersRepositoryUnitTest { @Test fun `GIVEN users from remote data source WHEN getUsers THEN return users successfully`() = runBlocking { - val page = 1 - val results = 10 val mockSeed = "mock-seed" val newSeed = "new-seed" val users = listOf(UserDtoMother.createModel()) coEvery { seedLocalDataSource.getSeed() } returns mockSeed.right() - coEvery { usersRemoteDataSource.getUsers(page, results, mockSeed) } returns + coEvery { usersRemoteDataSource.getUsers(PAGE, RESULTS, mockSeed) } returns RandomUsersResponseMother .createModel( results = users, @@ -48,28 +46,26 @@ class UsersRepositoryUnitTest { ).right() coEvery { seedLocalDataSource.saveSeed(newSeed) } returns Unit.right() - val result = usersRepository.getUsers(page, results) + val result = usersRepository.getUsers(PAGE, RESULTS) Assert.assertEquals(users.toDomain().right(), result) coVerify { seedLocalDataSource.getSeed() } - coVerify { usersRemoteDataSource.getUsers(page, results, mockSeed) } + coVerify { usersRemoteDataSource.getUsers(PAGE, RESULTS, mockSeed) } coVerify { seedLocalDataSource.saveSeed(newSeed) } } @Test fun `GIVEN error from remote data source WHEN getUsers THEN return error`() = runBlocking { - val page = 1 - val results = 10 val error = UsersErrors.NetworkError coEvery { seedLocalDataSource.getSeed() } returns null.right() - coEvery { usersRemoteDataSource.getUsers(page, results, null) } returns error.left() + coEvery { usersRemoteDataSource.getUsers(PAGE, RESULTS, null) } returns error.left() - val result = usersRepository.getUsers(page, results) + val result = usersRepository.getUsers(PAGE, RESULTS) Assert.assertEquals(error.left(), result) coVerify { seedLocalDataSource.getSeed() } - coVerify { usersRemoteDataSource.getUsers(page, results, null) } + coVerify { usersRemoteDataSource.getUsers(PAGE, RESULTS, null) } coVerify(exactly = 0) { seedLocalDataSource.saveSeed(any()) } } @@ -109,4 +105,9 @@ class UsersRepositoryUnitTest { Assert.assertEquals(Unit.right(), result) coVerify { usersLocalDataSource.deleteUser(uuid) } } + + companion object { + private const val PAGE = 1 + private const val RESULTS = 15 + } } diff --git a/domain/src/test/kotlin/com/random/users/domain/usecase/GetUserListUseCaseUnitTest.kt b/domain/src/test/kotlin/com/random/users/domain/usecase/GetUserListUseCaseUnitTest.kt index 962ed5e..32a1d6a 100644 --- a/domain/src/test/kotlin/com/random/users/domain/usecase/GetUserListUseCaseUnitTest.kt +++ b/domain/src/test/kotlin/com/random/users/domain/usecase/GetUserListUseCaseUnitTest.kt @@ -34,7 +34,7 @@ class GetUserListUseCaseUnitTest { UserMother.createModel(uuid = "3"), ) val deletedUsers = listOf("2") - coEvery { usersRepository.getUsers(1, any()) } returns users.right() + coEvery { usersRepository.getUsers(PAGE, RESULTS) } returns users.right() coEvery { usersRepository.getDeletedUsers() } returns deletedUsers.right() val result = getUserListUseCase(1) @@ -46,7 +46,7 @@ class GetUserListUseCaseUnitTest { ).right(), result, ) - coVerify { usersRepository.getUsers(1, any()) } + coVerify { usersRepository.getUsers(PAGE, RESULTS) } coVerify { usersRepository.getDeletedUsers() } } @@ -59,20 +59,13 @@ class GetUserListUseCaseUnitTest { UserMother.createModel(uuid = "2"), UserMother.createModel(uuid = "3"), ) - coEvery { usersRepository.getUsers(1, any()) } returns users.right() + coEvery { usersRepository.getUsers(PAGE, RESULTS) } returns users.right() coEvery { usersRepository.getDeletedUsers() } returns emptyList().right() val result = getUserListUseCase(1) - Assert.assertEquals( - listOf( - UserMother.createModel(uuid = "1"), - UserMother.createModel(uuid = "2"), - UserMother.createModel(uuid = "3"), - ).right(), - result, - ) - coVerify { usersRepository.getUsers(1, any()) } + Assert.assertEquals(users.right(), result) + coVerify { usersRepository.getUsers(PAGE, RESULTS) } coVerify { usersRepository.getDeletedUsers() } } @@ -86,13 +79,13 @@ class GetUserListUseCaseUnitTest { UserMother.createModel(uuid = "3"), ) val deletedUsers = listOf("1", "2", "3") - coEvery { usersRepository.getUsers(1, any()) } returns users.right() + coEvery { usersRepository.getUsers(PAGE, RESULTS) } returns users.right() coEvery { usersRepository.getDeletedUsers() } returns deletedUsers.right() val result = getUserListUseCase(1) Assert.assertEquals(emptyList().right(), result) - coVerify { usersRepository.getUsers(1, any()) } + coVerify { usersRepository.getUsers(PAGE, RESULTS) } coVerify { usersRepository.getDeletedUsers() } } @@ -100,12 +93,12 @@ class GetUserListUseCaseUnitTest { fun `GIVEN left in getUsers WHEN getUserListUseCase THEN returned left`() = runBlocking { val error = UsersErrors.NetworkError - coEvery { usersRepository.getUsers(1, any()) } returns error.left() + coEvery { usersRepository.getUsers(PAGE, RESULTS) } returns error.left() - val result = getUserListUseCase(1) + val result = getUserListUseCase(PAGE) Assert.assertEquals(error.left(), result) - coVerify { usersRepository.getUsers(any(), any()) } + coVerify { usersRepository.getUsers(PAGE, RESULTS) } coVerify(exactly = 0) { usersRepository.getDeletedUsers() } } @@ -118,20 +111,18 @@ class GetUserListUseCaseUnitTest { UserMother.createModel(uuid = "2"), UserMother.createModel(uuid = "3"), ) - coEvery { usersRepository.getUsers(1, any()) } returns users.right() + coEvery { usersRepository.getUsers(PAGE, RESULTS) } returns users.right() coEvery { usersRepository.getDeletedUsers() } returns UsersErrors.UserError.left() - val result = getUserListUseCase(1) + val result = getUserListUseCase(PAGE) - Assert.assertEquals( - listOf( - UserMother.createModel(uuid = "1"), - UserMother.createModel(uuid = "2"), - UserMother.createModel(uuid = "3"), - ).right(), - result, - ) - coVerify { usersRepository.getUsers(any(), any()) } + Assert.assertEquals(users.right(), result) + coVerify { usersRepository.getUsers(PAGE, RESULTS) } coVerify { usersRepository.getDeletedUsers() } } + + companion object { + private const val PAGE = 1 + private const val RESULTS = 15 + } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 6966e8e..2d284a2 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -54,6 +54,7 @@ coil-compose = { group = "io.coil-kt.coil3", name = "coil-compose", version.ref coil-okhttp = { group = "io.coil-kt.coil3", name = "coil-network-okhttp", version.ref = "coil" } room-ktx = { module = "androidx.room:room-ktx", version.ref = "room" } room-compiler = { module = "androidx.room:room-compiler", version.ref = "room" } +room-testing = { module = "androidx.room:room-testing", version.ref = "room" } androidx-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling", version.ref = "uiTooling" } test-junit4 = { module = "androidx.compose.ui:ui-test-junit4", version.ref = "composeJunit" } test-manifest = { module = "androidx.compose.ui:ui-test-manifest", version.ref = "composeJunit" } diff --git a/presentation/users/build.gradle.kts b/presentation/users/build.gradle.kts index 28e679c..c686ab0 100644 --- a/presentation/users/build.gradle.kts +++ b/presentation/users/build.gradle.kts @@ -61,8 +61,8 @@ dependencies { implementation(project(":core:presentation")) implementation(project(":domain")) - implementation(project(":core:test")) testImplementation(project(":core:test")) + testImplementation(project(":data")) testImplementation(libs.bundles.test.unit) testImplementation(libs.bundles.test.compose) } diff --git a/presentation/users/screenshots/com.random.users.users.screenshot.UserDetailScreenTest.GIVEN user model WHEN load screen THEN correct user info shown.png b/presentation/users/screenshots/com.random.users.users.screenshot.UserDetailScreenTest.GIVEN user model WHEN load screen THEN correct user info shown.png deleted file mode 100644 index 6aa46ec..0000000 Binary files a/presentation/users/screenshots/com.random.users.users.screenshot.UserDetailScreenTest.GIVEN user model WHEN load screen THEN correct user info shown.png and /dev/null differ diff --git a/presentation/users/screenshots/com.random.users.users.screenshot.UserDetailScreenshotTest.GIVEN user model WHEN load screen THEN correct user info shown.png b/presentation/users/screenshots/com.random.users.users.screenshot.UserDetailScreenshotTest.GIVEN user model WHEN load screen THEN correct user info shown.png new file mode 100644 index 0000000..b0b083c Binary files /dev/null and b/presentation/users/screenshots/com.random.users.users.screenshot.UserDetailScreenshotTest.GIVEN user model WHEN load screen THEN correct user info shown.png differ diff --git a/presentation/users/screenshots/com.random.users.users.screenshot.UsersScreenTest.GIVEN getUsersListUseCase returns users WHEN load screen THEN idle state with data.png b/presentation/users/screenshots/com.random.users.users.screenshot.UsersScreenTest.GIVEN getUsersListUseCase returns users WHEN load screen THEN idle state with data.png deleted file mode 100644 index 85d5588..0000000 Binary files a/presentation/users/screenshots/com.random.users.users.screenshot.UsersScreenTest.GIVEN getUsersListUseCase returns users WHEN load screen THEN idle state with data.png and /dev/null differ diff --git a/presentation/users/screenshots/com.random.users.users.screenshot.UsersScreenTest.GIVEN loaded screen WHEN apply text on filter THEN users get filtered.png b/presentation/users/screenshots/com.random.users.users.screenshot.UsersScreenTest.GIVEN loaded screen WHEN apply text on filter THEN users get filtered.png deleted file mode 100644 index 4344a9c..0000000 Binary files a/presentation/users/screenshots/com.random.users.users.screenshot.UsersScreenTest.GIVEN loaded screen WHEN apply text on filter THEN users get filtered.png and /dev/null differ diff --git a/presentation/users/screenshots/com.random.users.users.screenshot.UsersScreenTest.GIVEN loaded screen WHEN delete first user THEN idle state without deleted user.png b/presentation/users/screenshots/com.random.users.users.screenshot.UsersScreenTest.GIVEN loaded screen WHEN delete first user THEN idle state without deleted user.png deleted file mode 100644 index be781cd..0000000 Binary files a/presentation/users/screenshots/com.random.users.users.screenshot.UsersScreenTest.GIVEN loaded screen WHEN delete first user THEN idle state without deleted user.png and /dev/null differ diff --git a/presentation/users/screenshots/com.random.users.users.screenshot.UsersScreenTest.GIVEN loaded screen WHEN scrolling THEN idle state with new data.png b/presentation/users/screenshots/com.random.users.users.screenshot.UsersScreenTest.GIVEN loaded screen WHEN scrolling THEN idle state with new data.png deleted file mode 100644 index bbd670c..0000000 Binary files a/presentation/users/screenshots/com.random.users.users.screenshot.UsersScreenTest.GIVEN loaded screen WHEN scrolling THEN idle state with new data.png and /dev/null differ diff --git a/presentation/users/screenshots/com.random.users.users.screenshot.UsersScreenshotTest.GIVEN error in loading users WHEN load screen THEN error state.png b/presentation/users/screenshots/com.random.users.users.screenshot.UsersScreenshotTest.GIVEN error in loading users WHEN load screen THEN error state.png new file mode 100644 index 0000000..08dd36c Binary files /dev/null and b/presentation/users/screenshots/com.random.users.users.screenshot.UsersScreenshotTest.GIVEN error in loading users WHEN load screen THEN error state.png differ diff --git a/presentation/users/screenshots/com.random.users.users.screenshot.UsersScreenshotTest.GIVEN getUsersListUseCase returns users WHEN delete first user THEN idle state without deleted user.png b/presentation/users/screenshots/com.random.users.users.screenshot.UsersScreenshotTest.GIVEN getUsersListUseCase returns users WHEN delete first user THEN idle state without deleted user.png new file mode 100644 index 0000000..12b33f5 Binary files /dev/null and b/presentation/users/screenshots/com.random.users.users.screenshot.UsersScreenshotTest.GIVEN getUsersListUseCase returns users WHEN delete first user THEN idle state without deleted user.png differ diff --git a/presentation/users/screenshots/com.random.users.users.screenshot.UsersScreenshotTest.GIVEN getUsersListUseCase returns users WHEN load screen THEN idle state with data.png b/presentation/users/screenshots/com.random.users.users.screenshot.UsersScreenshotTest.GIVEN getUsersListUseCase returns users WHEN load screen THEN idle state with data.png new file mode 100644 index 0000000..fe164db Binary files /dev/null and b/presentation/users/screenshots/com.random.users.users.screenshot.UsersScreenshotTest.GIVEN getUsersListUseCase returns users WHEN load screen THEN idle state with data.png differ diff --git a/presentation/users/screenshots/com.random.users.users.screenshot.UsersScreenshotTest.GIVEN loaded screen WHEN apply text on filter THEN users get filtered.png b/presentation/users/screenshots/com.random.users.users.screenshot.UsersScreenshotTest.GIVEN loaded screen WHEN apply text on filter THEN users get filtered.png new file mode 100644 index 0000000..8e973bd Binary files /dev/null and b/presentation/users/screenshots/com.random.users.users.screenshot.UsersScreenshotTest.GIVEN loaded screen WHEN apply text on filter THEN users get filtered.png differ diff --git a/presentation/users/src/main/kotlin/com/random/users/users/composable/UserCard.kt b/presentation/users/src/main/kotlin/com/random/users/users/composable/UserCard.kt index 28da540..25ef00f 100644 --- a/presentation/users/src/main/kotlin/com/random/users/users/composable/UserCard.kt +++ b/presentation/users/src/main/kotlin/com/random/users/users/composable/UserCard.kt @@ -33,10 +33,6 @@ import coil3.request.ImageRequest import coil3.request.crossfade import com.random.user.presentation.ui.theme.RandomUsersTheme import com.random.users.users.contract.UserUiState -import com.random.users.users.model.UserLocationUiModel -import com.random.users.users.model.UserNameUiModel -import com.random.users.users.model.UserPictureUiModel -import com.random.users.users.model.UserStreetUiModel import com.random.users.users.model.UserUiModel @Composable @@ -120,41 +116,30 @@ internal fun UserCard( @PreviewLightDark @Composable -private fun UserCardPreview() { +private fun UserCardIdlePreview() { RandomUsersTheme { UserCard( user = UserUiState( - user = - UserUiModel( - uuid = "550e8400-e29b-41d4-a716-446655440000", - name = - UserNameUiModel( - first = "María", - last = "García", - ), - location = - UserLocationUiModel( - street = - UserStreetUiModel( - number = 123, - name = "Calle Mayor", - ), - city = "Madrid", - state = "Madrid", - ), - email = "maria.garcia@example.com", - phone = "+34 612 345 678", - gender = "female", - picture = - UserPictureUiModel( - medium = "https://randomuser.me/api/portraits/women/42.jpg", - thumbnail = "https://randomuser.me/api/portraits/thumb/women/42.jpg", - ), - ), + user = UserUiModel.toPreviewData(), userState = UserUiState.ContentState.Idle, ), onDeleteUser = {}, ) } } + +@PreviewLightDark +@Composable +private fun UserCardDeletingPreview() { + RandomUsersTheme { + UserCard( + user = + UserUiState( + user = UserUiModel.toPreviewData(), + userState = UserUiState.ContentState.Deleting, + ), + onDeleteUser = {}, + ) + } +} diff --git a/presentation/users/src/main/kotlin/com/random/users/users/composable/UserList.kt b/presentation/users/src/main/kotlin/com/random/users/users/composable/UserList.kt index f4a9c92..47b3f69 100644 --- a/presentation/users/src/main/kotlin/com/random/users/users/composable/UserList.kt +++ b/presentation/users/src/main/kotlin/com/random/users/users/composable/UserList.kt @@ -5,13 +5,22 @@ import androidx.compose.foundation.gestures.snapping.rememberSnapFlingBehavior import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material3.Button import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.derivedStateOf @@ -21,15 +30,12 @@ import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.testTag +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.PreviewLightDark import androidx.compose.ui.unit.dp import com.random.user.presentation.ui.theme.RandomUsersTheme import com.random.users.users.contract.UserUiState import com.random.users.users.contract.UsersScreenUiState -import com.random.users.users.model.UserLocationUiModel -import com.random.users.users.model.UserNameUiModel -import com.random.users.users.model.UserPictureUiModel -import com.random.users.users.model.UserStreetUiModel import com.random.users.users.model.UserUiModel import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.filter @@ -89,10 +95,20 @@ internal fun UserList( onDeleteUser = { uuid -> onDeleteUser(uuid) }, ) } - if (state.contentState is UsersScreenUiState.ContentState.Loading) { - item(key = "loading") { - LoadingItem() + when (state.contentState) { + is UsersScreenUiState.ContentState.Loading -> { + item(key = "loading") { + LoadingItem() + } } + is UsersScreenUiState.ContentState.Error -> { + item(key = "retry") { + RetryItem( + onRetry = onLoadUsers, + ) + } + } + else -> {} } } } @@ -109,6 +125,39 @@ private fun LoadingItem(modifier: Modifier = Modifier) { } } +@Composable +private fun RetryItem( + modifier: Modifier = Modifier, + onRetry: () -> Unit = {}, +) { + Box( + modifier = + modifier + .fillMaxWidth() + .padding(vertical = 8.dp), + contentAlignment = Alignment.Center, + ) { + Button( + onClick = onRetry, + shape = RoundedCornerShape(8.dp), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = Icons.Default.Refresh, + contentDescription = null, + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = "Retry", + fontWeight = FontWeight.Bold, + ) + } + } + } +} + private fun LazyListState.reachedBottom(buffer: Int = 1): Boolean { if (layoutInfo.visibleItemsInfo.isEmpty()) return true val lastVisibleItem = layoutInfo.visibleItemsInfo.last() @@ -117,7 +166,7 @@ private fun LazyListState.reachedBottom(buffer: Int = 1): Boolean { @PreviewLightDark @Composable -fun UserListPreview() { +private fun UserListLoadingPreview() { RandomUsersTheme { UserList( state = @@ -125,64 +174,12 @@ fun UserListPreview() { users = listOf( UserUiState( - user = - UserUiModel( - uuid = "550e8400-e29b-41d4-a716-446655440000", - name = - UserNameUiModel( - first = "María", - last = "García", - ), - location = - UserLocationUiModel( - street = - UserStreetUiModel( - number = 123, - name = "Calle Mayor", - ), - city = "Madrid", - state = "Madrid", - ), - email = "maria.garcia@example.com", - phone = "+34 612 345 678", - gender = "female", - picture = - UserPictureUiModel( - medium = "https://randomuser.me/api/portraits/women/42.jpg", - thumbnail = "https://randomuser.me/api/portraits/thumb/women/42.jpg", - ), - ), + user = UserUiModel.toPreviewData().copy(uuid = "1"), userState = UserUiState.ContentState.Idle, ), UserUiState( - user = - UserUiModel( - uuid = "550e8400-e29b-41d4-a716-446655440001", - name = - UserNameUiModel( - first = "Alejandro", - last = "Rodríguez", - ), - location = - UserLocationUiModel( - street = - UserStreetUiModel( - number = 47, - name = "Avenida Diagonal", - ), - city = "Barcelona", - state = "Cataluña", - ), - email = "alejandro.rodriguez@example.com", - phone = "+34 633 456 789", - gender = "male", - picture = - UserPictureUiModel( - medium = "https://randomuser.me/api/portraits/men/29.jpg", - thumbnail = "https://randomuser.me/api/portraits/thumb/men/29.jpg", - ), - ), - userState = UserUiState.ContentState.Deleting, + user = UserUiModel.toPreviewData().copy(uuid = "2"), + userState = UserUiState.ContentState.Idle, ), ), filterText = "", @@ -193,3 +190,30 @@ fun UserListPreview() { ) } } + +@PreviewLightDark +@Composable +private fun UserListErrorPreview() { + RandomUsersTheme { + UserList( + state = + UsersScreenUiState( + users = + listOf( + UserUiState( + user = UserUiModel.toPreviewData().copy(uuid = "1"), + userState = UserUiState.ContentState.Idle, + ), + UserUiState( + user = UserUiModel.toPreviewData().copy(uuid = "2"), + userState = UserUiState.ContentState.Idle, + ), + ), + filterText = "", + contentState = UsersScreenUiState.ContentState.Error, + ), + onDeleteUser = {}, + onLoadUsers = {}, + ) + } +} diff --git a/presentation/users/src/main/kotlin/com/random/users/users/contract/UsersScreenContract.kt b/presentation/users/src/main/kotlin/com/random/users/users/contract/UsersScreenContract.kt index 4ae6f7e..33651bd 100644 --- a/presentation/users/src/main/kotlin/com/random/users/users/contract/UsersScreenContract.kt +++ b/presentation/users/src/main/kotlin/com/random/users/users/contract/UsersScreenContract.kt @@ -15,6 +15,8 @@ internal data class UsersScreenUiState( data object Filtered : ContentState data object Loading : ContentState + + data object Error : ContentState } } @@ -43,8 +45,6 @@ internal sealed interface UsersEvent { } internal sealed class UsersErrorUiEventsState { - data object Idle : UsersErrorUiEventsState() - data object DeleteError : UsersErrorUiEventsState() data object LoadUsersError : UsersErrorUiEventsState() diff --git a/presentation/users/src/main/kotlin/com/random/users/users/model/UserUiModel.kt b/presentation/users/src/main/kotlin/com/random/users/users/model/UserUiModel.kt index f2fdeb3..d50d00a 100644 --- a/presentation/users/src/main/kotlin/com/random/users/users/model/UserUiModel.kt +++ b/presentation/users/src/main/kotlin/com/random/users/users/model/UserUiModel.kt @@ -13,7 +13,37 @@ data class UserUiModel( val phone: String, val gender: String, val picture: UserPictureUiModel, -) +) { + companion object { + fun toPreviewData() = + UserUiModel( + uuid = "550e8400-e29b-41d4-a716-446655440000", + name = + UserNameUiModel( + first = "María", + last = "García", + ), + location = + UserLocationUiModel( + street = + UserStreetUiModel( + number = 123, + name = "Calle Mayor", + ), + city = "Madrid", + state = "Madrid", + ), + email = "maria.garcia@example.com", + phone = "+34 612 345 678", + gender = "female", + picture = + UserPictureUiModel( + medium = "https://randomuser.me/api/portraits/women/42.jpg", + thumbnail = "https://randomuser.me/api/portraits/thumb/women/42.jpg", + ), + ) + } +} @Serializable @Immutable diff --git a/presentation/users/src/main/kotlin/com/random/users/users/navigation/UsersRoute.kt b/presentation/users/src/main/kotlin/com/random/users/users/navigation/UsersRoute.kt index aed1435..423899f 100644 --- a/presentation/users/src/main/kotlin/com/random/users/users/navigation/UsersRoute.kt +++ b/presentation/users/src/main/kotlin/com/random/users/users/navigation/UsersRoute.kt @@ -3,7 +3,7 @@ package com.random.users.users.navigation import com.random.users.users.model.UserUiModel import kotlinx.serialization.Serializable -sealed class UsersRoute { +internal sealed class UsersRoute { @Serializable data object Home : UsersRoute() diff --git a/presentation/users/src/main/kotlin/com/random/users/users/screen/UsersScreen.kt b/presentation/users/src/main/kotlin/com/random/users/users/screen/UsersScreen.kt index bfa474e..194d3f4 100644 --- a/presentation/users/src/main/kotlin/com/random/users/users/screen/UsersScreen.kt +++ b/presentation/users/src/main/kotlin/com/random/users/users/screen/UsersScreen.kt @@ -1,18 +1,23 @@ package com.random.users.users.screen +import android.content.Context import android.widget.Toast import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.material3.Scaffold import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.tooling.preview.PreviewLightDark import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.repeatOnLifecycle import androidx.navigation.NavHostController import androidx.navigation.compose.rememberNavController import com.random.user.presentation.ui.theme.RandomUsersTheme @@ -22,13 +27,10 @@ import com.random.users.users.contract.UserUiState import com.random.users.users.contract.UsersErrorUiEventsState import com.random.users.users.contract.UsersEvent import com.random.users.users.contract.UsersScreenUiState -import com.random.users.users.model.UserLocationUiModel -import com.random.users.users.model.UserNameUiModel -import com.random.users.users.model.UserPictureUiModel -import com.random.users.users.model.UserStreetUiModel import com.random.users.users.model.UserUiModel import com.random.users.users.navigation.UsersRoute import com.random.users.users.viewmodel.UsersViewModel +import kotlinx.coroutines.flow.Flow @Composable internal fun UsersScreen( @@ -36,7 +38,8 @@ internal fun UsersScreen( navController: NavHostController = rememberNavController(), ) { val state by viewModel.uiState.collectAsStateWithLifecycle() - val eventsState by viewModel.uiEventsState.collectAsStateWithLifecycle(UsersErrorUiEventsState.Idle) + + HandleOneTimeEvents(viewModel.uiEventsState) Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding -> UsersContent( @@ -48,22 +51,6 @@ internal fun UsersScreen( onUserClick = { navController.navigate(UsersRoute.UserDetail(user = it)) }, ) } - ProcessError(eventsState) -} - -@Composable -private fun ProcessError(state: UsersErrorUiEventsState) { - when (state) { - is UsersErrorUiEventsState.DeleteError -> { - Toast.makeText(LocalContext.current, "Error deleting user", Toast.LENGTH_SHORT).show() - } - - is UsersErrorUiEventsState.LoadUsersError -> { - Toast.makeText(LocalContext.current, "Error loading users", Toast.LENGTH_SHORT).show() - } - - else -> {} - } } @Composable @@ -93,6 +80,38 @@ private fun UsersContent( } } +@Composable +private fun HandleOneTimeEvents(uiEventsState: Flow) { + val lifecycle = LocalLifecycleOwner.current.lifecycle + val context = LocalContext.current + LaunchedEffect(uiEventsState) { + lifecycle.repeatOnLifecycle(state = Lifecycle.State.STARTED) { + uiEventsState.collect { event -> + showError(state = event, context = context) + } + } + } +} + +private fun showError( + state: UsersErrorUiEventsState, + context: Context, +) { + when (state) { + is UsersErrorUiEventsState.DeleteError -> { + Toast.makeText(context, "Error deleting user", Toast.LENGTH_SHORT).show() + } + + is UsersErrorUiEventsState.LoadUsersError -> { + Toast.makeText(context, "Error loading users", Toast.LENGTH_SHORT).show() + } + + else -> { + Toast.makeText(context, "Something went wrong", Toast.LENGTH_SHORT).show() + } + } +} + @PreviewLightDark @Composable private fun UsersScreenPreview() { @@ -103,63 +122,11 @@ private fun UsersScreenPreview() { users = listOf( UserUiState( - user = - UserUiModel( - uuid = "550e8400-e29b-41d4-a716-446655440000", - name = - UserNameUiModel( - first = "María", - last = "García", - ), - location = - UserLocationUiModel( - street = - UserStreetUiModel( - number = 123, - name = "Calle Mayor", - ), - city = "Madrid", - state = "Madrid", - ), - email = "maria.garcia@example.com", - phone = "+34 612 345 678", - gender = "female", - picture = - UserPictureUiModel( - medium = "https://randomuser.me/api/portraits/women/42.jpg", - thumbnail = "https://randomuser.me/api/portraits/thumb/women/42.jpg", - ), - ), + user = UserUiModel.toPreviewData(), userState = UserUiState.ContentState.Idle, ), UserUiState( - user = - UserUiModel( - uuid = "550e8400-e29b-41d4-a716-446655440001", - name = - UserNameUiModel( - first = "Alejandro", - last = "Rodríguez", - ), - location = - UserLocationUiModel( - street = - UserStreetUiModel( - number = 47, - name = "Avenida Diagonal", - ), - city = "Barcelona", - state = "Cataluña", - ), - email = "alejandro.rodriguez@example.com", - phone = "+34 633 456 789", - gender = "male", - picture = - UserPictureUiModel( - medium = "https://randomuser.me/api/portraits/men/29.jpg", - thumbnail = "https://randomuser.me/api/portraits/thumb/men/29.jpg", - ), - ), + user = UserUiModel.toPreviewData().copy(uuid = "2"), userState = UserUiState.ContentState.Deleting, ), ), diff --git a/presentation/users/src/main/kotlin/com/random/users/users/viewmodel/UsersViewModel.kt b/presentation/users/src/main/kotlin/com/random/users/users/viewmodel/UsersViewModel.kt index 8a98654..3dff250 100644 --- a/presentation/users/src/main/kotlin/com/random/users/users/viewmodel/UsersViewModel.kt +++ b/presentation/users/src/main/kotlin/com/random/users/users/viewmodel/UsersViewModel.kt @@ -11,11 +11,11 @@ import com.random.users.users.contract.UsersScreenUiState import com.random.users.users.mapper.UsersErrorsMapper.toUiError import com.random.users.users.mapper.toUiState import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import javax.inject.Inject @@ -32,8 +32,8 @@ internal class UsersViewModel UsersScreenUiState(), ) val uiState: StateFlow = _uiState - private val _uiEventsState = MutableSharedFlow() - val uiEventsState: SharedFlow = _uiEventsState.asSharedFlow() + private val _uiEventsState = Channel(capacity = Channel.CONFLATED) + val uiEventsState: Flow = _uiEventsState.receiveAsFlow() private var currentPage: Int = 0 private var userList: List = emptyList() @@ -62,9 +62,9 @@ internal class UsersViewModel getUserListUseCase(page = currentPage) .fold( ifLeft = { error -> - _uiEventsState.emit(error.toUiError()) + _uiEventsState.send(error.toUiError()) _uiState.update { state -> - state.copy(contentState = UsersScreenUiState.ContentState.Idle) + state.copy(contentState = UsersScreenUiState.ContentState.Error) } }, ifRight = { newUsers -> @@ -86,7 +86,7 @@ internal class UsersViewModel deleteUserUseCase(uuid = uuid) .fold( ifLeft = { error -> - _uiEventsState.emit(error.toUiError()) + _uiEventsState.send(error.toUiError()) userList = userList.updateUser(uuid) { user -> user.copy(userState = UserUiState.ContentState.Idle) @@ -146,6 +146,10 @@ internal class UsersViewModel updateUser: (UserUiState) -> UserUiState, ): List = map { user -> - if (user.user.uuid == uuid) updateUser(user) else user + if (user.user.uuid == uuid) { + updateUser(user) + } else { + user + } } } diff --git a/presentation/users/src/test/kotlin/com/random/users/users/mother/UserMother.kt b/presentation/users/src/test/kotlin/com/random/users/users/mother/UserMother.kt index 1febc14..7be98bc 100644 --- a/presentation/users/src/test/kotlin/com/random/users/users/mother/UserMother.kt +++ b/presentation/users/src/test/kotlin/com/random/users/users/mother/UserMother.kt @@ -6,7 +6,7 @@ import com.random.users.domain.models.UserName import com.random.users.domain.models.UserPicture import com.random.users.domain.models.UserStreet -object UserMother { +internal object UserMother { fun createModel( uuid: String = "mock-uuid", name: UserName = createUserName(), diff --git a/presentation/users/src/test/kotlin/com/random/users/users/screenshot/UserDetailScreenTest.kt b/presentation/users/src/test/kotlin/com/random/users/users/screenshot/UserDetailScreenshotTest.kt similarity index 79% rename from presentation/users/src/test/kotlin/com/random/users/users/screenshot/UserDetailScreenTest.kt rename to presentation/users/src/test/kotlin/com/random/users/users/screenshot/UserDetailScreenshotTest.kt index 5a43e7f..35ef5f4 100644 --- a/presentation/users/src/test/kotlin/com/random/users/users/screenshot/UserDetailScreenTest.kt +++ b/presentation/users/src/test/kotlin/com/random/users/users/screenshot/UserDetailScreenshotTest.kt @@ -8,20 +8,15 @@ import androidx.test.ext.junit.runners.AndroidJUnit4 import com.github.takahirom.roborazzi.RobolectricDeviceQualifiers import com.github.takahirom.roborazzi.RoborazziRule import com.github.takahirom.roborazzi.captureRoboImage +import com.random.users.test.rules.MainDispatcherRule import com.random.users.test.rules.createRoborazziRule import com.random.users.test.rules.createScreenshotTestComposeRule import com.random.users.users.mapper.toUiModel import com.random.users.users.mother.UserMother import com.random.users.users.screen.UserDetailScreen -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.advanceUntilIdle -import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.runTest -import kotlinx.coroutines.test.setMain -import org.junit.After -import org.junit.Before import org.junit.Rule import org.junit.runner.RunWith import org.robolectric.annotation.Config @@ -31,8 +26,14 @@ import kotlin.test.Test @ExperimentalCoroutinesApi @RunWith(AndroidJUnit4::class) @GraphicsMode(GraphicsMode.Mode.NATIVE) -@Config(qualifiers = RobolectricDeviceQualifiers.Pixel4a) -internal class UserDetailScreenTest { +@Config( + qualifiers = RobolectricDeviceQualifiers.Pixel7, + sdk = [34], +) +internal class UserDetailScreenshotTest { + @get:Rule + val instantRule = InstantTaskExecutorRule() + @get:Rule val composeTestRule = createScreenshotTestComposeRule() @@ -41,17 +42,7 @@ internal class UserDetailScreenTest { createRoborazziRule(composeTestRule = composeTestRule, captureType = RoborazziRule.CaptureType.None) @get:Rule - val instantRule = InstantTaskExecutorRule() - - @Before - fun setup() { - Dispatchers.setMain(StandardTestDispatcher()) - } - - @After - fun tearDown() { - Dispatchers.resetMain() - } + val mainDispatcherRule = MainDispatcherRule() @Test fun `GIVEN user model WHEN load screen THEN correct user info shown`() = diff --git a/presentation/users/src/test/kotlin/com/random/users/users/screenshot/UsersScreenTest.kt b/presentation/users/src/test/kotlin/com/random/users/users/screenshot/UsersScreenshotTest.kt similarity index 63% rename from presentation/users/src/test/kotlin/com/random/users/users/screenshot/UsersScreenTest.kt rename to presentation/users/src/test/kotlin/com/random/users/users/screenshot/UsersScreenshotTest.kt index ba55c2d..3047f3b 100644 --- a/presentation/users/src/test/kotlin/com/random/users/users/screenshot/UsersScreenTest.kt +++ b/presentation/users/src/test/kotlin/com/random/users/users/screenshot/UsersScreenshotTest.kt @@ -7,15 +7,16 @@ import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onRoot import androidx.compose.ui.test.performTextInput import androidx.compose.ui.test.performTouchInput -import androidx.compose.ui.test.swipeUp import androidx.test.ext.junit.runners.AndroidJUnit4 +import arrow.core.left import arrow.core.right import com.github.takahirom.roborazzi.RobolectricDeviceQualifiers import com.github.takahirom.roborazzi.RoborazziRule import com.github.takahirom.roborazzi.captureRoboImage -import com.random.users.domain.models.UserName +import com.random.users.domain.models.UsersErrors import com.random.users.domain.usecase.DeleteUserUseCase import com.random.users.domain.usecase.GetUserListUseCase +import com.random.users.test.rules.MainDispatcherRule import com.random.users.test.rules.createRoborazziRule import com.random.users.test.rules.createScreenshotTestComposeRule import com.random.users.users.mother.UserMother @@ -23,16 +24,12 @@ import com.random.users.users.screen.UsersScreen import com.random.users.users.viewmodel.UsersViewModel import io.mockk.coEvery import io.mockk.mockk -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.advanceUntilIdle -import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.runTest -import kotlinx.coroutines.test.setMain -import org.junit.After import org.junit.Before import org.junit.Rule +import org.junit.rules.TestRule import org.junit.runner.RunWith import org.robolectric.annotation.Config import org.robolectric.annotation.GraphicsMode @@ -41,32 +38,31 @@ import kotlin.test.Test @ExperimentalCoroutinesApi @RunWith(AndroidJUnit4::class) @GraphicsMode(GraphicsMode.Mode.NATIVE) -@Config(qualifiers = RobolectricDeviceQualifiers.Pixel4a) -internal class UsersScreenTest { - private val getUsersListUseCase: GetUserListUseCase = mockk() - private val deleteUserUseCase: DeleteUserUseCase = mockk() - private val viewModel: UsersViewModel by lazy { - UsersViewModel(getUsersListUseCase, deleteUserUseCase) - } - - @get:Rule +@Config( + qualifiers = RobolectricDeviceQualifiers.Pixel7, + sdk = [34], +) +internal class UsersScreenshotTest { + @get:Rule(order = 1) + var instantRule: TestRule = InstantTaskExecutorRule() + + @get:Rule(order = 2) + var mainRule: TestRule = MainDispatcherRule() + + @get:Rule(order = 3) val composeTestRule = createScreenshotTestComposeRule() - @get:Rule + @get:Rule(order = 4) val roborazziRule = createRoborazziRule(composeTestRule = composeTestRule, captureType = RoborazziRule.CaptureType.None) - @get:Rule - val instantRule = InstantTaskExecutorRule() + private val getUsersListUseCase: GetUserListUseCase = mockk() + private val deleteUserUseCase: DeleteUserUseCase = mockk() + lateinit var viewModel: UsersViewModel @Before fun setup() { - Dispatchers.setMain(StandardTestDispatcher()) - } - - @After - fun tearDown() { - Dispatchers.resetMain() + viewModel = UsersViewModel(getUsersListUseCase, deleteUserUseCase) } @Test @@ -92,7 +88,7 @@ internal class UsersScreenTest { } @Test - fun `GIVEN loaded screen WHEN delete first user THEN idle state without deleted user`() = + fun `GIVEN getUsersListUseCase returns users WHEN delete first user THEN idle state without deleted user`() = runTest { coEvery { getUsersListUseCase(any()) } returns listOf( @@ -120,49 +116,6 @@ internal class UsersScreenTest { composeTestRule.onRoot().captureRoboImage() } - @Test - fun `GIVEN loaded screen WHEN scrolling THEN idle state with new data`() = - runTest { - coEvery { getUsersListUseCase(any()) } returnsMany - listOf( - listOf( - UserMother.createModel(uuid = "1"), - UserMother.createModel(uuid = "2"), - UserMother.createModel(uuid = "3"), - UserMother.createModel(uuid = "4"), - UserMother.createModel(uuid = "5"), - UserMother.createModel(uuid = "6"), - UserMother.createModel(uuid = "7"), - UserMother.createModel(uuid = "8"), - UserMother.createModel(uuid = "9"), - ).right(), - listOf( - UserMother.createModel(uuid = "16", name = UserName(first = "Paco", last = "Doe")), - UserMother.createModel(uuid = "17", name = UserName(first = "Paco", last = "Doe")), - UserMother.createModel(uuid = "18", name = UserName(first = "Paco", last = "Doe")), - UserMother.createModel(uuid = "19", name = UserName(first = "Paco", last = "Doe")), - UserMother.createModel(uuid = "20", name = UserName(first = "Paco", last = "Doe")), - UserMother.createModel(uuid = "21", name = UserName(first = "Paco", last = "Doe")), - UserMother.createModel(uuid = "22", name = UserName(first = "Paco", last = "Doe")), - UserMother.createModel(uuid = "23", name = UserName(first = "Paco", last = "Doe")), - UserMother.createModel(uuid = "24", name = UserName(first = "Paco", last = "Doe")), - ).right(), - ) - - coEvery { deleteUserUseCase(any()) } returns Unit.right() - - renderScreen() - advanceUntilIdle() - composeTestRule - .onNodeWithTag("userList") - .performTouchInput { - swipeUp() - } - advanceUntilIdle() - - composeTestRule.onRoot().captureRoboImage() - } - @Test fun `GIVEN loaded screen WHEN apply text on filter THEN users get filtered`() = runTest { @@ -189,6 +142,18 @@ internal class UsersScreenTest { composeTestRule.onRoot().captureRoboImage() } + @Test + fun `GIVEN error in loading users WHEN load screen THEN error state`() = + runTest { + coEvery { getUsersListUseCase(any()) } returns + UsersErrors.NetworkError.left() + + renderScreen() + advanceUntilIdle() + + composeTestRule.onRoot().captureRoboImage() + } + private fun renderScreen() { composeTestRule.setContent { UsersScreen(viewModel) diff --git a/presentation/users/src/test/kotlin/com/random/users/users/viewmodel/UsersViewModelIntegrationTest.kt b/presentation/users/src/test/kotlin/com/random/users/users/viewmodel/UsersViewModelIntegrationTest.kt new file mode 100644 index 0000000..333fa6b --- /dev/null +++ b/presentation/users/src/test/kotlin/com/random/users/users/viewmodel/UsersViewModelIntegrationTest.kt @@ -0,0 +1,153 @@ +package com.random.users.users.viewmodel + +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import app.cash.turbine.test +import com.random.users.domain.usecase.DeleteUserUseCase +import com.random.users.domain.usecase.GetUserListUseCase +import com.random.users.test.model.getUserListResponsePage1Json +import com.random.users.test.rules.MainDispatcherRule +import com.random.users.users.contract.UsersErrorUiEventsState +import com.random.users.users.contract.UsersEvent +import com.random.users.users.contract.UsersScreenUiState +import dagger.hilt.android.testing.HiltAndroidRule +import dagger.hilt.android.testing.HiltAndroidTest +import dagger.hilt.android.testing.HiltTestApplication +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TestRule +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import javax.inject.Inject +import kotlin.getValue +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +@OptIn(ExperimentalCoroutinesApi::class) +@HiltAndroidTest +@Config(application = HiltTestApplication::class) +@RunWith(RobolectricTestRunner::class) +internal class UsersViewModelIntegrationTest { + @Rule(order = 0) + @JvmField + val hiltRule = HiltAndroidRule(this) + + @get:Rule(order = 1) + var instantRule: TestRule = InstantTaskExecutorRule() + + @get:Rule(order = 2) + var mainRule: TestRule = MainDispatcherRule() + + @Inject + lateinit var getUsersListUseCase: GetUserListUseCase + + @Inject + lateinit var deleteUserUseCase: DeleteUserUseCase + + @Inject + lateinit var mockWebServer: MockWebServer + + lateinit var viewModel: UsersViewModel + + @Before + fun setup() { + hiltRule.inject() + } + + @After + fun tearDown() { + mockWebServer.shutdown() + } + + private fun initViewModel() { + viewModel = UsersViewModel(getUsersListUseCase, deleteUserUseCase) + } + + @Test + fun `GIVEN getUsersListUseCase returns users WHEN load users event THEN receives Idle state`() = + runTest { + initViewModel() + mockWebServer.enqueue(MockResponse().setResponseCode(200).setBody(getUserListResponsePage1Json)) + + viewModel.handleEvent(UsersEvent.OnLoadUsers) + runCurrent() + + viewModel.uiState.test { + val initialState = awaitItem() + val finalState = awaitItem() + assertTrue(initialState.contentState is UsersScreenUiState.ContentState.Loading) + assertTrue(finalState.contentState is UsersScreenUiState.ContentState.Idle) + assertTrue(finalState.users.isNotEmpty()) + expectNoEvents() + } + } + + @Test + fun `GIVEN getUsersListUseCase returns error WHEN load users event THEN receives Error state`() = + runTest { + initViewModel() + mockWebServer.enqueue(MockResponse().setResponseCode(500)) + + viewModel.handleEvent(UsersEvent.OnLoadUsers) + runCurrent() + + viewModel.uiState.test { + val initialState = awaitItem() + val finalState = awaitItem() + assertTrue(initialState.contentState is UsersScreenUiState.ContentState.Loading) + assertTrue(finalState.contentState is UsersScreenUiState.ContentState.Error) + expectNoEvents() + } + + viewModel.uiEventsState.test { + assertEquals(UsersErrorUiEventsState.LoadUsersError, awaitItem()) + expectNoEvents() + } + } + + @Test + fun `GIVEN getUsersListUseCase returns users WHEN filter users event with text THEN receives Filtered state`() = + runTest { + initViewModel() + mockWebServer.enqueue(MockResponse().setResponseCode(200).setBody(getUserListResponsePage1Json)) + + viewModel.handleEvent(UsersEvent.OnLoadUsers) + runCurrent() + + viewModel.uiState.test { + skipItems(2) + viewModel.handleEvent(UsersEvent.OnFilterUsers("Jos")) + runCurrent() + + val newState = awaitItem() + assertTrue(newState.users.size == 1) + assertTrue(newState.contentState is UsersScreenUiState.ContentState.Filtered) + assertEquals(newState.filterText, "Jos") + expectNoEvents() + } + } + + @Test + fun `GIVEN getUsersListUseCase returns users WHEN filter users event with no text THEN receives correct state`() = + runTest { + initViewModel() + mockWebServer.enqueue(MockResponse().setResponseCode(200).setBody(getUserListResponsePage1Json)) + + viewModel.handleEvent(UsersEvent.OnFilterUsers("")) + runCurrent() + + viewModel.uiState.test { + val newState = awaitItem() + assertTrue(newState.contentState is UsersScreenUiState.ContentState.Idle) + assertEquals(newState.filterText, "") + expectNoEvents() + } + } +} diff --git a/presentation/users/src/test/kotlin/com/random/users/users/viewmodel/UsersViewModelTest.kt b/presentation/users/src/test/kotlin/com/random/users/users/viewmodel/UsersViewModelTest.kt deleted file mode 100644 index 504f79b..0000000 --- a/presentation/users/src/test/kotlin/com/random/users/users/viewmodel/UsersViewModelTest.kt +++ /dev/null @@ -1,143 +0,0 @@ -package com.random.users.users.viewmodel - -import app.cash.turbine.test -import arrow.core.left -import arrow.core.right -import com.random.users.domain.models.UsersErrors -import com.random.users.domain.usecase.DeleteUserUseCase -import com.random.users.domain.usecase.GetUserListUseCase -import com.random.users.users.contract.UsersErrorUiEventsState -import com.random.users.users.contract.UsersEvent -import com.random.users.users.contract.UsersScreenUiState -import com.random.users.users.mapper.toUiState -import com.random.users.users.mother.UserMother -import com.random.users.users.rules.MainDispatcherRule -import io.mockk.coEvery -import io.mockk.coVerify -import io.mockk.mockk -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.test.runTest -import org.junit.Rule -import org.junit.Test -import kotlin.getValue -import kotlin.test.assertEquals -import kotlin.test.assertTrue - -class UsersViewModelTest { - @get:Rule - val mainDispatcherRule = MainDispatcherRule() - - private val getUsersListUseCase: GetUserListUseCase = mockk() - private val deleteUserUseCase: DeleteUserUseCase = mockk() - private val viewModel: UsersViewModel by lazy { - UsersViewModel(getUsersListUseCase, deleteUserUseCase) - } - - @OptIn(ExperimentalCoroutinesApi::class) - @Test - fun `GIVEN getUsersListUseCase returns users WHEN load users event THEN receives correct state`() = - runTest { - val expectedUsers = - listOf( - UserMother.createModel(uuid = "1"), - UserMother.createModel(uuid = "2"), - UserMother.createModel(uuid = "3"), - ) - coEvery { getUsersListUseCase(any()) } returns expectedUsers.right() - - viewModel.handleEvent(UsersEvent.OnLoadUsers) - - viewModel.uiState.test { - assertTrue(awaitItem().contentState is UsersScreenUiState.ContentState.Loading) - assertEquals(expectedUsers.toUiState(), awaitItem().users) - expectNoEvents() - } - - coVerify { - getUsersListUseCase(any()) - } - coVerify(exactly = 0) { deleteUserUseCase(any()) } - } - - @Test - fun `GIVEN getUsersListUseCase returns error WHEN load users event THEN receives error state`() = - runTest { - coEvery { getUsersListUseCase(any()) } returns UsersErrors.NetworkError.left() - - viewModel.uiEventsState.test { - viewModel.handleEvent(UsersEvent.OnLoadUsers) - assertEquals(UsersErrorUiEventsState.LoadUsersError, awaitItem()) - expectNoEvents() - } - - coVerify { - getUsersListUseCase(any()) - } - } - - @Test - fun `GIVEN getUsersListUseCase returns users WHEN filter users event with text THEN receives correct state`() = - runTest { - val expectedUsers = - listOf( - UserMother.createModel(uuid = "1"), - UserMother.createModel(uuid = "2"), - UserMother.createModel(uuid = "3"), - ) - coEvery { getUsersListUseCase(any()) } returns expectedUsers.right() - - viewModel.handleEvent(UsersEvent.OnFilterUsers("test")) - - viewModel.uiState.test { - val item = awaitItem() - assertTrue(item.contentState is UsersScreenUiState.ContentState.Filtered) - assertEquals(item.filterText, "test") - } - - coVerify(exactly = 0) { - getUsersListUseCase(any()) - deleteUserUseCase(any()) - } - } - - @Test - fun `GIVEN getUsersListUseCase returns users WHEN filter users event with no text THEN receives correct state`() = - runTest { - val expectedUsers = - listOf( - UserMother.createModel(uuid = "1"), - UserMother.createModel(uuid = "2"), - UserMother.createModel(uuid = "3"), - ) - coEvery { getUsersListUseCase(any()) } returns expectedUsers.right() - - viewModel.handleEvent(UsersEvent.OnFilterUsers("")) - - viewModel.uiState.test { - val item = awaitItem() - assertTrue(item.contentState is UsersScreenUiState.ContentState.Idle) - assertEquals(item.filterText, "") - } - - coVerify(exactly = 0) { - getUsersListUseCase(any()) - deleteUserUseCase(any()) - } - } - - @Test - fun `GIVEN deleteUser returns error WHEN deleteUserUseCase THEN receives error state`() = - runTest { - coEvery { deleteUserUseCase("1") } returns UsersErrors.UserError.left() - - viewModel.uiEventsState.test { - viewModel.handleEvent(UsersEvent.OnDeleteUser("1")) - assertEquals(UsersErrorUiEventsState.DeleteError, awaitItem()) - expectNoEvents() - } - - coVerify { - deleteUserUseCase(any()) - } - } -} diff --git a/presentation/users/src/test/kotlin/com/random/users/users/viewmodel/UsersViewModelUnitTest.kt b/presentation/users/src/test/kotlin/com/random/users/users/viewmodel/UsersViewModelUnitTest.kt new file mode 100644 index 0000000..8953630 --- /dev/null +++ b/presentation/users/src/test/kotlin/com/random/users/users/viewmodel/UsersViewModelUnitTest.kt @@ -0,0 +1,57 @@ +package com.random.users.users.viewmodel + +import app.cash.turbine.test +import arrow.core.left +import com.random.users.domain.models.UsersErrors +import com.random.users.domain.usecase.DeleteUserUseCase +import com.random.users.domain.usecase.GetUserListUseCase +import com.random.users.test.rules.MainDispatcherRule +import com.random.users.users.contract.UsersErrorUiEventsState +import com.random.users.users.contract.UsersEvent +import com.random.users.users.contract.UsersScreenUiState +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.Rule +import org.junit.Test +import kotlin.getValue +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +@OptIn(ExperimentalCoroutinesApi::class) +internal class UsersViewModelUnitTest { + @get:Rule + val mainDispatcherRule = MainDispatcherRule() + + private val getUsersListUseCase: GetUserListUseCase = mockk() + private val deleteUserUseCase: DeleteUserUseCase = mockk() + private val viewModel: UsersViewModel by lazy { + UsersViewModel(getUsersListUseCase, deleteUserUseCase) + } + + @Test + fun `GIVEN deleteUser returns error WHEN deleteUserUseCase THEN receives error state`() = + runTest { + coEvery { deleteUserUseCase("1") } returns UsersErrors.UserError.left() + + viewModel.handleEvent(UsersEvent.OnDeleteUser("1")) + runCurrent() + + viewModel.uiState.test { + assertTrue(awaitItem().contentState is UsersScreenUiState.ContentState.Idle) + expectNoEvents() + } + + viewModel.uiEventsState.test { + assertEquals(UsersErrorUiEventsState.DeleteError, awaitItem()) + expectNoEvents() + } + + coVerify { + deleteUserUseCase("1") + } + } +}