Updates to unit tests

Added more units tests - room database test
Amended ui
Changed how recycler view loads data
This commit is contained in:
2020-11-20 15:34:17 +00:00
parent 28fa9fd258
commit 71968d39b8
27 changed files with 342 additions and 236 deletions

View File

@@ -70,6 +70,11 @@ dependencies {
implementation 'android.arch.core:core-testing' implementation 'android.arch.core:core-testing'
androidTestImplementation 'androidx.test:rules:1.3.0-rc01' androidTestImplementation 'androidx.test:rules:1.3.0-rc01'
// Mockk
def mockk_ver = "1.10.2"
testImplementation "io.mockk:mockk:$mockk_ver"
androidTestImplementation "io.mockk:mockk-android:$mockk_ver"
//Retrofit and GSON //Retrofit and GSON
implementation 'com.squareup.retrofit2:retrofit:2.8.1' implementation 'com.squareup.retrofit2:retrofit:2.8.1'
implementation 'com.squareup.retrofit2:converter-gson:2.8.1' implementation 'com.squareup.retrofit2:converter-gson:2.8.1'
@@ -96,4 +101,6 @@ dependencies {
// Circle Image View // Circle Image View
implementation 'com.mikhaellopez:circularimageview:4.2.0' implementation 'com.mikhaellopez:circularimageview:4.2.0'
// Latest version of JSONObject - needed to pass unit tests
testImplementation 'org.json:json:20180813'
} }

View File

@@ -1,22 +0,0 @@
package com.example.h_mal.stackexchangeusers
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import org.junit.Assert.assertEquals
import org.junit.Test
import org.junit.runner.RunWith
/**
* Instrumented test, which will execute on an Android device.
*
* See [testing documentation](http://d.android.com/tools/testing).
*/
@RunWith(AndroidJUnit4::class)
class ExampleInstrumentedTest {
@Test
fun useAppContext() {
// Context of the app under test.
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
assertEquals("com.example.h_mal.candyspace", appContext.packageName)
}
}

View File

@@ -0,0 +1,86 @@
package com.example.h_mal.stackexchangeusers.data.room
import android.content.Context
import androidx.arch.core.executor.testing.InstantTaskExecutorRule
import androidx.room.Room
import androidx.test.core.app.ApplicationProvider
import androidx.test.espresso.matcher.ViewMatchers
import com.example.h_mal.stackexchangeusers.data.room.entities.UserEntity
import kotlinx.coroutines.delay
import kotlinx.coroutines.runBlocking
import org.hamcrest.CoreMatchers
import org.junit.After
import org.junit.Assert.*
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TestRule
import java.io.IOException
import java.util.concurrent.CountDownLatch
import java.util.concurrent.Executors
class AppDatabaseTest{
@get:Rule
var rule: TestRule = InstantTaskExecutorRule()
private lateinit var simpleDao: UsersDao
private lateinit var db: AppDatabase
@Before
fun createDb() {
val context = ApplicationProvider.getApplicationContext<Context>()
db = Room.inMemoryDatabaseBuilder(
context, AppDatabase::class.java)
.build()
simpleDao = db.getUsersDao()
}
@After
@Throws(IOException::class)
fun closeDb() {
db.close()
}
@Test
@Throws(Exception::class)
fun writeEntryAndReadResponse(){
// Given
val entity = UserEntity(123)
val latch = CountDownLatch(1)
var userEntity: UserEntity? = null
// When
simpleDao.saveAllUsers(listOf(entity))
// Then
simpleDao.getUser(123).observeForever {
userEntity = it
latch.countDown()
}
ViewMatchers.assertThat(userEntity, CoreMatchers.equalTo(entity))
}
@Test
@Throws(Exception::class)
fun upsertUsersAndReadResponse() = runBlocking{
// Given
val userEntity = UserEntity(123)
val newUserEntity = UserEntity(456)
var result: List<UserEntity>? = null
// When
simpleDao.saveAllUsers(listOf(userEntity))
simpleDao.upsertNewUsers(listOf(newUserEntity))
// Then
val latch = CountDownLatch(1)
simpleDao.getAllUsers().observeForever {
result = it
latch.countDown()
}
ViewMatchers.assertThat(newUserEntity, CoreMatchers.equalTo(result?.get(0)))
}
}

View File

@@ -0,0 +1,27 @@
package com.example.h_mal.stackexchangeusers.data.model
import androidx.room.PrimaryKey
import com.example.h_mal.stackexchangeusers.data.room.entities.UserEntity
data class UserItem(
val userId: Int?,
val displayName: String?,
val bronze: Int?,
val silver: Int?,
val gold: Int?,
val reputation: Int?,
val creationDate: Int?,
val profileImage: String?
){
constructor(userEntity: UserEntity): this(
userEntity.userId,
userEntity.displayName,
userEntity.bronze,
userEntity.silver,
userEntity.gold,
userEntity.reputation,
userEntity.creationDate,
userEntity.profileImage
)
}

View File

@@ -2,7 +2,7 @@ package com.example.h_mal.stackexchangeusers.data.network.api
import com.example.h_mal.stackexchangeusers.data.network.api.interceptors.NetworkConnectionInterceptor import com.example.h_mal.stackexchangeusers.data.network.api.interceptors.NetworkConnectionInterceptor
import com.example.h_mal.stackexchangeusers.data.network.api.interceptors.QueryParamsInterceptor import com.example.h_mal.stackexchangeusers.data.network.api.interceptors.QueryParamsInterceptor
import com.example.h_mal.stackexchangeusers.data.network.model.ApiResponse import com.example.h_mal.stackexchangeusers.data.network.response.ApiResponse
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import retrofit2.Response import retrofit2.Response
import retrofit2.Retrofit import retrofit2.Retrofit

