Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package com.example.metasearch.core.data.impl.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.Qualifier
import javax.inject.Singleton

@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class IoDispatcher

@Module
@InstallIn(SingletonComponent::class)
object CoroutineModule {

@Provides
@Singleton
@IoDispatcher
fun provideIoDispatcher(): CoroutineDispatcher = Dispatchers.IO
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import android.util.Log
import com.example.metasearch.core.common.utils.normalizePhoneNumber
import com.example.metasearch.core.data.api.repository.DatabaseNameRepository
import com.example.metasearch.core.data.api.repository.PersonRepository
import com.example.metasearch.core.data.impl.di.IoDispatcher
import com.example.metasearch.core.data.impl.mapper.toModel
import com.example.metasearch.core.model.PersonModel
import com.example.metasearch.core.network.request.ChangeNameRequest
Expand All @@ -18,7 +19,11 @@ import com.example.metasearch.core.network.service.WebService
import com.example.metasearch.core.room.api.dao.PersonDao
import com.example.metasearch.core.room.api.entity.FaceEntity
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
Expand All @@ -32,6 +37,7 @@ class PersonRepositoryImpl @Inject constructor(
private val aiService: AIService,
private val webService: WebService,
private val databaseNameRepository: DatabaseNameRepository,
@IoDispatcher private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO,
@ApplicationContext private val context: Context,
) : PersonRepository {
private val tag = "PersonRepoImpl"
Expand Down Expand Up @@ -153,16 +159,23 @@ class PersonRepositoryImpl @Inject constructor(
it.serverName to it.actualName
}

override suspend fun deleteAnalyzedPerson(person: PersonModel): Result<Unit> = withContext(Dispatchers.IO) {
override suspend fun deleteAnalyzedPerson(person: PersonModel): Result<Unit> = withContext(ioDispatcher) {
runCatching {
val dbName = databaseNameRepository.getPersistentDeviceDatabaseName()

webService.deleteEntity(DeleteEntityRequest(dbName, person.inputName))
coroutineScope {
val webDelete = async {
webService.deleteEntity(DeleteEntityRequest(dbName, person.inputName))
}
val aiDelete = async {
aiService.deletePerson(
dbName.toRequestBody("text/plain".toMediaTypeOrNull()),
person.name.toRequestBody("text/plain".toMediaTypeOrNull()),
)
}

aiService.deletePerson(
dbName.toRequestBody("text/plain".toMediaTypeOrNull()),
person.name.toRequestBody("text/plain".toMediaTypeOrNull()),
)
awaitAll(webDelete, aiDelete)
}

personDao.deletePersonById(person.id)
}
Expand All @@ -187,7 +200,7 @@ class PersonRepositoryImpl @Inject constructor(
newPhone: String,
isHome: Boolean,
faceId: Long?,
): Result<Long> = withContext(Dispatchers.IO) {
): Result<Long> = withContext(ioDispatcher) {
runCatching {
val currentPersonEntity = personDao.getPersonById(personId)
val oldName = currentPersonEntity?.inputName ?: ""
Expand Down Expand Up @@ -217,7 +230,7 @@ class PersonRepositoryImpl @Inject constructor(
personDao.updateRepresentativeFace(personId, faceId)
}

override suspend fun changePersonNameOnServer(oldName: String, newName: String): Result<Unit> = withContext(Dispatchers.IO) {
override suspend fun changePersonNameOnServer(oldName: String, newName: String): Result<Unit> = withContext(ioDispatcher) {
runCatching {
val dbName = databaseNameRepository.getPersistentDeviceDatabaseName()

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
package com.example.metasearch.core.data.impl.repository

import android.content.Context
import com.example.metasearch.core.data.api.repository.DatabaseNameRepository
import com.example.metasearch.core.model.PersonModel
import com.example.metasearch.core.network.service.AIService
import com.example.metasearch.core.network.service.WebService
import com.example.metasearch.core.room.api.dao.PersonDao
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.mockito.Mock
import org.mockito.MockitoAnnotations
import org.mockito.kotlin.any
import org.mockito.kotlin.never
import org.mockito.kotlin.verify
import org.mockito.kotlin.whenever

@OptIn(ExperimentalCoroutinesApi::class)
class PersonRepositoryImplTest {

@Mock private lateinit var mockDao: PersonDao

@Mock private lateinit var mockAiService: AIService

@Mock private lateinit var mockWebService: WebService

@Mock private lateinit var mockDbRepo: DatabaseNameRepository

@Mock private lateinit var mockContext: Context

private val testDispatcher = StandardTestDispatcher()
private lateinit var repository: PersonRepositoryImpl

private val fakePerson = PersonModel(id = 1L, name = "test_uuid", inputName = "홍길동")

@BeforeEach
fun setUp() {
MockitoAnnotations.openMocks(this)
// 생성자 주입을 통해 테스트 디스패처 전달
repository = PersonRepositoryImpl(
personDao = mockDao,
aiService = mockAiService,
webService = mockWebService,
databaseNameRepository = mockDbRepo,
ioDispatcher = testDispatcher,
context = mockContext,
)
}
Comment on lines +41 to +53
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major

Close MockitoAnnotations to prevent resource leaks.

MockitoAnnotations.openMocks() returns an AutoCloseable that should be closed after each test to avoid resource leaks.

♻️ Suggested fix
+import org.junit.jupiter.api.AfterEach
+
 @OptIn(ExperimentalCoroutinesApi::class)
 class PersonRepositoryImplTest {
 
     @Mock private lateinit var mockDao: PersonDao
     // ... other mocks ...

     private val testDispatcher = StandardTestDispatcher()
     private lateinit var repository: PersonRepositoryImpl
+    private lateinit var closeable: AutoCloseable

     private val fakePerson = PersonModel(id = 1L, name = "test_uuid", inputName = "홍길동")

     @BeforeEach
     fun setUp() {
-        MockitoAnnotations.openMocks(this)
+        closeable = MockitoAnnotations.openMocks(this)
         repository = PersonRepositoryImpl(
             // ...
         )
     }
+
+    @AfterEach
+    fun tearDown() {
+        closeable.close()
+    }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
@BeforeEach
fun setUp() {
MockitoAnnotations.openMocks(this)
// 생성자 주입을 통해 테스트 디스패처 전달
repository = PersonRepositoryImpl(
personDao = mockDao,
aiService = mockAiService,
webService = mockWebService,
databaseNameRepository = mockDbRepo,
ioDispatcher = testDispatcher,
context = mockContext,
)
}
private lateinit var closeable: AutoCloseable
private val fakePerson = PersonModel(id = 1L, name = "test_uuid", inputName = "홍길동")
@BeforeEach
fun setUp() {
closeable = MockitoAnnotations.openMocks(this)
// 생성자 주입을 통해 테스트 디스패처 전달
repository = PersonRepositoryImpl(
personDao = mockDao,
aiService = mockAiService,
webService = mockWebService,
databaseNameRepository = mockDbRepo,
ioDispatcher = testDispatcher,
context = mockContext,
)
}
@AfterEach
fun tearDown() {
closeable.close()
}
🤖 Prompt for AI Agents
In
@core/data/impl/src/test/java/com/example/metasearch/core/data/impl/repository/PersonRepositoryImplTest.kt
around lines 41 - 53, The test opens Mockito annotations in setUp via
MockitoAnnotations.openMocks(this) but never closes the returned AutoCloseable;
store the returned value (e.g., a field named mocks or openMocks) when calling
MockitoAnnotations.openMocks(this) inside PersonRepositoryImplTest.setUp and add
an @AfterEach tearDown method that calls mocks.close() (or safely closes it) to
prevent resource leaks.


@Test
fun `모든 서버 삭제가 성공하면 로컬 DB에서도 인물을 삭제한다`() = runTest(testDispatcher) {
// Given
whenever(mockDbRepo.getPersistentDeviceDatabaseName()).thenReturn("db_name")

// When
val result = repository.deleteAnalyzedPerson(fakePerson)
advanceUntilIdle() // 모든 비동기 작업(async)이 완료될 때까지 대기

// Then
assertTrue(result.isSuccess)
verify(mockWebService).deleteEntity(any())
verify(mockAiService).deletePerson(any(), any())
verify(mockDao).deletePersonById(fakePerson.id)
}

@Test
fun `서버 삭제 중 하나라도 실패하면 로컬 DB 삭제를 호출하지 않는다`() = runTest(testDispatcher) {
// Given
whenever(mockDbRepo.getPersistentDeviceDatabaseName()).thenReturn("db_name")
// AI 서비스에서 예외 발생 시나리오
whenever(mockAiService.deletePerson(any(), any())).thenThrow(RuntimeException("AI Server Error"))

// When
val result = repository.deleteAnalyzedPerson(fakePerson)
advanceUntilIdle()

// Then
assertTrue(result.isFailure)
// 서버가 터졌으므로 로컬 DB 삭제는 절대 실행되면 안 됨
verify(mockDao, never()).deletePersonById(fakePerson.id)
}
}
4 changes: 2 additions & 2 deletions feature/person/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@
<resources>
<string name="person_screen_header">인물</string>
<string name="person_search_text_field_placeholder">이름 입력</string>
<string name="person_delete_dialog_title">인물 등록 취소</string>
<string name="person_delete_dialog_title">인물 삭제 확인</string>
<string name="person_delete_dialog_content">\'%s\'님을 인물 목록에서 삭제할까요?</string>
<string name="person_delete_dialog_confirm_button">삭제</string>
<string name="person_delete_dialog_cancel_button">취소</string>
<string name="person_delete_failed_toast_message">인물 삭제 실패</string>
<string name="person_delete_failed_toast_message">삭제 실패 🚨 네트워크를 확인하거나 잠시후 다시 시도해주세요.</string>
</resources>