From 1d24f30ee18a11699361baac884e41927fed49d0 Mon Sep 17 00:00:00 2001 From: edv-Shin Date: Sun, 6 Sep 2026 14:15:50 +0900 Subject: [PATCH] =?UTF-8?q?chore:=20Room=20DAO=20=EB=A9=94=EB=AA=A8?= =?UTF-8?q?=EB=A6=AC=20DB=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EA=B8=B0?= =?UTF-8?q?=EB=B0=98=20=EC=B6=94=EA=B0=80=20#596?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- data/build.gradle.kts | 6 + .../ExerciseRecordLocalDataSourceTest.kt | 307 +++++++++++++----- .../project200/data/local/RoomTestDatabase.kt | 23 ++ .../data/local/dao/ExerciseCountDaoTest.kt | 120 +++++++ .../data/local/dao/ExerciseRecordDaoTest.kt | 278 ++++++++++++++++ .../src/test/resources/robolectric.properties | 2 + gradle/libs.versions.toml | 2 + 7 files changed, 659 insertions(+), 79 deletions(-) create mode 100644 data/src/test/java/com/project200/data/local/RoomTestDatabase.kt create mode 100644 data/src/test/java/com/project200/data/local/dao/ExerciseCountDaoTest.kt create mode 100644 data/src/test/java/com/project200/data/local/dao/ExerciseRecordDaoTest.kt create mode 100644 data/src/test/resources/robolectric.properties diff --git a/data/build.gradle.kts b/data/build.gradle.kts index 56f8abef..1b6d3f9b 100644 --- a/data/build.gradle.kts +++ b/data/build.gradle.kts @@ -18,6 +18,11 @@ android { buildConfigField("String", "BASE_URL", "\"https://api.undabang.site/\"") // Release용 URL }*/ } + + // Room 메모리 DB 테스트에 Robolectric이 android 리소스와 Context를 제공합니다 + testOptions { + unitTests.isIncludeAndroidResources = true + } } // Room 스키마를 파일로 남겨 마이그레이션 검증에 씁니다 @@ -49,6 +54,7 @@ dependencies { implementation(libs.androidx.datastore.preferences) testImplementation(libs.androidx.arch.core.testing) + testImplementation(libs.robolectric) implementation(libs.appauth) diff --git a/data/src/test/java/com/project200/data/datasource/ExerciseRecordLocalDataSourceTest.kt b/data/src/test/java/com/project200/data/datasource/ExerciseRecordLocalDataSourceTest.kt index 102c2b56..6d909a55 100644 --- a/data/src/test/java/com/project200/data/datasource/ExerciseRecordLocalDataSourceTest.kt +++ b/data/src/test/java/com/project200/data/datasource/ExerciseRecordLocalDataSourceTest.kt @@ -1,153 +1,302 @@ package com.project200.data.datasource -import androidx.room.withTransaction import com.google.common.truth.Truth.assertThat import com.project200.data.local.PreferenceManager import com.project200.data.local.UndabangDatabase -import com.project200.data.local.dao.ExerciseCountDao -import com.project200.data.local.dao.ExerciseRecordDao -import com.project200.data.local.dao.ServerIdLocalId +import com.project200.data.local.createInMemoryDatabase +import com.project200.data.local.entity.ExerciseListItemEntity +import com.project200.data.local.entity.ExerciseRecordDetailEntity import com.project200.data.local.entity.SyncState +import com.project200.domain.model.ExerciseCount import com.project200.domain.model.ExerciseListItem -import io.mockk.Runs -import io.mockk.coEvery -import io.mockk.coVerify +import com.project200.domain.model.ExerciseRecord import io.mockk.every -import io.mockk.impl.annotations.MockK -import io.mockk.junit4.MockKRule -import io.mockk.just -import io.mockk.mockkStatic -import io.mockk.slot -import kotlinx.coroutines.ExperimentalCoroutinesApi +import io.mockk.mockk import kotlinx.coroutines.test.runTest +import org.junit.After import org.junit.Before -import org.junit.Rule import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner import java.time.LocalDate import java.time.LocalDateTime -@ExperimentalCoroutinesApi +/** + * 메모리 DB로 캐시 계층을 검증합니다. + * + * DAO를 목킹하면 호출 인자만 확인할 수 있어서 SQL과 트랜잭션 결과는 검증되지 않습니다. + * 대기 행 보존은 틀려도 화면에 드러나지 않으므로 실제 DB로 확인합니다 + */ +@RunWith(RobolectricTestRunner::class) class ExerciseRecordLocalDataSourceTest { - @get:Rule - val mockkRule = MockKRule(this) - - @MockK private lateinit var database: UndabangDatabase - - @MockK - private lateinit var exerciseCountDao: ExerciseCountDao - - @MockK(relaxUnitFun = true) - private lateinit var exerciseRecordDao: ExerciseRecordDao - - @MockK private lateinit var preferenceManager: PreferenceManager - private lateinit var dataSource: ExerciseRecordLocalDataSource + private val date = LocalDate.parse("2026-09-10") + private val otherDate = LocalDate.parse("2026-09-11") + @Before fun setUp() { - // withTransaction은 RoomDatabase 확장 함수라 블록을 그대로 실행하도록 바꿉니다 - mockkStatic("androidx.room.RoomDatabaseKt") - val transaction = slot Any>() - coEvery { database.withTransaction(capture(transaction)) } coAnswers { transaction.captured.invoke() } + database = createInMemoryDatabase() + preferenceManager = mockk() every { preferenceManager.getMemberId() } returns MEMBER_ID + dataSource = + ExerciseRecordLocalDataSource( + database = database, + exerciseCountDao = database.exerciseCountDao(), + exerciseRecordDao = database.exerciseRecordDao(), + preferenceManager = preferenceManager, + ) + } - coEvery { exerciseRecordDao.getPendingServerIds(any(), any(), any()) } returns emptyList() - coEvery { exerciseRecordDao.getSyncedByServerIds(any(), any(), any()) } returns emptyList() - coEvery { exerciseRecordDao.getSyncedByDate(any(), any(), any()) } returns emptyList() + @After + fun tearDown() { + database.close() + } - dataSource = - ExerciseRecordLocalDataSource(database, exerciseCountDao, exerciseRecordDao, preferenceManager) + private fun serverItem(recordId: Long) = + ExerciseListItem( + recordId = recordId, + title = "제목 $recordId", + type = "WEIGHT", + startTime = LocalDateTime.parse("2026-09-10T10:00:00"), + endTime = LocalDateTime.parse("2026-09-10T11:00:00"), + imageUrl = null, + ) + + private fun record() = + ExerciseRecord( + title = "제목", + detail = "본문", + personalType = "WEIGHT", + startedAt = LocalDateTime.parse("2026-09-10T10:00:00"), + endedAt = LocalDateTime.parse("2026-09-10T11:00:00"), + location = "헬스장", + pictures = null, + ) + + private suspend fun insertRow( + localId: String, + serverId: Long?, + syncState: SyncState, + date: LocalDate = this.date, + ) { + database.exerciseRecordDao().upsertListItems( + listOf( + ExerciseListItemEntity( + memberId = MEMBER_ID, + localId = localId, + serverId = serverId, + syncState = syncState, + date = date, + sortOrder = 0, + title = "로컬 $localId", + personalType = "WEIGHT", + startedAt = LocalDateTime.parse("2026-09-10T09:00:00"), + endedAt = LocalDateTime.parse("2026-09-10T09:30:00"), + thumbnailUrls = null, + ), + ), + ) } + private suspend fun insertDetail( + localId: String, + serverId: Long?, + ) { + database.exerciseRecordDao().upsertDetail( + ExerciseRecordDetailEntity( + memberId = MEMBER_ID, + localId = localId, + serverId = serverId, + syncState = SyncState.SYNCED, + title = "상세 $localId", + detail = "본문", + personalType = "WEIGHT", + startedAt = LocalDateTime.parse("2026-09-10T10:00:00"), + endedAt = LocalDateTime.parse("2026-09-10T11:00:00"), + location = "헬스장", + pictures = null, + ), + ) + } + + // ── 서버 목록 반영 ──────────────────────────────── + @Test - fun `전송 대기 행이 걸린 기록은 서버 값으로 덮이지 않는다`() = + fun `목록 반영 - 전송 대기 행이 걸린 기록은 서버 값으로 덮이지 않는다`() = runTest { - // Given: 기록 2는 아직 못 올린 로컬 변경이 걸려 있다 - coEvery { exerciseRecordDao.getPendingServerIds(MEMBER_ID, DATE, SyncState.SYNCED) } returns listOf(2L) - val captured = slot>() - coEvery { exerciseRecordDao.upsertListItems(capture(captured)) } just Runs + // Given: 서버 기록 2번에 로컬 수정이 걸려 있다 + insertRow("pending-2", serverId = 2L, syncState = SyncState.UPDATE_PENDING) + insertRow("synced-1", serverId = 1L, syncState = SyncState.SYNCED) // When - dataSource.replaceSyncedListByDate(DATE, listOf(itemOf(1L), itemOf(2L), itemOf(3L))) + dataSource.replaceSyncedListByDate( + date, + listOf(serverItem(1L), serverItem(2L), serverItem(3L)), + ) - // Then: 서버가 준 기록 2는 반영 대상에서 빠진다 - assertThat(captured.captured.map { it.serverId }).containsExactly(1L, 3L) + // Then: 2번은 로컬 값 그대로 남고 서버 값이 들어오지 않는다 + val rows = database.exerciseRecordDao().getListByDate(MEMBER_ID, date) + val pending = rows.single { it.serverId == 2L } + assertThat(pending.localId).isEqualTo("pending-2") + assertThat(pending.syncState).isEqualTo(SyncState.UPDATE_PENDING) + assertThat(pending.title).isEqualTo("로컬 pending-2") + assertThat(rows.map { it.serverId }).containsExactly(1L, 2L, 3L) } @Test - fun `이미 캐시된 기록은 localId를 이어 쓴다`() = + fun `목록 반영 - 이미 캐시된 기록은 localId를 이어 쓴다`() = runTest { // Given - coEvery { exerciseRecordDao.getSyncedByServerIds(MEMBER_ID, listOf(1L), SyncState.SYNCED) } returns - listOf(ServerIdLocalId(serverId = 1L, localId = "keep-me")) - val captured = slot>() - coEvery { exerciseRecordDao.upsertListItems(capture(captured)) } just Runs + insertRow("keep-me", serverId = 1L, syncState = SyncState.SYNCED) + insertDetail("keep-me", serverId = 1L) + + // When + dataSource.replaceSyncedListByDate(date, listOf(serverItem(1L))) + // Then: 상세와 화면이 가리키던 행이 유지된다 + val row = database.exerciseRecordDao().getListByDate(MEMBER_ID, date).single() + assertThat(row.localId).isEqualTo("keep-me") + assertThat(database.exerciseRecordDao().getDetailByLocalId(MEMBER_ID, "keep-me")).isNotNull() + } + + @Test + fun `목록 반영 - 처음 보는 기록은 새 localId를 받는다`() = + runTest { // When - dataSource.replaceSyncedListByDate(DATE, listOf(itemOf(1L))) + dataSource.replaceSyncedListByDate(date, listOf(serverItem(9L))) // Then - assertThat(captured.captured.single().localId).isEqualTo("keep-me") + val row = database.exerciseRecordDao().getListByDate(MEMBER_ID, date).single() + assertThat(row.localId).isNotEmpty() + assertThat(row.syncState).isEqualTo(SyncState.SYNCED) + assertThat(row.sortOrder).isEqualTo(0) } @Test - fun `처음 보는 기록은 새 localId를 받는다`() = + fun `목록 반영 - 서버 목록에서 빠진 기록은 상세도 지운다`() = runTest { // Given - val captured = slot>() - coEvery { exerciseRecordDao.upsertListItems(capture(captured)) } just Runs + insertRow("gone", serverId = 5L, syncState = SyncState.SYNCED) + insertDetail("gone", serverId = 5L) // When - dataSource.replaceSyncedListByDate(DATE, listOf(itemOf(9L))) + dataSource.replaceSyncedListByDate(date, listOf(serverItem(6L))) // Then - assertThat(captured.captured.single().localId).isNotEmpty() - assertThat(captured.captured.single().syncState).isEqualTo(SyncState.SYNCED) + assertThat(database.exerciseRecordDao().getDetailByLocalId(MEMBER_ID, "gone")).isNull() + assertThat(database.exerciseRecordDao().getListByDate(MEMBER_ID, date).map { it.serverId }) + .containsExactly(6L) } @Test - fun `서버 목록에서 빠진 기록은 상세도 지운다`() = + fun `목록 반영 - 날짜가 바뀐 기록은 옛 날짜 행을 남기지 않는다`() = runTest { - // Given: 캐시에는 1, 2가 있는데 서버는 1만 준다 - coEvery { exerciseRecordDao.getSyncedByDate(MEMBER_ID, DATE, SyncState.SYNCED) } returns - listOf(ServerIdLocalId(1L, "local-1"), ServerIdLocalId(2L, "local-2")) - coEvery { exerciseRecordDao.upsertListItems(any()) } just Runs + // Given: 서버에서 기록 4번의 날짜가 바뀐 상황 + insertRow("moved", serverId = 4L, syncState = SyncState.SYNCED, date = date) + + // When + dataSource.replaceSyncedListByDate(otherDate, listOf(serverItem(4L))) + + // Then: 같은 serverId가 두 날짜에 남지 않는다 + assertThat(database.exerciseRecordDao().getListByDate(MEMBER_ID, date)).isEmpty() + val moved = database.exerciseRecordDao().getListByDate(MEMBER_ID, otherDate).single() + assertThat(moved.localId).isEqualTo("moved") + } + + @Test + fun `목록 반영 - 회원ID가 없으면 아무것도 쓰지 않는다`() = + runTest { + // Given + every { preferenceManager.getMemberId() } returns null // When - dataSource.replaceSyncedListByDate(DATE, listOf(itemOf(1L))) + dataSource.replaceSyncedListByDate(date, listOf(serverItem(1L))) // Then - coVerify(exactly = 1) { exerciseRecordDao.deleteDetailsByLocalIds(MEMBER_ID, listOf("local-2")) } + assertThat(database.exerciseRecordDao().getListByDate(MEMBER_ID, date)).isEmpty() + } + + // ── 읽기 ──────────────────────────────── + + @Test + fun `목록 읽기 - 서버 ID가 없는 행은 아직 걸러진다`() = + runTest { + // Given + insertRow("local-only", serverId = null, syncState = SyncState.CREATE_PENDING) + insertRow("synced", serverId = 1L, syncState = SyncState.SYNCED) + + // When + val result = dataSource.getListByDate(date) + + // Then: 화면이 서버 ID로 기록을 식별하는 동안의 동작이다 (#584에서 바뀐다) + assertThat(result.map { it.recordId }).containsExactly(1L) } @Test - fun `회원ID가 없으면 아무것도 쓰지 않는다`() = + fun `목록 읽기 - 회원ID가 없으면 빈 목록을 돌려준다`() = runTest { // Given + insertRow("synced", serverId = 1L, syncState = SyncState.SYNCED) every { preferenceManager.getMemberId() } returns null + // When & Then + assertThat(dataSource.getListByDate(date)).isEmpty() + } + + // ── 캘린더 집계 ──────────────────────────────── + + @Test + fun `집계 반영 - 구간을 비우고 다시 채운다`() = + runTest { + // Given: 0건이 된 날이 남지 않아야 한다 + dataSource.replaceCountsByRange( + date, + otherDate, + listOf(ExerciseCount(date, 2), ExerciseCount(otherDate, 1)), + ) + // When - dataSource.replaceSyncedListByDate(DATE, listOf(itemOf(1L))) + dataSource.replaceCountsByRange(date, otherDate, listOf(ExerciseCount(date, 3))) // Then - coVerify(exactly = 0) { exerciseRecordDao.upsertListItems(any()) } - coVerify(exactly = 0) { exerciseRecordDao.deleteSyncedListByDate(any(), any(), any()) } + val result = dataSource.getCountsByRange(date, otherDate) + assertThat(result.map { it.date to it.count }).containsExactly(date to 3) } - private fun itemOf(recordId: Long) = - ExerciseListItem( - recordId = recordId, - title = "달리기", - type = "PERSONAL", - startTime = LocalDateTime.parse("2026-08-17T10:00:00"), - endTime = LocalDateTime.parse("2026-08-17T11:00:00"), - imageUrl = null, - ) + // ── 상세와 삭제 ──────────────────────────────── + + @Test + fun `상세 저장 - 목록에 있는 기록이면 그 localId를 쓴다`() = + runTest { + // Given + insertRow("list-row", serverId = 3L, syncState = SyncState.SYNCED) + + // When + dataSource.saveSyncedDetail(3L, record()) + + // Then + assertThat(database.exerciseRecordDao().getDetailByLocalId(MEMBER_ID, "list-row")).isNotNull() + } + + @Test + fun `기록 삭제 - 목록과 상세를 함께 지운다`() = + runTest { + // Given + insertRow("target", serverId = 1L, syncState = SyncState.SYNCED) + insertDetail("target", serverId = 1L) + + // When + dataSource.deleteRecord("target") + + // Then + assertThat(database.exerciseRecordDao().getListByDate(MEMBER_ID, date)).isEmpty() + assertThat(database.exerciseRecordDao().getDetailByLocalId(MEMBER_ID, "target")).isNull() + } companion object { private const val MEMBER_ID = "member-a" - private val DATE: LocalDate = LocalDate.parse("2026-08-17") } } diff --git a/data/src/test/java/com/project200/data/local/RoomTestDatabase.kt b/data/src/test/java/com/project200/data/local/RoomTestDatabase.kt new file mode 100644 index 00000000..114e3c5a --- /dev/null +++ b/data/src/test/java/com/project200/data/local/RoomTestDatabase.kt @@ -0,0 +1,23 @@ +package com.project200.data.local + +import androidx.room.Room +import com.squareup.moshi.Moshi +import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory +import org.robolectric.RuntimeEnvironment + +/** + * 메모리 DB를 엽니다. + * + * 실제 스키마와 TypeConverter를 그대로 써서 DAO의 SQL을 검증합니다. + * `allowMainThreadQueries`는 테스트에서 `runTest`로 바로 호출하기 위한 것입니다 + */ +fun createInMemoryDatabase(): UndabangDatabase { + val moshi = Moshi.Builder().add(KotlinJsonAdapterFactory()).build() + return Room.inMemoryDatabaseBuilder( + RuntimeEnvironment.getApplication(), + UndabangDatabase::class.java, + ) + .addTypeConverter(UndabangTypeConverters(moshi)) + .allowMainThreadQueries() + .build() +} diff --git a/data/src/test/java/com/project200/data/local/dao/ExerciseCountDaoTest.kt b/data/src/test/java/com/project200/data/local/dao/ExerciseCountDaoTest.kt new file mode 100644 index 00000000..c7c40776 --- /dev/null +++ b/data/src/test/java/com/project200/data/local/dao/ExerciseCountDaoTest.kt @@ -0,0 +1,120 @@ +package com.project200.data.local.dao + +import com.google.common.truth.Truth.assertThat +import com.project200.data.local.UndabangDatabase +import com.project200.data.local.createInMemoryDatabase +import com.project200.data.local.entity.ExerciseCountEntity +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import java.time.LocalDate + +@RunWith(RobolectricTestRunner::class) +class ExerciseCountDaoTest { + private lateinit var database: UndabangDatabase + private lateinit var dao: ExerciseCountDao + + @Before + fun setUp() { + database = createInMemoryDatabase() + dao = database.exerciseCountDao() + } + + @After + fun tearDown() { + database.close() + } + + private fun count( + memberId: String, + date: String, + count: Int = 1, + ) = ExerciseCountEntity(memberId = memberId, date = LocalDate.parse(date), count = count) + + @Test + fun `범위 조회 - 시작일과 종료일을 포함한다`() = + runTest { + // Given + dao.upsertAll( + listOf( + count("member-a", "2026-08-31"), + count("member-a", "2026-09-01"), + count("member-a", "2026-09-15"), + count("member-a", "2026-09-30"), + count("member-a", "2026-10-01"), + ), + ) + + // When + val result = dao.getRange("member-a", LocalDate.parse("2026-09-01"), LocalDate.parse("2026-09-30")) + + // Then: 경계 날짜는 들어오고 바깥 날짜는 빠진다 + assertThat(result.map { it.date.toString() }) + .containsExactly("2026-09-01", "2026-09-15", "2026-09-30") + .inOrder() + } + + @Test + fun `범위 조회 - 다른 계정의 행은 나오지 않는다`() = + runTest { + // Given + dao.upsertAll( + listOf( + count("member-a", "2026-09-10", count = 2), + count("member-b", "2026-09-10", count = 5), + ), + ) + + // When + val result = dao.getRange("member-a", LocalDate.parse("2026-09-01"), LocalDate.parse("2026-09-30")) + + // Then + assertThat(result).hasSize(1) + assertThat(result.single().count).isEqualTo(2) + } + + @Test + fun `범위 삭제 - 구간 밖과 다른 계정은 남는다`() = + runTest { + // Given + dao.upsertAll( + listOf( + count("member-a", "2026-08-31"), + count("member-a", "2026-09-01"), + count("member-a", "2026-09-30"), + count("member-a", "2026-10-01"), + count("member-b", "2026-09-15"), + ), + ) + + // When + dao.deleteRange("member-a", LocalDate.parse("2026-09-01"), LocalDate.parse("2026-09-30")) + + // Then + val remainingA = + dao.getRange("member-a", LocalDate.parse("2026-01-01"), LocalDate.parse("2026-12-31")) + assertThat(remainingA.map { it.date.toString() }).containsExactly("2026-08-31", "2026-10-01") + + val remainingB = + dao.getRange("member-b", LocalDate.parse("2026-01-01"), LocalDate.parse("2026-12-31")) + assertThat(remainingB).hasSize(1) + } + + @Test + fun `저장 - 같은 계정과 날짜는 개수를 덮어쓴다`() = + runTest { + // Given + dao.upsertAll(listOf(count("member-a", "2026-09-10", count = 1))) + + // When + dao.upsertAll(listOf(count("member-a", "2026-09-10", count = 4))) + + // Then: PK가 (memberId, date)라 행이 늘지 않는다 + val result = dao.getRange("member-a", LocalDate.parse("2026-09-10"), LocalDate.parse("2026-09-10")) + assertThat(result).hasSize(1) + assertThat(result.single().count).isEqualTo(4) + } +} diff --git a/data/src/test/java/com/project200/data/local/dao/ExerciseRecordDaoTest.kt b/data/src/test/java/com/project200/data/local/dao/ExerciseRecordDaoTest.kt new file mode 100644 index 00000000..b136876b --- /dev/null +++ b/data/src/test/java/com/project200/data/local/dao/ExerciseRecordDaoTest.kt @@ -0,0 +1,278 @@ +package com.project200.data.local.dao + +import com.google.common.truth.Truth.assertThat +import com.project200.data.local.UndabangDatabase +import com.project200.data.local.createInMemoryDatabase +import com.project200.data.local.entity.ExerciseListItemEntity +import com.project200.data.local.entity.ExerciseRecordDetailEntity +import com.project200.data.local.entity.SyncState +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import java.time.LocalDate +import java.time.LocalDateTime + +@RunWith(RobolectricTestRunner::class) +class ExerciseRecordDaoTest { + private lateinit var database: UndabangDatabase + private lateinit var dao: ExerciseRecordDao + + @Before + fun setUp() { + database = createInMemoryDatabase() + dao = database.exerciseRecordDao() + } + + @After + fun tearDown() { + database.close() + } + + private val date = LocalDate.parse("2026-09-10") + private val otherDate = LocalDate.parse("2026-09-11") + + private fun listItem( + localId: String, + serverId: Long?, + syncState: SyncState = SyncState.SYNCED, + memberId: String = "member-a", + date: LocalDate = this.date, + sortOrder: Int = 0, + ) = ExerciseListItemEntity( + memberId = memberId, + localId = localId, + serverId = serverId, + syncState = syncState, + date = date, + sortOrder = sortOrder, + title = "제목 $localId", + personalType = "WEIGHT", + startedAt = LocalDateTime.parse("2026-09-10T10:00:00"), + endedAt = LocalDateTime.parse("2026-09-10T11:00:00"), + thumbnailUrls = listOf("https://example.com/$localId.jpg"), + ) + + private fun detail( + localId: String, + serverId: Long?, + memberId: String = "member-a", + ) = ExerciseRecordDetailEntity( + memberId = memberId, + localId = localId, + serverId = serverId, + syncState = SyncState.SYNCED, + title = "제목 $localId", + detail = "본문", + personalType = "WEIGHT", + startedAt = LocalDateTime.parse("2026-09-10T10:00:00"), + endedAt = LocalDateTime.parse("2026-09-10T11:00:00"), + location = "헬스장", + pictures = null, + ) + + // ── 목록 조회 ──────────────────────────────── + + @Test + fun `날짜별 조회 - 삭제 대기 행은 빼고 sortOrder로 정렬한다`() = + runTest { + // Given + dao.upsertListItems( + listOf( + listItem("b", serverId = 2L, sortOrder = 1), + listItem("a", serverId = 1L, sortOrder = 0), + listItem("c", serverId = 3L, syncState = SyncState.DELETE_PENDING, sortOrder = 2), + ), + ) + + // When + val result = dao.getListByDate("member-a", date) + + // Then + assertThat(result.map { it.localId }).containsExactly("a", "b").inOrder() + } + + @Test + fun `날짜별 조회 - 다른 계정과 다른 날짜는 나오지 않는다`() = + runTest { + // Given + dao.upsertListItems( + listOf( + listItem("mine", serverId = 1L), + listItem("other-member", serverId = 2L, memberId = "member-b"), + listItem("other-date", serverId = 3L, date = otherDate), + ), + ) + + // When + val result = dao.getListByDate("member-a", date) + + // Then + assertThat(result.map { it.localId }).containsExactly("mine") + } + + @Test + fun `날짜별 조회 - 서버 ID가 없는 생성 대기 행도 돌려준다`() = + runTest { + // Given: 오프라인에서 만든 기록은 서버 ID를 받기 전이다 + dao.upsertListItems(listOf(listItem("local-only", serverId = null, syncState = SyncState.CREATE_PENDING))) + + // When + val result = dao.getListByDate("member-a", date) + + // Then: DAO는 걸러내지 않는다. 화면 표시 여부는 상위 계층이 정한다 + assertThat(result.map { it.serverId }).containsExactly(null) + } + + // ── 전송 대기 행 ──────────────────────────────── + + @Test + fun `대기 서버ID 조회 - SYNCED가 아닌 행의 서버 ID만 돌려준다`() = + runTest { + // Given + dao.upsertListItems( + listOf( + listItem("synced", serverId = 1L), + listItem("updating", serverId = 2L, syncState = SyncState.UPDATE_PENDING), + listItem("deleting", serverId = 3L, syncState = SyncState.DELETE_PENDING), + listItem("creating", serverId = null, syncState = SyncState.CREATE_PENDING), + ), + ) + + // When + val result = dao.getPendingServerIds("member-a", date) + + // Then: 서버 ID가 없는 생성 대기 행은 비교 대상이 아니다 + assertThat(result).containsExactly(2L, 3L) + } + + @Test + fun `동기화 목록 삭제 - 전송 대기 행은 남긴다`() = + runTest { + // Given + dao.upsertListItems( + listOf( + listItem("synced", serverId = 1L), + listItem("updating", serverId = 2L, syncState = SyncState.UPDATE_PENDING), + listItem("creating", serverId = null, syncState = SyncState.CREATE_PENDING), + ), + ) + + // When + dao.deleteSyncedListByDate("member-a", date) + + // Then + assertThat(dao.getListByDate("member-a", date).map { it.localId }) + .containsExactly("updating", "creating") + } + + @Test + fun `동기화 목록 조회 - 대기 행과 서버 ID 없는 행을 제외한다`() = + runTest { + // Given + dao.upsertListItems( + listOf( + listItem("synced", serverId = 1L), + listItem("updating", serverId = 2L, syncState = SyncState.UPDATE_PENDING), + listItem("creating", serverId = null, syncState = SyncState.CREATE_PENDING), + ), + ) + + // When + val result = dao.getSyncedByDate("member-a", date) + + // Then + assertThat(result.map { it.serverId }).containsExactly(1L) + } + + // ── localId 이어쓰기 ──────────────────────────────── + + @Test + fun `서버ID로 localId 조회 - 날짜를 가리지 않고 최신 날짜 행을 집는다`() = + runTest { + // Given: 서버에서 기록의 날짜가 바뀌어 두 날짜에 남은 상태 + dao.upsertListItems( + listOf( + listItem("old-row", serverId = 7L, date = date), + listItem("new-row", serverId = 7L, date = otherDate), + ), + ) + + // When + val result = dao.findListItemLocalId("member-a", 7L) + + // Then + assertThat(result).isEqualTo("new-row") + } + + @Test + fun `다른 날짜 정리 - 같은 서버ID의 옛 행만 지운다`() = + runTest { + // Given + dao.upsertListItems( + listOf( + listItem("old-row", serverId = 7L, date = date), + listItem("kept-other-server", serverId = 8L, date = date), + listItem("new-row", serverId = 7L, date = otherDate), + ), + ) + + // When + dao.deleteSyncedOnOtherDates("member-a", otherDate, listOf(7L)) + + // Then + assertThat(dao.getListByDate("member-a", date).map { it.localId }) + .containsExactly("kept-other-server") + assertThat(dao.getListByDate("member-a", otherDate).map { it.localId }) + .containsExactly("new-row") + } + + // ── 상세 ──────────────────────────────── + + @Test + fun `상세 조회 - 계정 조건이 걸린다`() = + runTest { + // Given + dao.upsertDetail(detail("shared-local-id", serverId = 1L, memberId = "member-b")) + + // When & Then + assertThat(dao.getDetailByLocalId("member-a", "shared-local-id")).isNull() + assertThat(dao.getDetailByServerId("member-a", 1L)).isNull() + assertThat(dao.getDetailByLocalId("member-b", "shared-local-id")).isNotNull() + } + + @Test + fun `상세 일괄 삭제 - 지정한 localId만 지운다`() = + runTest { + // Given + dao.upsertDetail(detail("gone", serverId = 1L)) + dao.upsertDetail(detail("kept", serverId = 2L)) + + // When + dao.deleteDetailsByLocalIds("member-a", listOf("gone")) + + // Then + assertThat(dao.getDetailByLocalId("member-a", "gone")).isNull() + assertThat(dao.getDetailByLocalId("member-a", "kept")).isNotNull() + } + + @Test + fun `상세 저장 - 사진 목록이 JSON으로 왕복한다`() = + runTest { + // Given + val pictures = + listOf( + com.project200.data.local.entity.CachedPicture(id = 11L, url = "https://example.com/1.jpg"), + com.project200.data.local.entity.CachedPicture(id = 12L, url = "https://example.com/2.jpg"), + ) + + // When + dao.upsertDetail(detail("with-pictures", serverId = 1L).copy(pictures = pictures)) + + // Then: 순서까지 유지된다 + val saved = dao.getDetailByLocalId("member-a", "with-pictures") + assertThat(saved?.pictures?.map { it.id }).containsExactly(11L, 12L).inOrder() + } +} diff --git a/data/src/test/resources/robolectric.properties b/data/src/test/resources/robolectric.properties new file mode 100644 index 00000000..5f90e97b --- /dev/null +++ b/data/src/test/resources/robolectric.properties @@ -0,0 +1,2 @@ +# targetSdk 36은 Robolectric 4.14가 아직 지원하지 않아 34로 고정합니다 +sdk=34 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 7971e396..d2c79dcb 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -63,6 +63,7 @@ androidxTestEspresso = "3.6.1" mockk = "1.13.10" turbine = "1.1.0" # Flow 테스트용 truth = "1.1.5" +robolectric = "4.14.1" # 단위 테스트에서 Room 메모리 DB를 열기 위함 jacoco = "0.8.11" # 테스트 커버리지 측정 플러그인 ktlintPlugin = "12.1.1" ktlintCli = "1.2.1" @@ -199,6 +200,7 @@ androidx-arch-core-testing = { group = "androidx.arch.core", name = "core-testin mockk = { group = "io.mockk", name = "mockk", version.ref = "mockk" } turbine = { group = "app.cash.turbine", name = "turbine", version.ref = "turbine" } truth = { group = "com.google.truth", name = "truth", version.ref = "truth" } +robolectric = { group = "org.robolectric", name = "robolectric", version.ref = "robolectric" } ktlint-gradle-plugin = { group = "org.jlleitschuh.gradle", name = "ktlint-gradle", version.ref = "ktlintPlugin" } ktlint-cli = { group = "com.pinterest.ktlint", name = "ktlint-cli", version.ref = "ktlintCli" }