View File

@@ -5,7 +5,7 @@ import org.json.JSONObject
import retrofit2.Response import retrofit2.Response
import java.io.IOException import java.io.IOException
abstract class ResponseUnwrap { abstract class SafeApiCall {
@Suppress("BlockingMethodInNonBlockingContext") @Suppress("BlockingMethodInNonBlockingContext")
suspend fun <T : Any> responseUnwrap( suspend fun <T : Any> responseUnwrap(

View File

@@ -1,4 +1,4 @@
package com.example.h_mal.stackexchangeusers.data.network.model package com.example.h_mal.stackexchangeusers.data.network.response
data class ApiResponse( data class ApiResponse(
val items : List<User>?, val items : List<User>?,

View File

@@ -1,4 +1,4 @@
package com.example.h_mal.stackexchangeusers.data.network.model package com.example.h_mal.stackexchangeusers.data.network.response
import com.google.gson.annotations.Expose import com.google.gson.annotations.Expose
import com.google.gson.annotations.SerializedName import com.google.gson.annotations.SerializedName

View File

@@ -1,4 +1,4 @@
package com.example.h_mal.stackexchangeusers.data.network.model package com.example.h_mal.stackexchangeusers.data.network.response
import com.google.gson.annotations.Expose import com.google.gson.annotations.Expose
import com.google.gson.annotations.SerializedName import com.google.gson.annotations.SerializedName

View File

@@ -1,14 +1,14 @@
package com.example.h_mal.stackexchangeusers.data.repositories package com.example.h_mal.stackexchangeusers.data.repositories
import androidx.lifecycle.LiveData import androidx.lifecycle.LiveData
import com.example.h_mal.stackexchangeusers.data.network.model.ApiResponse import com.example.h_mal.stackexchangeusers.data.network.response.ApiResponse
import com.example.h_mal.stackexchangeusers.data.network.model.User import com.example.h_mal.stackexchangeusers.data.network.response.User
import com.example.h_mal.stackexchangeusers.data.room.entities.UserItem import com.example.h_mal.stackexchangeusers.data.room.entities.UserEntity
interface Repository { interface Repository {
fun getUsersFromDatabase(): LiveData<List<UserItem>> fun getUsersFromDatabase(): LiveData<List<UserEntity>>
fun getSingleUserFromDatabase(id: Int): LiveData<UserItem> fun getSingleUserFromDatabase(id: Int): LiveData<UserEntity>
suspend fun saveUsersToDatabase(users: List<User>) suspend fun saveUsersToDatabase(users: List<User>)
suspend fun getUsersFromApi(username: String): ApiResponse? suspend fun getUsersFromApi(username: String): ApiResponse?
fun saveCurrentSearchToPrefs(username: String) fun saveCurrentSearchToPrefs(username: String)

View File

@@ -1,33 +1,29 @@
package com.example.h_mal.stackexchangeusers.data.repositories package com.example.h_mal.stackexchangeusers.data.repositories
import com.example.h_mal.stackexchangeusers.data.network.api.ApiClass import com.example.h_mal.stackexchangeusers.data.network.api.ApiClass
import com.example.h_mal.stackexchangeusers.data.network.model.ApiResponse import com.example.h_mal.stackexchangeusers.data.network.response.ApiResponse
import com.example.h_mal.stackexchangeusers.data.network.model.User import com.example.h_mal.stackexchangeusers.data.network.response.User
import com.example.h_mal.stackexchangeusers.data.network.networkUtils.ResponseUnwrap import com.example.h_mal.stackexchangeusers.data.network.networkUtils.SafeApiCall
import com.example.h_mal.stackexchangeusers.data.preferences.PreferenceProvider import com.example.h_mal.stackexchangeusers.data.preferences.PreferenceProvider
import com.example.h_mal.stackexchangeusers.data.room.AppDatabase import com.example.h_mal.stackexchangeusers.data.room.AppDatabase
import com.example.h_mal.stackexchangeusers.data.room.entities.UserItem import com.example.h_mal.stackexchangeusers.data.room.entities.UserEntity
const val MILLISECONDS_ONE_MIN = 60000 const val MILLISECONDS_ONE_MIN = 60000
class RepositoryImpl( class RepositoryImpl(
private val api: ApiClass, private val api: ApiClass,
private val database: AppDatabase, private val database: AppDatabase,
private val preference: PreferenceProvider private val preference: PreferenceProvider
) : ResponseUnwrap(), Repository { ) : SafeApiCall(), Repository {
// Current list of users in the database
override fun getUsersFromDatabase() = database.getUsersDao().getAllUsers() override fun getUsersFromDatabase() = database.getUsersDao().getAllUsers()
// retrieving a single user from an ID
override fun getSingleUserFromDatabase(id: Int) = database.getUsersDao().getUser(id) override fun getSingleUserFromDatabase(id: Int) = database.getUsersDao().getUser(id)
// save a list of users to the room database
override suspend fun saveUsersToDatabase(users: List<User>){ override suspend fun saveUsersToDatabase(users: List<User>){
val userList= getUserList(users) val userList= users.map { UserEntity(it) }
database.getUsersDao().upsertNewUsers(userList) database.getUsersDao().upsertNewUsers(userList)
} }
// fetch users from an api call
override suspend fun getUsersFromApi(username: String): ApiResponse? { override suspend fun getUsersFromApi(username: String): ApiResponse? {
return when (isSearchValid(username)) { return when (isSearchValid(username)) {
true -> responseUnwrap { api.getUsersFromApi(username) } true -> responseUnwrap { api.getUsersFromApi(username) }
@@ -35,14 +31,12 @@ class RepositoryImpl(
} }
} }
// save current time and current search input into shared prefs
override fun saveCurrentSearchToPrefs(username: String){ override fun saveCurrentSearchToPrefs(username: String){
val time = System.currentTimeMillis() val time = System.currentTimeMillis()
preference.saveLastSavedAt(username, time) preference.saveLastSavedAt(username, time)
} }
// boolean response of validity of search // Check if search is valid based on lasted saved
// if the same search is taking place again with a minute return false
private fun isSearchValid(username: String): Boolean { private fun isSearchValid(username: String): Boolean {
val time = preference.getLastSavedAt(username) ?: return true val time = preference.getLastSavedAt(username) ?: return true
val currentTime = System.currentTimeMillis() val currentTime = System.currentTimeMillis()
@@ -50,24 +44,4 @@ class RepositoryImpl(
return difference > MILLISECONDS_ONE_MIN return difference > MILLISECONDS_ONE_MIN
} }
// map user from api response to userItem entity for database
private fun getUserList(users: List<User>): List<UserItem> {
return users.map {
it.mapToUserItem()
}
}
private fun User.mapToUserItem(): UserItem {
return UserItem(
userId,
displayName,
badgeCounts?.bronze,
badgeCounts?.silver,
badgeCounts?.gold,
reputation,
creationDate,
profileImage
)
}
} }

View File

@@ -4,10 +4,10 @@ import android.content.Context
import androidx.room.Database import androidx.room.Database
import androidx.room.Room import androidx.room.Room
import androidx.room.RoomDatabase import androidx.room.RoomDatabase
import com.example.h_mal.stackexchangeusers.data.room.entities.UserItem import com.example.h_mal.stackexchangeusers.data.room.entities.UserEntity
@Database( @Database(
entities = [UserItem::class], entities = [UserEntity::class],
version = 1 version = 1
) )
abstract class AppDatabase : RoomDatabase() { abstract class AppDatabase : RoomDatabase() {

View File

@@ -2,27 +2,27 @@ package com.example.h_mal.stackexchangeusers.data.room
import androidx.lifecycle.LiveData import androidx.lifecycle.LiveData
import androidx.room.* import androidx.room.*
import com.example.h_mal.stackexchangeusers.data.room.entities.UserItem import com.example.h_mal.stackexchangeusers.data.room.entities.UserEntity
@Dao @Dao
interface UsersDao { interface UsersDao {
@Insert(onConflict = OnConflictStrategy.REPLACE) @Insert(onConflict = OnConflictStrategy.REPLACE)
fun saveAllUsers(users : List<UserItem>) fun saveAllUsers(users : List<UserEntity>)
@Query("SELECT * FROM UserItem") @Query("SELECT * FROM UserEntity")
fun getAllUsers() : LiveData<List<UserItem>> fun getAllUsers() : LiveData<List<UserEntity>>
// clear database and add new entries // clear database and add new entries
@Transaction @Transaction
suspend fun upsertNewUsers(users : List<UserItem>){ suspend fun upsertNewUsers(users : List<UserEntity>){
deleteEntries() deleteEntries()
saveAllUsers(users) saveAllUsers(users)
} }
@Query("DELETE FROM UserItem") @Query("DELETE FROM UserEntity")
suspend fun deleteEntries() suspend fun deleteEntries()
@Query("SELECT * FROM UserItem WHERE userId = :id") @Query("SELECT * FROM UserEntity WHERE userId = :id")
fun getUser(id: Int) : LiveData<UserItem> fun getUser(id: Int) : LiveData<UserEntity>
} }

View File

@@ -0,0 +1,30 @@
package com.example.h_mal.stackexchangeusers.data.room.entities
import androidx.room.Entity
import androidx.room.PrimaryKey
import com.example.h_mal.stackexchangeusers.data.network.response.User
@Entity
data class UserEntity(
@PrimaryKey(autoGenerate = false)
val userId: Int,
val displayName: String? = null,
val bronze: Int? = null,
val silver: Int? = null,
val gold: Int? = null,
val reputation: Int? = null,
val creationDate: Int? = null,
val profileImage: String? = null
){
constructor(user: User): this(
user.userId!!,
user.displayName,
user.badgeCounts?.bronze,
user.badgeCounts?.silver,
user.badgeCounts?.gold,
user.reputation,
user.creationDate,
user.profileImage
)
}

View File

@@ -1,17 +0,0 @@
package com.example.h_mal.stackexchangeusers.data.room.entities
import androidx.room.Entity
import androidx.room.PrimaryKey
@Entity
data class UserItem(
@PrimaryKey(autoGenerate = false)
val userId: Int?,
val displayName: String?,
val bronze: Int?,
val silver: Int?,
val gold: Int?,
val reputation: Int?,
val creationDate: Int?,
val profileImage: String?
)

View File

@@ -1,9 +1,12 @@
package com.example.h_mal.stackexchangeusers.ui package com.example.h_mal.stackexchangeusers.ui
import androidx.lifecycle.MutableLiveData import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.Observer
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import com.example.h_mal.stackexchangeusers.application.AppClass.Companion.idlingResources import com.example.h_mal.stackexchangeusers.application.AppClass.Companion.idlingResources
import com.example.h_mal.stackexchangeusers.data.model.UserItem
import com.example.h_mal.stackexchangeusers.data.repositories.Repository import com.example.h_mal.stackexchangeusers.data.repositories.Repository
import com.example.h_mal.stackexchangeusers.data.room.entities.UserEntity
import com.example.h_mal.stackexchangeusers.utils.Event import com.example.h_mal.stackexchangeusers.utils.Event
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@@ -16,11 +19,19 @@ class MainViewModel(
) : ViewModel() { ) : ViewModel() {
// livedata for user items in room database // livedata for user items in room database
val usersLiveData = repository.getUsersFromDatabase() val usersLiveData = MutableLiveData<List<UserItem>>()
val operationState = MutableLiveData<Event<Boolean>>() val operationState = MutableLiveData<Event<Boolean>>()
val operationError = MutableLiveData<Event<String>>() val operationError = MutableLiveData<Event<String>>()
init {
val observer = Observer<List<UserEntity>> {
val list = it.map {entity -> UserItem(entity) }
usersLiveData.postValue(list)
}
repository.getUsersFromDatabase().observeForever (observer)
}
fun getUsers(username: String?){ fun getUsers(username: String?){
// validate that search term is not empty // validate that search term is not empty

View File

@@ -9,8 +9,8 @@ import androidx.fragment.app.activityViewModels
import androidx.lifecycle.Observer import androidx.lifecycle.Observer
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import com.example.h_mal.stackexchangeusers.R import com.example.h_mal.stackexchangeusers.R
import com.example.h_mal.stackexchangeusers.application.AppClass.Companion.idlingResources import com.example.h_mal.stackexchangeusers.data.model.UserItem
import com.example.h_mal.stackexchangeusers.data.room.entities.UserItem import com.example.h_mal.stackexchangeusers.data.room.entities.UserEntity
import com.example.h_mal.stackexchangeusers.ui.MainViewModel import com.example.h_mal.stackexchangeusers.ui.MainViewModel
import com.example.h_mal.stackexchangeusers.utils.onSubmitListener import com.example.h_mal.stackexchangeusers.utils.onSubmitListener
import kotlinx.android.synthetic.main.main_fragment.* import kotlinx.android.synthetic.main.main_fragment.*
@@ -21,11 +21,12 @@ import kotlinx.android.synthetic.main.main_fragment.*
* Results for users will be displayed here * Results for users will be displayed here
*/ */
class MainFragment : Fragment() { class MainFragment : Fragment() {
/**
* get [MainViewModel] from [MainActivity] (the calling activity) // Retrieve view model from main activity
*/
val viewModel: MainViewModel by activityViewModels() val viewModel: MainViewModel by activityViewModels()
lateinit var mAdapter: UserRecyclerViewAdapter
override fun onCreateView( override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?, inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle? savedInstanceState: Bundle?
@@ -42,6 +43,13 @@ class MainFragment : Fragment() {
viewModel.getUsers(query.toString().trim()) viewModel.getUsers(query.toString().trim())
} }
} }
mAdapter = UserRecyclerViewAdapter(requireView())
recycler_view.apply {
layoutManager = LinearLayoutManager(context)
setHasFixedSize(true)
adapter = mAdapter
}
} }
override fun onStart() { override fun onStart() {
@@ -55,18 +63,7 @@ class MainFragment : Fragment() {
} }
private val usersObserver = Observer<List<UserItem>> { private val usersObserver = Observer<List<UserItem>> {
// create adapter the recycler view mAdapter.updateList(it)
val mAdapter = UserRecyclerViewAdapter(
requireView(),
it
)
// setup the recyclerview
recycler_view.apply {
layoutManager = LinearLayoutManager(context)
setHasFixedSize(true)
adapter = mAdapter
}
} }
} }

View File

@@ -3,24 +3,33 @@ package com.example.h_mal.stackexchangeusers.ui.main.pages.home
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import androidx.core.os.persistableBundleOf
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import com.example.h_mal.stackexchangeusers.R import com.example.h_mal.stackexchangeusers.R
import com.example.h_mal.stackexchangeusers.data.room.entities.UserItem import com.example.h_mal.stackexchangeusers.data.model.UserItem
import com.example.h_mal.stackexchangeusers.data.room.entities.UserEntity
import com.example.h_mal.stackexchangeusers.utils.loadImage import com.example.h_mal.stackexchangeusers.utils.loadImage
import com.example.h_mal.stackexchangeusers.utils.navigateTo import com.example.h_mal.stackexchangeusers.utils.navigateTo
import kotlinx.android.extensions.LayoutContainer import kotlinx.android.extensions.LayoutContainer
import kotlinx.android.synthetic.main.simple_item.view.* import kotlinx.android.synthetic.main.simple_item.view.*
/** /**
* Create a Recyclerview adapter used to display [UserItem] * Create a Recyclerview adapter used to display [UserEntity]
* @param parentView is used for context and navigation * @param parentView is used for context and navigation
* @param items is the list of users parsed from the database
*/ */
class UserRecyclerViewAdapter ( class UserRecyclerViewAdapter (
val parentView: View, val parentView: View
val items: List<UserItem>
): RecyclerView.Adapter<RecyclerView.ViewHolder>(){ ): RecyclerView.Adapter<RecyclerView.ViewHolder>(){
var list = mutableListOf<UserItem>()
fun updateList(users: List<UserItem>) {
list.clear()
list.addAll(users)
notifyDataSetChanged()
}
// create a viewholder with [R.layout.simple_item] // create a viewholder with [R.layout.simple_item]
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
val view = LayoutInflater val view = LayoutInflater
@@ -31,13 +40,13 @@ class UserRecyclerViewAdapter (
} }
override fun getItemCount(): Int { override fun getItemCount(): Int {
return items.size return list.size
} }
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
val view = holder as ItemOne val view = holder as ItemOne
items[position].let { list[position].let {
view.bindUser(it) view.bindUser(it)
} }
} }
@@ -53,8 +62,7 @@ class UserRecyclerViewAdapter (
itemView.text2.text = userItem.reputation.toString() itemView.text2.text = userItem.reputation.toString()
itemView.setOnClickListener { itemView.setOnClickListener {
val action = val action = MainFragmentDirections.toUserProfileFragment(list[layoutPosition].userId!!)
MainFragmentDirections.toUserProfileFragment(items[position].userId!!)
parentView.navigateTo(action) parentView.navigateTo(action)
} }
} }

View File

@@ -8,16 +8,14 @@ import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels import androidx.fragment.app.activityViewModels
import androidx.lifecycle.Observer import androidx.lifecycle.Observer
import com.example.h_mal.stackexchangeusers.R import com.example.h_mal.stackexchangeusers.R
import com.example.h_mal.stackexchangeusers.data.room.entities.UserItem import com.example.h_mal.stackexchangeusers.data.room.entities.UserEntity
import com.example.h_mal.stackexchangeusers.ui.MainViewModel import com.example.h_mal.stackexchangeusers.ui.MainViewModel
import com.example.h_mal.stackexchangeusers.utils.epochToData import com.example.h_mal.stackexchangeusers.utils.epochToData
import com.example.h_mal.stackexchangeusers.utils.loadImage import com.example.h_mal.stackexchangeusers.utils.loadImage
import kotlinx.android.synthetic.main.fragment_user_profile.* import kotlinx.android.synthetic.main.fragment_user_profile.*
/** /**
* A simple [Fragment] subclass. * Fragment to show the user overview
* Use the [UserProfileFragment.newInstance] factory method to
* create an instance of this fragment.
*/ */
class UserProfileFragment : Fragment() { class UserProfileFragment : Fragment() {
val viewModel: MainViewModel by activityViewModels() val viewModel: MainViewModel by activityViewModels()
@@ -43,7 +41,7 @@ class UserProfileFragment : Fragment() {
viewModel.getSingleUser(id).removeObserver(singleUserObserver) viewModel.getSingleUser(id).removeObserver(singleUserObserver)
} }
private val singleUserObserver = Observer<UserItem> { private val singleUserObserver = Observer<UserEntity> {
username.text = it.displayName username.text = it.displayName
reputation.text = it.reputation.toString() reputation.text = it.reputation.toString()
gold_score.text = it.gold.toString() gold_score.text = it.gold.toString()

View File

@@ -7,7 +7,7 @@ import java.util.*
fun epochToData(number: Int?): String { fun epochToData(number: Int?): String {
number ?: return "" number ?: return ""
return try { return try {
val sdf = SimpleDateFormat("dd/MM/yyyy") val sdf = SimpleDateFormat("dd/MM/yyyy", Locale.getDefault())
val netDate = Date(number.toLong() * 1000) val netDate = Date(number.toLong() * 1000)
sdf.format(netDate) sdf.format(netDate)
} catch (e: Exception) { } catch (e: Exception) {

View File

@@ -4,20 +4,21 @@
xmlns:tools="http://schemas.android.com/tools" xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
android:layout_margin="16dp"
tools:context=".ui.main.pages.user.UserProfileFragment"> tools:context=".ui.main.pages.user.UserProfileFragment">
<ImageView <ImageView
android:id="@+id/imageView" android:id="@+id/imageView"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="0dp" android:layout_height="0dp"
app:layout_constraintHeight_percent="0.25"
android:adjustViewBounds="true" android:adjustViewBounds="true"
app:layout_constraintVertical_bias="0.12"
android:src="@mipmap/ic_launcher" android:src="@mipmap/ic_launcher"
app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintHeight_percent="0.25"
app:layout_constraintLeft_toLeftOf="parent" app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent" app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toTopOf="parent" app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_bias="0.12"
tools:src="@drawable/ic_launcher_foreground" /> tools:src="@drawable/ic_launcher_foreground" />
<TextView <TextView
@@ -25,37 +26,38 @@
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginTop="12dp" android:layout_marginTop="12dp"
android:text="Username:" android:text="@string/username"
app:layout_constraintRight_toLeftOf="@id/divider" app:layout_constraintRight_toLeftOf="@id/divider"
app:layout_constraintTop_toBottomOf="@id/imageView" /> app:layout_constraintTop_toBottomOf="@id/imageView" />
<TextView <TextView
android:id="@+id/username" android:id="@+id/username"
android:layout_width="wrap_content" android:layout_width="0dp"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginTop="12dp" android:layout_marginTop="12dp"
android:text="@{user.displayName}" android:maxLines="1"
app:layout_constraintLeft_toRightOf="@id/divider" app:layout_constraintLeft_toRightOf="@id/divider"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toBottomOf="@+id/imageView" app:layout_constraintTop_toBottomOf="@+id/imageView"
tools:text="Username1234" /> tools:text="Username1234" />
<TextView <TextView
android:id="@+id/reputation_label" android:id="@+id/reputation_label"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginTop="12dp" android:layout_marginTop="12dp"
android:text="Reputation:" android:text="@string/reputation"
app:layout_constraintRight_toLeftOf="@id/divider" app:layout_constraintRight_toLeftOf="@id/divider"
app:layout_constraintTop_toBottomOf="@id/username_label" /> app:layout_constraintTop_toBottomOf="@id/username_label" />
<TextView <TextView
android:id="@+id/reputation" android:id="@+id/reputation"
android:layout_width="wrap_content" android:layout_width="0dp"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginTop="12dp" android:layout_marginTop="12dp"
android:text="@{user.reputation.toString()}" android:maxLines="1"
app:layout_constraintLeft_toRightOf="@id/divider" app:layout_constraintLeft_toRightOf="@id/divider"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toBottomOf="@+id/username" app:layout_constraintTop_toBottomOf="@+id/username"
tools:text="1548252" /> tools:text="1548252" />
@@ -64,7 +66,7 @@
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginTop="12dp" android:layout_marginTop="12dp"
android:text="Bagdes:" android:text="@string/bagdes"
app:layout_constraintRight_toLeftOf="@id/divider" app:layout_constraintRight_toLeftOf="@id/divider"
app:layout_constraintTop_toBottomOf="@id/reputation_label" /> app:layout_constraintTop_toBottomOf="@id/reputation_label" />
@@ -73,16 +75,17 @@
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginTop="12dp" android:layout_marginTop="12dp"
android:text="Gold - " android:text="@string/gold"
app:layout_constraintLeft_toRightOf="@id/divider" app:layout_constraintLeft_toRightOf="@id/divider"
app:layout_constraintTop_toBottomOf="@+id/reputation" /> app:layout_constraintTop_toBottomOf="@+id/reputation" />
<TextView <TextView
android:id="@+id/gold_score" android:id="@+id/gold_score"
android:layout_width="wrap_content" android:layout_width="0dp"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:text="@{user.badgeCounts.gold.toString()}" android:maxLines="1"
app:layout_constraintLeft_toRightOf="@id/badges_gold" app:layout_constraintLeft_toRightOf="@id/badges_gold"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toTopOf="@id/badges_gold" app:layout_constraintTop_toTopOf="@id/badges_gold"
tools:text="3554" /> tools:text="3554" />
@@ -91,16 +94,17 @@
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginTop="6dp" android:layout_marginTop="6dp"
android:text="Silver - " android:text="@string/silver"
app:layout_constraintLeft_toRightOf="@id/divider" app:layout_constraintLeft_toRightOf="@id/divider"
app:layout_constraintTop_toBottomOf="@+id/badges_gold" /> app:layout_constraintTop_toBottomOf="@+id/badges_gold" />
<TextView <TextView
android:id="@+id/silver_score" android:id="@+id/silver_score"
android:layout_width="wrap_content" android:layout_width="0dp"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:text="@{user.badgeCounts.silver.toString()}" android:maxLines="1"
app:layout_constraintLeft_toRightOf="@id/badges_silver" app:layout_constraintLeft_toRightOf="@id/badges_silver"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toTopOf="@id/badges_silver" app:layout_constraintTop_toTopOf="@id/badges_silver"
tools:text="3554" /> tools:text="3554" />
@@ -109,16 +113,17 @@
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginTop="6dp" android:layout_marginTop="6dp"
android:text="Bronze - " android:text="@string/bronze"
app:layout_constraintLeft_toRightOf="@id/divider" app:layout_constraintLeft_toRightOf="@id/divider"
app:layout_constraintTop_toBottomOf="@+id/badges_silver" /> app:layout_constraintTop_toBottomOf="@+id/badges_silver" />
<TextView <TextView
android:id="@+id/bronze_score" android:id="@+id/bronze_score"
android:layout_width="wrap_content" android:layout_width="0dp"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:text="@{user.badgeCounts.bronze.toString()}" android:maxLines="1"
app:layout_constraintLeft_toRightOf="@id/badges_bronze" app:layout_constraintLeft_toRightOf="@id/badges_bronze"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toTopOf="@id/badges_bronze" app:layout_constraintTop_toTopOf="@id/badges_bronze"
tools:text="3554" /> tools:text="3554" />
@@ -127,17 +132,18 @@
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginTop="12dp" android:layout_marginTop="12dp"
android:text="Date Joined:" android:text="@string/date_joined"
app:layout_constraintRight_toLeftOf="@id/divider" app:layout_constraintRight_toLeftOf="@id/divider"
app:layout_constraintTop_toBottomOf="@id/badges_bronze" /> app:layout_constraintTop_toBottomOf="@id/badges_bronze" />
<TextView <TextView
android:id="@+id/date_joined" android:id="@+id/date_joined"
android:layout_width="wrap_content" android:layout_width="0dp"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginTop="12dp" android:layout_marginTop="12dp"
android:text="@{ConverterUtil.epochToData(user.creationDate)}" android:maxLines="1"
app:layout_constraintLeft_toRightOf="@id/divider" app:layout_constraintLeft_toRightOf="@id/divider"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toBottomOf="@+id/badges_bronze" app:layout_constraintTop_toBottomOf="@+id/badges_bronze"
tools:text="31/01/2019" /> tools:text="31/01/2019" />

View File

@@ -1,39 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
xmlns:app="http://schemas.android.com/apk/res-auto">
<data>
<variable
name="user"
type="com.example.h_mal.stackexchangeusers.data.network.model.User" />
</data>
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="wrap_content" >
<TextView
android:id="@+id/textView"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_margin="12dp"
tools:text="12345"
android:text="@{user.userId.toString()}"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintBottom_toBottomOf="parent" />
<TextView
android:id="@+id/textView2"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_margin="12dp"
tools:text="Username"
android:text="@{user.displayName}"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintLeft_toRightOf="@id/textView"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintBottom_toBottomOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
</layout>

View File

@@ -5,4 +5,11 @@
<string name="hello_blank_fragment">Hello blank fragment</string> <string name="hello_blank_fragment">Hello blank fragment</string>
<string name="invalid_entry">Enter a valid username</string> <string name="invalid_entry">Enter a valid username</string>
<string name="username">Username:</string>
<string name="reputation">Reputation:</string>
<string name="bagdes">Bagdes:</string>
<string name="gold">Gold -</string>
<string name="silver">Silver -</string>
<string name="bronze">Bronze -</string>
<string name="date_joined">Date Joined:</string>
</resources> </resources>

View File

@@ -1,16 +0,0 @@
package com.example.h_mal.stackexchangeusers
import org.junit.Assert.assertEquals
import org.junit.Test
/**
* Example local unit test, which will execute on the development machine (host).
*
* See [testing documentation](http://d.android.com/tools/testing).
*/
class ExampleUnitTest {
@Test
fun addition_isCorrect() {
assertEquals(4, 2 + 2)
}
}

View File

@@ -0,0 +1,50 @@
package com.example.h_mal.stackexchangeusers.data.network.networkUtils
import com.example.h_mal.stackexchangeusers.data.network.response.ApiResponse
import io.mockk.mockk
import kotlinx.coroutines.runBlocking
import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.ResponseBody.Companion.toResponseBody
import org.junit.Assert.*
import org.junit.Test
import retrofit2.Response
import java.io.IOException
import kotlin.test.assertFailsWith
class SafeApiCallTest: SafeApiCall(){
@Test
fun successfulResponse_SuccessfulOutput() = runBlocking{
// GIVEN
val mockApiResponse = mockk<ApiResponse>()
val mockResponse = Response.success(mockApiResponse)
// WHEN
val result = responseUnwrap { mockResponse }
// THEN
assertNotNull(result)
assertEquals(mockApiResponse, result)
}
@Test
fun unsuccessfulResponse_thrownOutput() = runBlocking{
// GIVEN
val errorMessage = "{\n" +
" \"status_code\": 7,\n" +
" \"error_message\": \"Invalid API key: You must be granted a valid key.\",\n" +
"}"
val errorResponseBody = errorMessage.toResponseBody("application/json".toMediaTypeOrNull())
val mockResponse = Response.error<String>(404, errorResponseBody)
//THEN - assert exception is not null
val ioExceptionReturned = assertFailsWith<IOException> {
responseUnwrap { mockResponse }!!
}
assertNotNull(ioExceptionReturned)
assertEquals(ioExceptionReturned.message, "Invalid API key: You must be granted a valid key.")
}
}

View File

@@ -1,10 +1,11 @@
package com.example.h_mal.stackexchangeusers.data.repositories package com.example.h_mal.stackexchangeusers.data.repositories
import com.example.h_mal.stackexchangeusers.data.network.api.ApiClass import com.example.h_mal.stackexchangeusers.data.network.api.ApiClass
import com.example.h_mal.stackexchangeusers.data.network.model.ApiResponse import com.example.h_mal.stackexchangeusers.data.network.response.ApiResponse
import com.example.h_mal.stackexchangeusers.data.preferences.PreferenceProvider import com.example.h_mal.stackexchangeusers.data.preferences.PreferenceProvider
import com.example.h_mal.stackexchangeusers.data.room.AppDatabase import com.example.h_mal.stackexchangeusers.data.room.AppDatabase
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import okhttp3.MediaType.Companion.toMediaTypeOrNull
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull import org.junit.Assert.assertNotNull
import org.junit.Before import org.junit.Before
@@ -15,6 +16,7 @@ import org.mockito.Mockito.mock
import org.mockito.MockitoAnnotations import org.mockito.MockitoAnnotations
import retrofit2.Response import retrofit2.Response
import okhttp3.ResponseBody import okhttp3.ResponseBody
import okhttp3.ResponseBody.Companion.toResponseBody
import okio.IOException import okio.IOException
import kotlin.test.assertFailsWith import kotlin.test.assertFailsWith
@@ -49,44 +51,49 @@ class RepositoryTest {
Mockito.`when`(prefs.getLastSavedAt(input)).thenReturn(null) Mockito.`when`(prefs.getLastSavedAt(input)).thenReturn(null)
// THEN // THEN
val getUser = repository.getUsersFromApi(input) val apiResponse = repository.getUsersFromApi(input)
assertNotNull(getUser) assertNotNull(apiResponse)
assertEquals(mockApiResponse, getUser) assertEquals(mockApiResponse, apiResponse)
} }
@Test @Test
fun fetchUserFromApi_negativeResponse() = runBlocking { fun fetchUserFromApi_negativeResponse() = runBlocking {
//GIVEN //GIVEN
//mock retrofit error response val input = "12345"
val mockBody = mock(ResponseBody::class.java) val errorMessage = "{\n" +
val mockRaw = mock(okhttp3.Response::class.java) " \"status_code\": 7,\n" +
val re = Response.error<String>(mockBody, mockRaw) " \"error_message\": \"Invalid API key: You must be granted a valid key.\",\n" +
"}"
val errorResponseBody = errorMessage.toResponseBody("application/json".toMediaTypeOrNull())
val mockResponse = Response.error<String>(404, errorResponseBody)
//WHEN //WHEN
Mockito.`when`(api.getUsersFromApi("12345")).then { re } Mockito.`when`(api.getUsersFromApi(input)).then { mockResponse }
//THEN - assert exception is not null //THEN - assert exception is not null
val ioExceptionReturned = assertFailsWith<IOException> { val ioExceptionReturned = assertFailsWith<IOException> {
repository.getUsersFromApi("12345") repository.getUsersFromApi(input)
} }
assertNotNull(ioExceptionReturned) assertNotNull(ioExceptionReturned)
assertNotNull(ioExceptionReturned.message) assertEquals(ioExceptionReturned.message, "Invalid API key: You must be granted a valid key.")
} }
@Test @Test
fun fetchUserFromApi_alreadySearched() = runBlocking { fun fetchUserFromApi_alreadySearched() = runBlocking {
// GIVEN // GIVEN
val input = "12345"
val mockApiResponse = mock(ApiResponse::class.java) val mockApiResponse = mock(ApiResponse::class.java)
val mockResponse = Response.success(mockApiResponse) val mockResponse = Response.success(mockApiResponse)
//WHEN //WHEN
Mockito.`when`(api.getUsersFromApi("12345")).thenReturn( Mockito.`when`(api.getUsersFromApi(input)).thenReturn(
mockResponse mockResponse
) )
Mockito.`when`(prefs.getLastSavedAt("12345")).thenReturn(System.currentTimeMillis()) Mockito.`when`(prefs.getLastSavedAt(input)).thenReturn(System.currentTimeMillis())
// THEN // THEN
val getUser = repository.getUsersFromApi("12345") val getUser = repository.getUsersFromApi(input)
assertEquals(getUser, null) assertEquals(getUser, null)
} }

View File

@@ -2,14 +2,11 @@ package com.example.h_mal.stackexchangeusers.ui.main
import androidx.arch.core.executor.testing.InstantTaskExecutorRule import androidx.arch.core.executor.testing.InstantTaskExecutorRule
import androidx.lifecycle.LiveData import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData import com.example.h_mal.stackexchangeusers.data.network.response.ApiResponse
import androidx.lifecycle.Observer
import com.example.h_mal.stackexchangeusers.data.network.model.ApiResponse
import com.example.h_mal.stackexchangeusers.data.network.model.User
import com.example.h_mal.stackexchangeusers.data.repositories.Repository import com.example.h_mal.stackexchangeusers.data.repositories.Repository
import com.example.h_mal.stackexchangeusers.data.repositories.RepositoryImpl import com.example.h_mal.stackexchangeusers.data.room.entities.UserEntity
import com.example.h_mal.stackexchangeusers.data.room.entities.UserItem
import com.example.h_mal.stackexchangeusers.ui.MainViewModel import com.example.h_mal.stackexchangeusers.ui.MainViewModel
import kotlinx.coroutines.delay
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Before import org.junit.Before
@@ -22,8 +19,6 @@ import org.mockito.Mockito.mock
import org.mockito.MockitoAnnotations import org.mockito.MockitoAnnotations
import java.io.IOException import java.io.IOException
import kotlin.test.assertFalse import kotlin.test.assertFalse
import kotlin.test.assertNotNull
import kotlin.test.assertTrue
class MainViewModelTest { class MainViewModelTest {
@@ -38,8 +33,11 @@ class MainViewModelTest {
@Before @Before
fun setUp() { fun setUp() {
MockitoAnnotations.initMocks(this) MockitoAnnotations.initMocks(this)
viewModel = MainViewModel(repository)
val mockLiveData = object: LiveData<List<UserEntity>>(){}
Mockito.`when`(repository.getUsersFromDatabase()).thenReturn(mockLiveData)
viewModel = MainViewModel(repository)
} }
@Test @Test
@@ -52,11 +50,8 @@ class MainViewModelTest {
//THEN //THEN
viewModel.getUsers("12345") viewModel.getUsers("12345")
viewModel.operationState.observeForever{ delay(50)
it.getContentIfNotHandled()?.let {result -> assertFalse { viewModel.operationState.value?.getContentIfNotHandled()!! }
assertFalse { result }
}
}
} }
@Test @Test
@@ -65,11 +60,8 @@ class MainViewModelTest {
Mockito.`when`(repository.getUsersFromApi("12345")).thenAnswer{ throw IOException("throwed") } Mockito.`when`(repository.getUsersFromApi("12345")).thenAnswer{ throw IOException("throwed") }
// THEN // THEN
viewModel.getUsers("fsdfsdf") viewModel.getUsers("12345")
viewModel.operationError.observeForever{ delay(50)
it.getContentIfNotHandled()?.let {result -> assertEquals (viewModel.operationError.value?.getContentIfNotHandled()!!, "throwed")
assertEquals(result, "throwed")
}
}
} }
} }